/* FixCamera console.

   One set of variables for every page. The palette is carried over from the
   original status page (validated for colour-vision separation and contrast);
   light and dark are both chosen explicitly rather than inverted. */

:root {
  color-scheme: light;
  --plane:      #f9f9f7;
  --surface:    #fcfcfb;
  --border:     #e4e3df;
  /* Input borders (select, text / date / number fields). --border is 1.25:1
     on a card, which left a field findable only by its hover; this one is
     >= 3:1 on both --surface and --plane (VID-ui-07). Cards and rules keep
     the quieter --border. */
  --control-border: #8a8883;
  --ink:        #0b0b0b;
  --ink-2:      #52514e;
  --muted:      #6e6d68;
  --series-1:   #2a78d6;
  --series-2:   #eb6834;
  --accent:     var(--series-1);
  --accent-ink: #ffffff;
  --good:       #0ca30c;
  --warning:    #fab219;
  --serious:    #ec835a;
  --critical:   #d03b3b;

  /* Text-safe half of the palette. The saturated tokens above stay on bars,
     dots, borders and chart strokes; anything the eye has to *read* takes
     the matching *-text token, which is picked per theme for >= 4.5:1 on
     that theme's surface. --accent-fill is the one that goes behind white
     (primary button, pressed segment). */
  --accent-fill:   #2468c4;
  --accent-text:   #2468c4;
  --good-text:     #0a7d0a;
  --warning-text:  #8a5a00;
  --serious-text:  #a8471a;
  /* console-batch01-p2 T02 (CONS-C-06): #d03b3b was 4.68:1 on --surface;
     #b83232 is 5.78:1 on --surface and 5.63:1 on --plane. */
  --critical-text: #b83232;
  /* T28 (CAM-ui-05): the 3px attention bars on camera parameter rows. On a
     light card the saturated --warning / --serious are 1.79:1 / 2.57:1, under
     the 3:1 a non-text mark needs, so light takes the text tones (>= 5.5:1);
     dark keeps the saturated hues it has always drawn. */
  --warning-bar:   #8a5a00;
  --serious-bar:   #a8471a;

  /* Type scale. px, not rem: the page's own base is 15px while rem is 16px,
     and mixing the two was how twenty-two different sizes got here. Seven
     steps, and every rule below picks one of them. */
  --fs-xs:      12px;   /* reading labels, units, group captions, pills */
  --fs-sm:      13px;   /* meta lines, form labels, table headers, badges */
  --fs-md:      14px;   /* table body, description lists, .note, 相机参数行头 .param-name (600) */
  --fs-base:    15px;   /* body text, buttons, inputs */
  --fs-lg:      17px;   /* card title h2, panel and modal titles */
  --fs-xl:      20px;   /* page title h2, brand, card readings */
  --fs-num:     24px;   /* the big tile reading */

  /* 4px grid. 2px is still allowed for inline nudges (a dot's baseline, a
     two-line stack inside one row); anything that separates blocks uses a
     step below, and --gap stays the name for "between two blocks". */
  --sp-1:       4px;
  --sp-2:       8px;
  --sp-3:       12px;
  --sp-4:       16px;
  --sp-5:       20px;
  --sp-6:       24px;

  --radius-sm:  6px;    /* controls, inline boxes */
  --radius:     8px;    /* cards, modals, drawers */
  --radius-pill: 999px; /* tags and flags (was 10px and 12px) */

  --shadow-pop:   0 4px 16px rgba(0, 0, 0, .12);   /* toast, tooltip */
  --shadow-modal: 0 12px 32px rgba(0, 0, 0, .35);  /* modal panel */
  --overlay:      rgba(0, 0, 0, .45);              /* every backdrop */
  /* A panel that floats above the page (.modal). Light already separates it
     with the shadow, so it is the card colour; dark lifts it a step. */
  --surface-raised: var(--surface);

  --control-h:       36px;  /* .btn, select, input all measure this */
  --control-h-touch: 44px;  /* a control a thumb has to hit, on narrow screens */

  --gap:        var(--sp-4);
  --nav-width:  200px;
  --topbar-h:   56px;
  --content-max: 1100px; /* the content column; the top bar ends where it does */
  --form-col:   8.5em;  /* label column of .form-stacked.is-columned (127.5px at 15px) */
  /* Latin first: on Windows the CJK face would otherwise claim the digits and
     the Latin text too, which costs the readings their tabular figures. */
  --font: system-ui, "Segoe UI", -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --plane:    #0d0d0d;
    --surface:  #1a1a19;
    --border:   #2e2e2c;
    --control-border: #74736d;
    --ink:      #ffffff;
    --ink-2:    #c3c2b7;
    --muted:    #898781;
    --series-1: #3987e5;
    --series-2: #d95926;

    --accent-fill:   #2468c4;
    --accent-text:   #3987e5;
    --good-text:     #0ca30c;
    --warning-text:  #fab219;
    --serious-text:  #ec835a;
    --critical-text: #f26d6d;
    --warning-bar:   #fab219;
    --serious-bar:   #ec835a;

    /* AP-ui-08 / OV-ui-08: a modal has to read as above the page. A lighter
       panel (#1f1f1e keeps --muted and --accent-text >= 4.5:1 on it), a
       heavier backdrop, and a light hairline, because a black shadow on a
       near-black page shows nothing. */
    --surface-raised: #1f1f1e;
    --overlay:        rgba(0, 0, 0, .6);
    --shadow-modal:   0 0 0 1px rgba(255, 255, 255, .12), 0 16px 48px rgba(0, 0, 0, .6);

    /* A floating panel (the ? tip, the time zone list, toast, more-menu, chart tip, map marker and bubble) has the
       modal's problem: its black shadow is invisible on a near-black page, so in dark it floated no higher than the card
       under it. The same light hairline in front of the same shadow as before. */
    --shadow-pop:     0 0 0 1px rgba(255, 255, 255, .12), 0 4px 16px rgba(0, 0, 0, .12);
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --plane:    #0d0d0d;  --surface: #1a1a19;  --border: #2e2e2c;
  --control-border: #74736d;
  --ink:      #ffffff;  --ink-2:   #c3c2b7;  --muted:  #898781;
  --series-1: #3987e5;  --series-2: #d95926;

  --accent-fill:   #2468c4;  --accent-text:   #3987e5;
  --good-text:     #0ca30c;  --warning-text:  #fab219;
  --serious-text:  #ec835a;  --critical-text: #f26d6d;
  --warning-bar:   #fab219;  --serious-bar:   #ec835a;

  /* Same four as the prefers-color-scheme block above. */
  --surface-raised: #1f1f1e;
  --overlay:        rgba(0, 0, 0, .6);
  --shadow-modal:   0 0 0 1px rgba(255, 255, 255, .12), 0 16px 48px rgba(0, 0, 0, .6);
  --shadow-pop:     0 0 0 1px rgba(255, 255, 255, .12), 0 4px 16px rgba(0, 0, 0, .12);
}

* { box-sizing: border-box; }
/* overflow-x: clip blocks sideways overflow the same way hidden does, but it
   does not turn the page into a scroll container, so `position: sticky`
   keeps working (save bars, the photo selection bar). `hidden` stays in
   front as the fallback for browsers without clip. */
html, body { max-width: 100%; overflow-x: hidden; overflow-x: clip; }
body {
  margin: 0; background: var(--plane); color: var(--ink);
  font: var(--fs-base)/1.6 var(--font);
}
[hidden] { display: none !important; }
/* Two title sizes: a page is --fs-xl, and the first heading of a card, panel,
   drawer or modal is an h2 at --fs-lg (17/600/--ink). h2 defaults to the card
   size because most of the h2s on these pages are card headings; .page-head h2
   below takes it back up. .sub-head (14/600/--ink-2, the photos section below)
   is not a third title size: it only names a second-level group *inside* a card
   or drawer (h3.sub-head), never the card itself (CONS-A-01). */
h1, h2, h3 { font-weight: 600; margin: 0; }
h1 { font-size: var(--fs-xl); }
h2, h3 { font-size: var(--fs-lg); }
h2 { margin-bottom: var(--sp-2); }
/* The browser's own 700 is the eighth weight on the page; b and strong take
   the same 600 every other emphasised run of text uses. */
b, strong { font-weight: 600; }
.muted { color: var(--muted); }
.spacer { flex: 1; }

/* --------------------------------------------------------------- controls */
button, select, input {
  font: inherit; color: var(--ink); max-width: 100%;
}
/* Every field is 15/400 (CONS-A-03), whatever its container is set to: a
   13px toolbar or a 14px value column used to hand its size down through the
   inherit above. A compact toolbar makes its fields smaller by padding only. */
select, input:not([type="checkbox"]):not([type="radio"]), textarea {
  font-size: var(--fs-base); font-weight: 400;
}
/* One height for every control. The button is inline-flex so its label sits
   in the middle of that height no matter what the padding or the font is;
   select and input get vertically centred text from the browser once the box
   is taller than the line. Checkboxes and radios keep their own 16px box. */
.btn, select,
input:not([type="checkbox"]):not([type="radio"]) { min-height: var(--control-h); }
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--sp-2); text-align: center;
}
.btn {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-sm); padding: var(--sp-1) var(--sp-3); cursor: pointer;
}
/* A select is a field, not a button: same well colour and border as the
   text, date and number inputs beside it (VID-ui-07), so a row of them reads
   as one kind of thing. */
select {
  background: var(--plane); border: 1px solid var(--control-border);
  border-radius: var(--radius-sm); padding: var(--sp-1) var(--sp-3); cursor: pointer;
}
/* A disabled control answers nothing to the mouse (CONS-C-02). The exclusion
   sits in :where() so these keep the specificity of a bare :hover: a pressed,
   primary, danger or invalid border declared later still wins over them. */
.btn:hover:where(:not(:disabled)), select:hover:where(:not(:disabled)),
input.text:hover:where(:not(:disabled)), input.num:hover:where(:not(:disabled)) { border-color: var(--muted); }
.btn:disabled, select:disabled { cursor: default; opacity: .6; }
.btn.primary { background: var(--accent-fill); color: var(--accent-ink); border-color: var(--accent-fill); }
/* The primary button darkens the way the danger button does (.admin-danger-go). */
.btn.primary:hover:not(:disabled) { filter: brightness(.92); }
.btn.link {
  background: none; border: none; color: var(--ink-2);
  text-decoration: underline; text-underline-offset: 3px; padding: var(--sp-1) 0;
}
/* .btn:hover only moves the border colour, and a link button has no border,
   so without this a link button is the one control on the page that answers
   nothing to the mouse. */
.btn.link:hover:where(:not(:disabled)) { color: var(--ink); }
a:focus-visible, button:focus-visible, select:focus-visible, input:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

/* ------------------------------------------------------------------- boot */
.boot { max-width: 420px; margin: 18vh auto; padding: 0 var(--sp-5); text-align: center; }

/* ------------------------------------------------------------------ login */
.login {
  width: calc(100% - 32px); max-width: 340px; margin: 12vh auto;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--sp-6);
}
.login h1 { margin-bottom: var(--sp-1); }
.login p { font-size: var(--fs-sm); margin: 0 0 var(--sp-5); }
.login label { display: block; font-size: var(--fs-sm); color: var(--ink-2); margin: var(--sp-3) 0 var(--sp-1); }
.login input {
  width: 100%; padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm);
  border: 1px solid var(--control-border); background: var(--plane);
}
.login button[type=submit] { width: 100%; margin-top: var(--sp-5); }
.err { color: var(--critical-text); font-size: var(--fs-sm); margin: var(--sp-3) 0 0; }

/* ------------------------------------------------------------------ shell */
.shell {
  display: grid; min-height: 100vh;
  grid-template-columns: var(--nav-width) minmax(0, 1fr);
  grid-template-rows: auto 1fr;
  grid-template-areas: "top top" "nav main";
}
.topbar {
  grid-area: top; display: flex; align-items: center; flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4); padding: var(--sp-2) var(--sp-5); min-height: var(--topbar-h);
  background: var(--surface); border-bottom: 1px solid var(--border);
}
/* Wider than nav + content column, the bar's last item (退出) used to sit
   100px right of the content's edge at 1440 (OV-ui-14). The right padding
   grows by whatever lies beyond the column, so both end on the same line;
   percentages here resolve against the full-width grid area. Below that
   width max() keeps the usual 20px, and <=720px sets its own padding. */
.topbar { padding-right: max(var(--sp-5), calc(100% - var(--nav-width) - var(--sp-5) - var(--content-max))); }
.brand { font-weight: 600; font-size: var(--fs-xl); }
.pickers { display: flex; gap: var(--sp-2) var(--sp-4); flex-wrap: wrap; min-width: 0; }
.picker { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.picker label { font-size: var(--fs-sm); color: var(--ink-2); white-space: nowrap; }
.picker select { min-width: 0; max-width: 240px; }
.user { font-size: var(--fs-sm); overflow-wrap: anywhere; }
/* Name, 修改密码 and 退出 are one block. On a wide bar that changes
   nothing; on a narrow one it stops "退出" from wrapping alone to the
   line directly under the "菜单" button, which is what made it easy to
   hit by accident. */
.user-actions { display: flex; align-items: center; gap: var(--sp-4); min-width: 0; }
/* 修改密码 and 退出 never give up width. Chinese breaks between any two
   characters, so a link button squeezed by 2px folded into "修改密 / 码" and
   "退 / 出" and took the bar from 56 to 73px (R1-OV-ui-01). The name, capped
   and ellipsised below 1024, is the one item that gives way. */
.user-actions > .btn { flex: none; white-space: nowrap; }
.nav-toggle { display: none; }

.sidenav {
  grid-area: nav; background: var(--surface);
  border-right: 1px solid var(--border); padding: var(--sp-3) 0;
}
.sidenav ul { list-style: none; margin: 0; padding: 0; }
.sidenav a {
  display: block; padding: var(--sp-2) var(--sp-5); color: var(--ink-2); text-decoration: none;
  border-left: 3px solid transparent;
}
.sidenav a:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 6%, transparent); }
.sidenav a[aria-current="page"] {
  color: var(--ink); font-weight: 600; border-left-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
}

.content { grid-area: main; padding: var(--sp-5); min-width: 0; outline: none; }
.content > * { max-width: var(--content-max); }

/* ---------------------------------------------------------- shared blocks */
.card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--sp-4); margin-bottom: var(--gap);
}
.empty { color: var(--muted); padding: var(--sp-5) 0; text-align: center; margin: 0; }
.error-box {
  background: var(--surface); border: 1px solid var(--critical);
  border-radius: var(--radius); padding: var(--sp-4); margin-bottom: var(--gap);
  display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
}
.error-text { margin: 0; color: var(--critical-text); }

/* ----------------------------------------------------------------- toasts */
.toasts {
  position: fixed; right: var(--sp-4); bottom: var(--sp-4); z-index: 50;
  display: flex; flex-direction: column; gap: var(--sp-2);
  max-width: calc(100vw - 32px); width: 320px; pointer-events: none;
}
.toast {
  pointer-events: auto; background: var(--surface); color: var(--ink);
  border: 1px solid var(--border); border-left: 4px solid var(--accent);
  border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); font-size: var(--fs-md);
  box-shadow: var(--shadow-pop); overflow-wrap: anywhere;
}
.toast.success { border-left-color: var(--good); }
.toast.warning { border-left-color: var(--warning); }
.toast.error   { border-left-color: var(--critical); }

/* ------------------------------------------------------------- page head */
.page-head {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3);
  min-height: calc(var(--control-h) + var(--sp-3) + 1px);
  padding-bottom: var(--sp-3); margin-bottom: var(--gap); border-bottom: 1px solid var(--border);
}
/* The one h2 that is a page title rather than a card title. */
.page-head h2 { margin: 0; font-size: var(--fs-xl); font-weight: 700; line-height: 1.6; overflow-wrap: anywhere; }
.meta { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
/* Online state: words and glyph carry the meaning, colour only reinforces. */
.badge { font-size: var(--fs-sm); white-space: nowrap; }
.badge.online  { color: var(--good-text); }
.badge.offline { color: var(--muted); }

/* Voltage status line (tiles and overview cards). */
.s { font-size: var(--fs-sm); margin: var(--sp-1) 0 0; display: flex; align-items: center; gap: var(--sp-1); }
.s .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.s.good     { color: var(--good-text); }
.s.warning  { color: var(--warning-text); }
.s.serious  { color: var(--serious-text); }
.s.critical { color: var(--critical-text); }
.s.good .dot     { background: var(--good); }
.s.warning .dot  { background: var(--warning); }
.s.serious .dot  { background: var(--serious); }
.s.critical .dot { background: var(--critical); }

/* --------------------------------------------------------------- overview */
.cam-grid {
  display: grid; gap: var(--sp-3);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
}
.cam-card {
  display: block; color: inherit; text-decoration: none; min-width: 0;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--sp-4);
}
.cam-card:hover { border-color: var(--muted); }
.cam-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); }
.cam-head h3 { overflow-wrap: anywhere; }
.cam-card .meta { margin: var(--sp-1) 0 0; }
.readings {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-2);
  margin: var(--sp-3) 0 0;
}
.reading dt { color: var(--muted); font-size: var(--fs-xs); }
.reading dd {
  margin: 0; font-size: var(--fs-xl); font-weight: 600; font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
.reading dd small, .tile .v small {
  font-size: var(--fs-xs); font-weight: 400; color: var(--ink-2); margin-left: 3px;
}
.gaps.failed { color: var(--critical-text); }

/* ---------------------------------------------------------- device status */
.tiles {
  display: grid; gap: var(--sp-3); margin-bottom: var(--gap);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr));
}
.tile {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--sp-4); min-width: 0;
}
.tile .k { color: var(--muted); font-size: var(--fs-xs); letter-spacing: .04em; }
.tile .v {
  font-size: var(--fs-num); font-weight: 600; margin-top: 2px;
  font-variant-numeric: tabular-nums; letter-spacing: -.01em;
}
.filters { display: flex; gap: var(--sp-2); align-items: center; margin-bottom: var(--gap); flex-wrap: wrap; }
/* The group used to carry `overflow: hidden` so the pressed button's fill
   stopped at the rounded corner. That also cut the 2px focus ring the buttons
   draw 2px outside themselves, leaving a keyboard user with a single blue
   sliver between two buttons. The rounding now lives on the first and last
   button instead, so nothing clips and the ring shows on all four sides.
   Drawing the ring inside (outline-offset: -2px) was the other candidate and
   was rejected: on the pressed button that paints --accent on --accent-fill,
   which is the one button where the ring would vanish. */
.seg {
  display: inline-flex; flex-wrap: wrap; border: 1px solid var(--border);
  border-radius: var(--radius-sm); background: var(--surface);
}
/* The group border adds the missing 2px, so a segmented control measures the
   same --control-h as the buttons and selects standing next to it. */
.seg button {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: calc(var(--control-h) - 2px);
  border: none; background: none; padding: var(--sp-1) var(--sp-3);
  cursor: pointer; border-radius: 0;
}
.seg button:first-child { border-radius: calc(var(--radius-sm) - 1px) 0 0 calc(var(--radius-sm) - 1px); }
.seg button:last-child { border-radius: 0 calc(var(--radius-sm) - 1px) calc(var(--radius-sm) - 1px) 0; }
/* --plane 与 --surface 在浅色下只差 1.02:1，悬停等于没反应；跟侧导航的
   hover 用同一个 ink 6% 叠色，两套主题下都看得出来。 */
.seg button:not([aria-pressed="true"]):hover:where(:not(:disabled)) { background: color-mix(in srgb, var(--ink) 6%, transparent); }
.seg button[aria-pressed="true"] { background: var(--accent-fill); color: var(--accent-ink); }

.chart {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--sp-4); margin-bottom: var(--gap);
}
.chart h2 { margin: 0 0 2px; }
.chart .sub { color: var(--muted); font-size: var(--fs-sm); margin: 0 0 var(--sp-3); }
.legend { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); font-size: var(--fs-sm); color: var(--ink-2); margin-bottom: var(--sp-2); }
.legend span { display: inline-flex; align-items: center; gap: var(--sp-2); }
.legend i { width: 14px; height: 2px; border-radius: 1px; }
.legend-event b { color: var(--critical-text); font-weight: 400; font-size: var(--fs-xs); }
/* 图表的绘制区。ResizeObserver 观察的就是它，量到的宽度即 svg 的宽度（卡片
   的 padding 不在内）；min-width:0 保证它在 flex/grid 里能收缩到容器宽度，
   图表才不会把页面撑出横向滚动条。 */
.chart-plot { min-width: 0; max-width: 100%; }
/* viewBox 现在按容器的实际像素宽度生成（lib/chart.js chartLayout），所以
   width:100% 是 1:1，height:auto 取的就是 viewBox 里那个固定像素高度，
   .chart-axis 的 11px 在任何视口下都是屏幕上的 11px。 */
.chart svg { display: block; width: 100%; height: auto; overflow: visible; touch-action: pan-y; }
.chart-grid { stroke: var(--border); stroke-width: 1; }
.chart-axis { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.chart-line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart-band { fill-opacity: .15; stroke: none; }
.chart-event { fill: var(--critical); stroke: var(--surface); stroke-width: 1.5; }
.chart-crosshair { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 3 3; }
/* A ring in the surface colour keeps a marker legible on its own line. */
.chart-marker { stroke: var(--surface); stroke-width: 2; }

.chart-tip {
  position: fixed; pointer-events: none; z-index: 40; max-width: calc(100vw - 16px);
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
  padding: var(--sp-2) var(--sp-3); font-size: var(--fs-sm); box-shadow: var(--shadow-pop);
  font-variant-numeric: tabular-nums;
}
.chart-tip b { display: block; color: var(--muted); font-weight: 400; font-size: var(--fs-xs); margin-bottom: 3px; }
.chart-tip .row { display: flex; align-items: center; gap: var(--sp-2); }
.chart-tip .row i { width: 10px; height: 2px; border-radius: 1px; flex: none; }
.chart-tip .row.event { color: var(--critical-text); }

.scroll { overflow-x: auto; max-width: 100%; }
.chart table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.chart th, .chart td { text-align: right; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); white-space: nowrap; }
.chart th:first-child, .chart td:first-child { text-align: left; }
.chart th { color: var(--muted); font-weight: 600; }
.chart td.note-event { color: var(--critical-text); text-align: left; }

/* --------------------------------------------------------- settings pages */
.notice {
  display: flex; align-items: flex-start; gap: var(--sp-2); margin: 0 0 var(--gap);
  padding: var(--sp-3) var(--sp-4); border-radius: var(--radius);
  background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--warning);
}
.notice-icon { flex: none; font-style: normal; font-size: var(--fs-base); }
.settings-status { display: grid; gap: var(--sp-1); }
.st-row { margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); overflow-wrap: anywhere; }
.st-label { color: var(--ink-2); min-width: 5em; }
/* 下发行图标按级别着色（data-level），见文件末尾 console-batch01-p2 T08 段；这里只是没带
   data-level 时的缺省，与未带级别类的 .notice 同为 warning（FD2）。 */
.st-delivery .notice-icon { color: var(--warning-text); }
.st-device { color: var(--ink-2); }
.settings-notes ul { margin: 0; padding-left: 1.2em; color: var(--ink-2); font-size: var(--fs-md); }

.form-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); margin: var(--sp-2) 0; }
.check { display: inline-flex; align-items: center; gap: var(--sp-2); }
.check input { width: 16px; height: 16px; margin: 0; }
.field { display: inline-flex; align-items: center; gap: var(--sp-2); }
.field label { font-size: var(--fs-sm); color: var(--ink-2); }
input.num {
  width: 6.5em; padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm);
  border: 1px solid var(--control-border); background: var(--plane);
}
input:disabled { opacity: .6; }
.btn.small { padding: var(--sp-1) var(--sp-2); font-size: var(--fs-sm); }

.win-toolbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-2);
  padding: var(--sp-2) var(--sp-3); margin: var(--sp-1) 0 var(--sp-2);
  background: var(--plane); border: 1px solid var(--border); border-radius: var(--radius-sm);
  font-size: var(--fs-sm);
}
.win-toolbar select { padding: 3px var(--sp-2); }
.win-copy-done { font-size: var(--fs-sm); }
.win-list { display: grid; gap: 2px; }
.win-row {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3);
  padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
}
.win-row:last-child { border-bottom: none; }
.win-day { width: 2.5em; font-weight: 600; }
.win-row .field-error { flex-basis: 100%; }
.win-hint { font-size: var(--fs-sm); }

/* Errors: a cross and words, not colour alone. */
.field-error { color: var(--critical-text); font-size: var(--fs-sm); }
.field-error p { margin: 2px 0; }
.field-error p::before { content: "✕ "; }

.tb-legend { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-sm); color: var(--ink-2); margin: 0 0 var(--sp-2); }
.tb-legend span { display: inline-flex; align-items: center; gap: var(--sp-2); }
.tb-swatch { display: inline-block; width: 18px; height: 10px; border-radius: 2px; }
.tb-list { display: grid; gap: var(--sp-1); }
.tb-row { display: grid; grid-template-columns: 2.5em minmax(0, 1fr); align-items: center; gap: var(--sp-2); }
.tb-day { font-size: var(--fs-sm); color: var(--ink-2); }
.tb-bar {
  position: relative; height: 22px; border-radius: 3px; overflow: hidden;
  background-color: var(--plane); border: 1px solid var(--border);
  background-image: linear-gradient(to right, var(--border) 1px, transparent 1px);
  background-size: calc(100% / 24) 100%;
}
.tb-seg { position: absolute; }
.tb-seg.tb-capture { top: 0; height: 50%; }
.tb-seg.tb-upload  { bottom: 0; height: 50%; }
/* Capture is solid, upload is hatched: the two differ by pattern and by
   position (top/bottom half), so colour is not the only cue. */
.tb-capture { background: var(--series-1); }
.tb-upload {
  background: repeating-linear-gradient(45deg, var(--series-2) 0 3px, transparent 3px 6px);
  box-shadow: inset 0 0 0 1px var(--series-2);
}
.tb-scale { position: relative; height: 1.2em; font-size: var(--fs-xs); color: var(--muted); }
.tb-scale span { position: absolute; transform: translateX(-50%); }
.tb-scale span:first-child { transform: none; }
.tb-scale span:last-child { transform: translateX(-100%); }

.warn-list { list-style: none; margin: 0 0 var(--gap); padding: 0; }
.warn-list li {
  display: flex; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2);
  background: var(--surface); border: 1px solid var(--border);
  border-left: 4px solid var(--warning); border-radius: var(--radius-sm);
}
.error-box.conflict { flex-direction: column; align-items: flex-start; gap: var(--sp-2); }
.error-box.conflict p { margin: 0; }

/* The storage page builds its form into fixed slots (pages/storage.js); the
   save slot is taken out of the box tree so the sticky bar inside it belongs
   to .settings-page and can travel the full page height. */
.storage-save-slot { display: contents; }

.save-bar {
  position: sticky; bottom: 0; z-index: 5;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--gap);
  box-shadow: 0 -2px 12px rgba(0, 0, 0, .06);
}
.save-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
.dirty { color: var(--serious-text); font-size: var(--fs-md); display: inline-flex; gap: var(--sp-2); align-items: center; }
.save-result:empty { display: none; }
.save-line { margin: var(--sp-2) 0 0; font-size: var(--fs-sm); overflow-wrap: anywhere; }
.save-line.success { color: var(--good-text); }
.save-line.error { color: var(--critical-text); }
.save-line.success::before { content: "✓ "; }
.save-line.error::before { content: "✕ "; }

.drawer-root { position: fixed; inset: 0; z-index: 60; }
.drawer-backdrop { position: absolute; inset: 0; background: var(--overlay); }
.drawer {
  position: absolute; top: 0; right: 0; bottom: 0;
  width: min(420px, 100%); overflow-y: auto; outline: none;
  background: var(--surface); border-left: 1px solid var(--border); padding: var(--sp-4);
  box-shadow: -8px 0 24px rgba(0, 0, 0, .14);
}
.drawer-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2); }
.drawer-head h2 { margin: 0; }
.drawer-note { margin: 0 0 var(--sp-3); }
.hist-list { list-style: none; margin: 0; padding: 0; }
.hist-item { padding: var(--sp-3) 0; border-bottom: 1px solid var(--border); }
.hist-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); }
.hist-tag {
  font-size: var(--fs-xs); color: var(--ink-2); border: 1px solid var(--border);
  border-radius: var(--radius-pill); padding: 0 var(--sp-2);
}
.hist-changes { margin: var(--sp-2) 0 0; padding-left: 1.2em; font-size: var(--fs-sm); overflow-wrap: anywhere; }

/* camera / storage settings */
.notice-support { font-size: var(--fs-base); }
/* The mark's colour comes from the notice's level (.notice.<level>, console-batch01-p2
   T02 / T17 segments at the end), not from here. */
input.text {
  padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm);
  border: 1px solid var(--control-border); background: var(--plane);
}
input[aria-invalid="true"] { border-color: var(--critical); }
.path-root { margin: 0 0 var(--sp-2); display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); overflow-wrap: anywhere; }
.path-root code { font-size: var(--fs-md); }
.path-input { flex: 1 1 auto; min-width: 0; width: 100%; }
.path-ok { margin: 2px 0; font-size: var(--fs-sm); color: var(--good-text); overflow-wrap: anywhere; }
.path-ok:empty { display: none; }
/* 路径规则是 .note（14/400 --ink-2，console-batch01-p2 T18）；原来的 .path-row 行已并入
   .form-stacked.is-columned 的字段（文件末尾 T18 段）。 */
.path-rules { margin: var(--sp-2) 0 0; padding-left: 1.2em; }

/* ----------------------------------------------------------------- photos */
.sub-head { font-size: var(--fs-md); font-weight: 600; margin: 0 0 var(--sp-2); color: var(--ink-2); }
.folder-list { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-3); }
.folder-btn {
  display: inline-flex; flex-direction: column; align-items: flex-start; gap: 0;
  min-width: 0; max-width: 100%; padding: var(--sp-2) var(--sp-3); cursor: pointer; text-align: left;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
}
.folder-btn:hover:where(:not(:disabled)) { border-color: var(--muted); }
.folder-btn[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
/* The tick carries "selected"; the accent border only reinforces it. */
.folder-btn[aria-pressed="true"] .folder-name::before { content: "✓ "; }
.folder-name { font-weight: 600; overflow-wrap: anywhere; }
.folder-meta { font-size: var(--fs-xs); color: var(--muted); font-variant-numeric: tabular-nums; }
.photo-filters .hint { margin: var(--sp-2) 0 0; }

.sel-bar {
  position: sticky; top: 0; z-index: 5;
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--sp-2) var(--sp-3); margin-bottom: var(--gap);
}
.sel-count { font-weight: 600; font-variant-numeric: tabular-nums; }
.sel-hint { font-size: var(--fs-sm); color: var(--muted); }
.sel-hint.over { color: var(--critical-text); }

.photo-grid {
  list-style: none; margin: 0 0 var(--gap); padding: 0;
  display: grid; gap: var(--sp-3);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr));
}
.photo-tile {
  min-width: 0; background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); overflow: hidden;
}
.photo-tile.removed { border-style: dashed; }
.thumb-btn { display: block; width: 100%; padding: 0; border: none; background: none; cursor: zoom-in; }
/* The tile clips to its rounded corners, which used to eat three sides of the
   thumbnail button's focus ring. The ring moves inside the button: it lands on
   the photo, where 2px of --accent reads on any picture, and the tile keeps
   its rounding. */
.thumb-btn:focus-visible { outline-offset: -3px; }
.thumb {
  aspect-ratio: 4 / 3; width: 100%;
  display: flex; align-items: center; justify-content: center;
  background: var(--plane);
}
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-tile.removed .thumb img { opacity: .5; }
.thumb.unavailable { outline: 1px dashed var(--border); outline-offset: -6px; }
.thumb-text { font-size: var(--fs-sm); color: var(--muted); padding: var(--sp-1); text-align: center; }
.tile-foot { display: grid; gap: 2px; padding: var(--sp-2) var(--sp-2) var(--sp-2); font-size: var(--fs-sm); min-width: 0; }
.tile-foot .meta { font-size: var(--fs-xs); }
.tile-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ------------------------------------------------ one photo per day builder */
.daily-builder .form-row { margin-bottom: var(--sp-2); }
.daily-builder input.text, .daily-builder select { padding: var(--sp-1) var(--sp-2); }
.daily-result { margin-top: var(--sp-3); }
.daily-strip {
  display: grid; gap: var(--sp-2); margin: var(--sp-2) 0 var(--sp-3);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 112px), 1fr));
  max-height: 460px; overflow-y: auto; padding-right: var(--sp-1);
}
.daily-tile {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-sm); overflow: hidden; min-width: 0;
}
.daily-tile.missing { border-style: dashed; }
.daily-tile.missing .thumb-text { color: var(--serious-text); }
.daily-caption { font-size: var(--fs-xs); padding: var(--sp-1) var(--sp-2) var(--sp-2); overflow-wrap: anywhere; }
.daily-options { border-top: 1px solid var(--border); padding-top: var(--sp-3); margin-top: var(--sp-1); }
.tag-removed { color: var(--critical-text); font-size: var(--fs-xs); }
.more-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--gap); }

/* ----------------------------------------------------------------- energy */
.tile-hint { color: var(--muted); font-size: var(--fs-xs); margin-top: 2px; }
.energy-svg { display: block; width: 100%; height: auto; overflow: visible; }
.energy-hit { fill: transparent; }
.energy-measured { fill: var(--series-1); }
.energy-estimated { fill: var(--series-1); fill-opacity: .22; stroke: var(--series-1); stroke-opacity: .55; stroke-dasharray: 3 2; }
.energy-bar.in-progress .energy-measured { fill-opacity: .7; }
.energy-bar:focus { outline: none; }
.energy-bar.active .energy-hit,
.energy-bar:focus-visible .energy-hit { fill: var(--border); fill-opacity: .45; }
.energy-mark { fill: var(--serious-text); font-size: var(--fs-xs); font-weight: 600; }
.energy-bar.level-none .energy-mark { fill: var(--muted); }
.legend i.swatch { width: 12px; height: 10px; border-radius: 2px; }
.energy-measured-swatch { background: var(--series-1); }
.energy-estimated-swatch {
  background: color-mix(in srgb, var(--series-1) 22%, transparent);
  outline: 1px dashed color-mix(in srgb, var(--series-1) 55%, transparent); outline-offset: -1px;
}
.energy-mark-text { color: var(--serious-text); font-weight: 600; }
.energy-readout { margin: var(--sp-2) 0 var(--sp-2); font-size: var(--fs-sm); color: var(--ink-2); min-height: 1.3em; font-variant-numeric: tabular-nums; }
.chart tr[data-level="low"] td:nth-child(6) { color: var(--serious-text); }
.chart tr[data-level="none"] td { color: var(--muted); }
.energy-note { line-height: 1.6; margin: 0 0 var(--gap); }

.modal-root {
  position: fixed; inset: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center; padding: var(--sp-4);
}
.modal-backdrop { position: absolute; inset: 0; background: var(--overlay); }
.modal {
  position: relative; width: min(460px, 100%); max-height: calc(100vh - 32px); overflow-y: auto;
  background: var(--surface-raised); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow-modal); padding: var(--sp-4); outline: none;
}
.modal.wide { width: min(1000px, 100%); }
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-3); }
.modal-head h2 { margin: 0; overflow-wrap: anywhere; }
.preview { display: grid; gap: var(--sp-3); grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.preview-image {
  min-height: 200px; display: flex; align-items: center; justify-content: center;
  background: var(--plane); border-radius: var(--radius-sm);
}
.preview-image img { max-width: 100%; max-height: 70vh; display: block; }
.meta-list { margin: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-1) var(--sp-3); font-size: var(--fs-sm); }
.meta-list dt { color: var(--muted); }
.meta-list dd { margin: 0; overflow-wrap: anywhere; }
.video-form p { margin: 0 0 var(--sp-2); }

/* ------------------------------------------------------------ narrow view */
@media (max-width: 720px) {
  .shell {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto 1fr;
    grid-template-areas: "top" "nav" "main";
  }
  .topbar { padding: var(--sp-2) var(--sp-3); }
  .nav-toggle { display: inline-block; }
  /* Two lines and no more: 菜单 + 品牌 + 账号区 on the first, the two
     selectors sharing the second. The account block keeps margin-left
     auto so it stays at the far end of line one, as far from the
     "菜单" button as the bar is wide. */
  .user-actions { margin-left: auto; }
  /* A link button is text with no box, so its tap target was as narrow
     as the word. Padding widens it without moving the underline off
     the label. */
  .topbar .btn.link { padding-left: var(--sp-2); padding-right: var(--sp-2); }
  .pickers { order: 10; width: 100%; gap: var(--sp-2); }
  .picker { flex: 1 1 calc(50% - var(--sp-1)); }
  .picker select { flex: 1; width: 100%; max-width: none; }
  /* Control heights for a thumb are in the touch-targets block below. */
  .sidenav a { padding-top: var(--sp-3); padding-bottom: var(--sp-3); }
  .sidenav { display: none; border-right: none; border-bottom: 1px solid var(--border); padding: var(--sp-1) 0; }
  .sidenav.open { display: block; }
  .content { padding: var(--sp-3); }
  .toasts { right: 16px; left: 16px; width: auto; }
  .readings { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .tile .v { font-size: var(--fs-xl); }
  .seg button { padding: var(--sp-2) var(--sp-3); }
  .preview { grid-template-columns: minmax(0, 1fr); }
  .photo-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 130px), 1fr)); }
}

/* ---------------------------------------------------------- touch targets
   --control-h is sized for a mouse; a thumb needs the 44px one. The
   condition is a narrow screen *or* a coarse pointer: a 768px tablet is a
   touch screen too, and with the width alone it kept 36px buttons, selects
   and fields (EN-ux-05, VID-ux-10, AUD-ux-06, PH-ux-10). A mouse at 768px
   still gets 36px. Page blocks that size a control of their own use the
   same pair of conditions (storage radios, admin drawer below). */
@media (max-width: 720px), (pointer: coarse) {
  .btn, select,
  input:not([type="checkbox"]):not([type="radio"]) { min-height: var(--control-h-touch); }
  .seg button { min-height: calc(var(--control-h-touch) - 2px); }
  /* Check and radio rows: the 16px box stays, the row becomes the target.
     The label's ::before is stretched over the whole .check row, so a tap
     anywhere on it (above the box, on the gap, past the words) lands on the
     label, which toggles its box (STO-ux-12, AU-ux-13, AP-ux-06, PH-ux-10,
     VID-ux-10). Anything else living inside a row is lifted above that layer
     where its page block says so. */
  .check { position: relative; min-height: var(--control-h-touch); align-items: center; }
  .check > label { flex: 1 1 auto; }
  .check > label::before { content: ""; position: absolute; inset: 0; }
}

/* --------------------------------------------- top bar below desktop width */
/* Under 1024 the bar cannot hold the brand, two labelled selectors and the
   account block on one line any more, and the selector that loses the race is
   the camera one — the label gets cut at "联调整机 A0…", which throws away the
   number that tells the cameras apart. The two words "项目" and "相机" leave
   the screen (they stay in the accessibility tree, and the selected option
   already names a project or a camera on its own) and that width goes to the
   selects instead. */
@media (max-width: 1023px) {
  .picker label {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    border: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
  }
  /* A long display name used to decide on its own how many lines the bar has:
     it is laid out at full width first, and whatever no longer fits wraps.
     Capped and clipped it cannot, and the end of a long name is the part
     that identifies it least. The basis is the name itself, never more: a
     6em basis on a shorter name ("Li") made the block claim width its
     content never asked for, and the buttons paid for it. */
  .user { flex: 0 1 auto; max-width: 6em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
/* At 375 even that is not enough for a menu button, the brand, the name and
   two account entries on one line. The display name is the only one of them
   nobody taps, so it is what gives way; 修改密码 and 退出 stay where they are,
   at the far end of the line the "菜单" button starts. */
@media (max-width: 480px) {
  .topbar .user { display: none; }
}

/* ------------------------------------------- storage page: CM4 SSD status */
.cm4-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); margin-bottom: var(--sp-2); }
.cm4-head h2 { margin: 0; }
.cm4-updated { margin-left: auto; }
.cm4-status-wrap { margin-bottom: var(--sp-3); }
.cm4-when { font-variant-numeric: tabular-nums; }
.cm4-busy { color: var(--accent-text); font-weight: 600; }
.cm4-notice { margin: var(--sp-2) 0 0; overflow-wrap: anywhere; }
.cm4-warn { border-left-color: var(--serious); }
.cm4-warn .notice-icon { color: var(--serious-text); }
.cm4-error .error-box { margin-bottom: var(--sp-3); }

.cm4-capacity { margin-bottom: var(--sp-4); }
.cm4-figures { margin: 0 0 var(--sp-2); }
/* Track and fill use theme variables only; the percentage is also written
   out next to the bar, and a low SSD gets a text warning. */
.cm4-meter {
  position: relative; height: 12px; width: 100%; overflow: hidden;
  background: var(--plane); border: 1px solid var(--border); border-radius: var(--radius-sm);
}
.cm4-meter-fill { height: 100%; max-width: 100%; background: var(--accent); }
.cm4-meter.low { border-color: var(--serious); }
.cm4-meter.low .cm4-meter-fill { background: var(--serious); }
.cm4-meter-text { margin: var(--sp-1) 0 0; }

.cm4-empty { padding: var(--sp-2) 0 var(--sp-1); }
.cm4-empty p { margin: 0 0 var(--sp-1); }
.cm4-empty-title { font-weight: 600; }

.cm4-months .sub-head { margin-top: var(--sp-1); }
.cm4-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.cm4-table th, .cm4-table td {
  text-align: right; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); white-space: nowrap;
}
.cm4-table th:first-child, .cm4-table td:first-child { text-align: left; }
.cm4-table thead th { color: var(--muted); font-weight: 600; }
.cm4-table tbody th { font-weight: 600; }
.cm4-table tfoot th, .cm4-table tfoot td { font-weight: 600; border-bottom: none; border-top: 2px solid var(--border); }
.cm4-table td.cm4-action { text-align: right; padding-top: 3px; padding-bottom: 3px; }
/* 其他数据 (R13; retest R17 found it unstyled): 各月份数据's table, with the
   directory name and the 说明 column left-aligned and free to wrap — names
   and the CM4's reasons can be long — while sizes, counts and times keep
   .cm4-table's right-aligned nowrap. Narrow screens scroll inside
   .table-scroll, as the months do. 项目目录里的其他文件 (CM-06) is the same
   section and the same table, with a path for the name. */
.cm4-other-data, .cm4-project-extra { margin-top: var(--sp-3); }
.cm4-other-data .sub-head, .cm4-project-extra .sub-head { margin-top: var(--sp-1); }
.cm4-extra-unlisted { margin: var(--sp-1) 0 0; }
.cm4-breakdown { margin: var(--sp-1) 0 0; }
.cm4-other-table tbody th { white-space: normal; overflow-wrap: anywhere; min-width: 12em; }
.cm4-other-table th.cm4-other-note, .cm4-other-table td.cm4-other-note { text-align: left; white-space: normal; }
/* Between the phone cards and a wide table the row scrolls sideways, and a
   note left to shrink came out one character wide (还/有/3/张…, CM-06). */
.cm4-other-table td.cm4-other-note { min-width: 10em; }
.cm4-other-table thead th.cm4-action { text-align: right; }
/* Column names inside the cells, for the cards below only. */
.cm4-cell-label { display: none; }
/* Retest R18: at 480px and below each directory is a card instead of a row
   that only scrolled into view — name with 删除 at its top right, then
   size · files, then the last change, then the note when there is one. The
   same DOM as the table, laid out with grid; nothing scrolls sideways. The
   column names show inside the cells (directory and note to screen readers
   only), since a table drawn as cards may not be read as a table. */
@media (max-width: 480px) {
  .cm4-other-data .table-scroll, .cm4-project-extra .table-scroll { overflow-x: visible; }
  .cm4-other-table, .cm4-other-table tbody { display: block; width: 100%; }
  .cm4-other-table thead { display: none; }
  .cm4-other-table tr {
    display: grid; grid-template-columns: auto auto 1fr auto; column-gap: var(--sp-2); row-gap: 2px;
    grid-template-areas: "name name name act" "size files . act" "time time time time" "note note note note";
    padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
  }
  .cm4-other-table tbody tr > * { padding: 0; border-bottom: none; text-align: left; }
  .cm4-other-table th.cm4-other-name { grid-area: name; min-width: 0; align-self: center; }
  .cm4-other-table td.cm4-other-size { grid-area: size; }
  .cm4-other-table td.cm4-other-files { grid-area: files; }
  .cm4-other-table td.cm4-other-files::before { content: "· "; color: var(--muted); }
  .cm4-other-table td.cm4-other-time { grid-area: time; white-space: normal; }
  .cm4-other-table td.cm4-other-note { grid-area: note; min-width: 0; }
  .cm4-other-table td.cm4-action { grid-area: act; align-self: start; padding: 0; text-align: right; }
  .cm4-other-table td.is-empty { display: none; }
  .cm4-other-table .cm4-cell-label { display: inline; color: var(--muted); }
  .cm4-other-table .cm4-cell-label.is-sr {
    position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
  }
  .cm4-other-table .cm4-delete-dir { min-height: var(--control-h-touch); min-width: 56px; }
  .cm4-other-table td.cm4-other-note .cm4-flag { margin-left: 0; }
}
/* Rows that still owe photos: a tinted band, an edge mark and the word
   "需补传", so the highlight does not rely on colour. */
.cm4-table tr.cm4-need th, .cm4-table tr.cm4-need td {
  background: color-mix(in srgb, var(--warning) 14%, transparent);
}
.cm4-table tr.cm4-need th:first-child { box-shadow: inset 3px 0 0 var(--warning); }
.cm4-flag {
  display: inline-block; margin-left: var(--sp-2); padding: 0 var(--sp-2); border-radius: var(--radius-pill);
  font-size: var(--fs-xs); font-weight: 600; color: var(--ink);
  border: 1px solid var(--warning); background: var(--surface);
}

@media (max-width: 480px) {
  .cm4-updated { margin-left: 0; flex-basis: 100%; }
  .cm4-figures .reading dd { font-size: var(--fs-lg); }
  .cm4-table th, .cm4-table td { padding: var(--sp-2) var(--sp-2); }
}

/* ------------------------------ storage page: upload a month (CM4 tasks) */
.cm4-task-intro { margin: calc(var(--sp-1) * -1) 0 var(--sp-3); }
.cm4-task-form { margin-bottom: var(--sp-4); }
.cm4-task-form select, .cm4-task-form input.text { padding: var(--sp-1) var(--sp-2); }
.cm4-month-field { flex-wrap: wrap; min-width: 0; max-width: 100%; }
.cm4-month-field select, .cm4-month-field input { min-width: 0; max-width: 100%; }
.cm4-month-hint { margin: 0 0 var(--sp-1); overflow-wrap: anywhere; }
.cm4-mode {
  min-width: 0; margin: var(--sp-2) 0 var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-2);
  border: 1px solid var(--border); border-radius: var(--radius-sm);
}
.cm4-mode legend { padding: 0 var(--sp-1); font-size: var(--fs-sm); color: var(--ink-2); }
.cm4-radio {
  display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline;
  gap: 0 var(--sp-2); margin: var(--sp-1) 0;
}
.cm4-radio input { width: 16px; height: 16px; margin: 0; align-self: center; }
.cm4-radio-note { grid-column: 2; margin: 0; overflow-wrap: anywhere; }
.cm4-include { margin-top: var(--sp-1); }
@media (max-width: 720px), (pointer: coarse) {
  /* STO-ux-12: the same whole-row target as .check (touch targets above).
     The note under each option belongs to it and selects it too; the
     "同时重试多次失败的照片" row inside the second option sits above that
     layer and keeps its own box. */
  .cm4-radio { position: relative; min-height: var(--control-h-touch); }
  .cm4-radio > label::before { content: ""; position: absolute; inset: 0; }
  .cm4-include { position: relative; z-index: 1; }
}
.cm4-submit-row { margin-top: var(--sp-3); }
.cm4-confirm { margin: var(--sp-2) 0 0; border-left-color: var(--accent); }
.cm4-confirm .notice-icon { color: var(--accent-text); font-weight: 600; }
.cm4-confirm-body { min-width: 0; }
.cm4-confirm-body .form-row { margin-bottom: 0; }
.cm4-result, .cm4-list-result { margin: 0 0 var(--sp-2); }
.cm4-task-head { border-top: 1px solid var(--border); padding-top: var(--sp-3); }
.cm4-task-head .sub-head { margin: 0; }
.cm4-task-head > .refresh-ctl { margin-left: auto; }

.cm4-task-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.cm4-task-table th, .cm4-task-table td {
  text-align: left; padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--border); white-space: nowrap;
}
.cm4-task-table thead th { color: var(--muted); font-weight: 600; }
.cm4-task-table td .meta { font-size: var(--fs-sm); }
.cm4-task-table td.cm4-task-summary { white-space: normal; min-width: 9em; max-width: 16em; }
.cm4-task-table td.cm4-task-reason { white-space: normal; min-width: 8em; max-width: 20em; overflow-wrap: anywhere; }
/* The method column (the third td, no class) wraps in Chinese too (user 2026-10-27): with nowrap 「核对整月，只重传缺失的照片（含失败重试）」
   made the table scroll 61px sideways at 1280px. keep-all breaks only after 「，」, before 「（」 and at spaces, so that name takes three
   lines at its punctuation and the others stay whole; 8em keeps 「补传未上传的照片」 and 「删除 SSD 目录」 on one line on a phone.
   English has its own rules in the console-i18n-v1 segment (W03); :lang(zh) leaves the English table as it was. */
:lang(zh) .cm4-task-table td:nth-child(3) { white-space: normal; word-break: keep-all; min-width: 8em; }
.cm4-task-table td.cm4-task-action { padding-top: 3px; padding-bottom: 3px; }
/* The row a 409 pointed at: tint, edge mark and the words "已有同类指令". */
.cm4-task-table tr.cm4-task-hl td { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.cm4-task-table tr.cm4-task-hl td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
.cm4-flag.cm4-flag-hl { border-color: var(--accent); margin-left: var(--sp-2); }

@media (max-width: 480px) {
  .cm4-mode { padding: var(--sp-2) var(--sp-3) var(--sp-2); }
  .cm4-task-table th, .cm4-task-table td { padding: var(--sp-2) var(--sp-2); }
}

/* ------------------------------ storage page: capture and upload test */
/* Intro, button hint, "no test yet" and every secondary line in this card
   (creator and time, check detail and time, the re-check hint, the thumbnail
   caption) are .meta and keep its --muted: .meta is never recoloured
   (CONS-A-04). Prose that has to read one step up takes .note instead. */
.cm4-capture-intro { margin: calc(var(--sp-1) * -1) 0 var(--sp-3); overflow-wrap: anywhere; }
.cm4-capture-info { margin-bottom: var(--sp-3); }
.cm4-capture-info .st-label { min-width: 6.5em; }
/* Long Dropbox paths wrap anywhere instead of widening the card. */
.cm4-capture-path {
  font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: var(--fs-sm); overflow-wrap: anywhere; word-break: break-all; min-width: 0;
}
.cm4-capture-notices .cm4-notice { margin: 0 0 var(--sp-2); }
.cm4-capture-notices:empty { display: none; }
.cm4-capture-notice .notice-icon { font-variant-emoji: text; }
.cm4-capture-dirty strong, .cm4-capture-mismatch strong { font-weight: 600; }
.cm4-capture-actions { margin: var(--sp-1) 0 0; }
.cm4-capture-hint { overflow-wrap: anywhere; }
.cm4-capture-hint:empty { display: none; }
.cm4-capture-messages { margin: 0 0 var(--sp-1); }
/* Messages keep the body ink; the leading tick or cross carries the tone,
   in the per-theme --good-text / --critical-text. */
.save-line.cm4-capture-line { color: var(--ink); }
.save-line.cm4-capture-line.success::before { color: var(--good-text); }
.save-line.cm4-capture-line.error::before { color: var(--critical-text); }
.save-line.cm4-capture-line.info::before { content: "ⓘ "; color: var(--accent-text); }
.cm4-capture-error-text { color: var(--ink); }

.cm4-capture-test { margin-top: var(--sp-3); }
.cm4-capture-test:empty { display: none; }
.cm4-capture-none { margin: 0; }
.cm4-capture-head { border-top: 1px solid var(--border); padding-top: var(--sp-3); }
.cm4-capture-head .sub-head { margin: 0; }

/* The progress bar: one track from the first node's centre to the last's,
   a fill up to where the test got, four round nodes on top and the words
   under them. Each node carries a number, tick, arrow or cross, so colour
   is never the only cue. While the test runs the current node has a
   spinning ring and the segment being worked on has moving stripes; an
   ended test is still. Only CSS moves anything. */
.cm4-capture-progress {
  --cm4-dot: 28px;
  position: relative; margin: var(--sp-3) 0 var(--sp-4); min-width: 0;
}
.cm4-capture-progress-track {
  position: absolute; left: 12.5%; right: 12.5%; top: calc(var(--cm4-dot) / 2 - 3px);
  height: 6px; border-radius: 3px; background: var(--border); overflow: hidden;
}
.cm4-capture-progress-fill {
  position: absolute; left: 0; top: 0; bottom: 0; width: var(--cm4-fill, 0%);
  background: var(--accent); border-radius: inherit;
  transition: width 400ms ease;
}
.cm4-capture-progress-flow {
  display: none; position: absolute; top: 0; bottom: 0;
  left: var(--cm4-flow-start, 0%); width: calc(100% / 3); overflow: hidden;
}
.cm4-capture-progress.is-active .cm4-capture-progress-flow { display: block; }
/* 45deg stripes repeat every 10px along the track, so sliding them 20px
   and starting over is seamless. */
.cm4-capture-progress-flow::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: -20px; right: 0;
  background: repeating-linear-gradient(-45deg, var(--accent) 0 3.5355px, transparent 3.5355px 7.071px);
  opacity: .55;
  animation: cm4-progress-flow 800ms linear infinite;
}
.cm4-capture-progress-nodes {
  position: relative; list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
}
.cm4-capture-progress-node {
  display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
  min-width: 0; padding: 0 var(--sp-1); text-align: center;
}
.cm4-capture-progress-dot {
  position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: var(--cm4-dot); height: var(--cm4-dot); border-radius: 50%;
  border: 2px solid var(--muted); background: var(--surface); color: var(--ink);
  font-size: var(--fs-sm); font-weight: 600; line-height: 1;
  font-variant-emoji: text; font-variant-numeric: tabular-nums;
  transition: background-color 400ms ease, border-color 400ms ease;
}
.cm4-capture-progress-label {
  max-width: 100%; color: var(--ink-2); font-size: var(--fs-sm); line-height: 1.35;
  overflow-wrap: anywhere;
}
.cm4-capture-progress-node.done .cm4-capture-progress-dot {
  background: var(--accent-fill); border-color: var(--accent-fill); color: var(--accent-ink);
}
.cm4-capture-progress-node.done .cm4-capture-progress-label { color: var(--ink); }
.cm4-capture-progress-node.current .cm4-capture-progress-dot { border-color: var(--accent); }
.cm4-capture-progress-node.current .cm4-capture-progress-label { color: var(--ink); font-weight: 600; }
.cm4-capture-progress-node.bad .cm4-capture-progress-dot {
  background: var(--critical); border-color: var(--critical); color: var(--accent-ink);
}
.cm4-capture-progress-node.bad .cm4-capture-progress-label { color: var(--ink); font-weight: 600; }
.cm4-capture-progress.is-active .cm4-capture-progress-node.current .cm4-capture-progress-dot::after {
  content: ""; position: absolute; inset: -6px; border-radius: 50%;
  border: 2px solid transparent; border-top-color: var(--accent); border-right-color: var(--accent);
  animation: cm4-progress-spin 1s linear infinite;
}
@keyframes cm4-progress-spin { to { transform: rotate(360deg); } }
@keyframes cm4-progress-flow { from { transform: translateX(0); } to { transform: translateX(20px); } }
@media (prefers-reduced-motion: reduce) {
  .cm4-capture-progress-fill,
  .cm4-capture-progress-flow::before,
  .cm4-capture-progress-dot,
  .cm4-capture-progress.is-active .cm4-capture-progress-node.current .cm4-capture-progress-dot::after {
    animation: none; transition: none;
  }
  /* No spin: the current node keeps a full, still ring instead. */
  .cm4-capture-progress.is-active .cm4-capture-progress-node.current .cm4-capture-progress-dot::after {
    border-color: var(--accent);
  }
}

.cm4-capture-mark {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 1.5em; height: 1.5em; border-radius: 50%; font-size: var(--fs-sm); font-weight: 600;
  border: 1px solid var(--border); background: var(--surface); color: var(--ink);
  font-variant-emoji: text; font-variant-numeric: tabular-nums;
}

.cm4-capture-result {
  display: grid; gap: var(--sp-3) var(--sp-4); align-items: start;
  grid-template-columns: minmax(0, 1fr) minmax(0, 240px);
}
.cm4-capture-result.cm4-capture-failed { grid-template-columns: minmax(0, 1fr); }
.cm4-capture-body { min-width: 0; display: grid; gap: var(--sp-2); }
.cm4-capture-body .st-row { overflow-wrap: anywhere; }
.cm4-capture-body .form-row { margin: 0; }
.cm4-capture-body .cm4-notice { margin: 0; }
.cm4-capture-rows { font-size: var(--fs-md); }
.cm4-capture-rows dt { color: var(--ink-2); }
.cm4-capture-warn-value { display: flex; gap: var(--sp-2); align-items: baseline; }
.cm4-capture-flag { color: var(--serious-text); font-variant-emoji: text; }
.cm4-capture-verdict {
  display: inline-flex; align-items: center; gap: var(--sp-2); color: var(--ink); font-weight: 600;
  padding: 0 var(--sp-2) 0 2px; border: 1px solid var(--border); border-radius: var(--radius-pill);
}
.cm4-capture-verdict .cm4-capture-mark { width: 1.3em; height: 1.3em; border: none; background: none; }
.cm4-capture-verdict.good { border-color: var(--good); }
.cm4-capture-verdict.warning { border-color: var(--serious); }
.cm4-capture-thumb { margin: 0; min-width: 0; max-width: 100%; }
.cm4-capture-thumb .thumb { border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); }
.cm4-capture-thumb figcaption { margin-top: var(--sp-1); }
.cm4-capture-thumb .thumb-text { color: var(--ink-2); }
.cm4-capture-view { margin-top: var(--sp-2); }

/* "查看大图": since 2026-10-13 this is the shared viewer (lib/photo_viewer.js),
   the same dialog as the photo page's, so its panel, sizes, image frame and
   the loading / failed lines are all the .photo-viewer rules further down and
   nothing is set for them here. What stays is the one thing of this card's
   own inside it: the remote path in the facts list. */
.cm4-photo-meta dd code { overflow-wrap: anywhere; }

@media (max-width: 720px) {
  .cm4-capture-result { grid-template-columns: minmax(0, 1fr); }
  .cm4-capture-thumb { max-width: 320px; }
}
@media (max-width: 480px) {
  /* Still one row: smaller nodes, and the words wrap under them. */
  .cm4-capture-progress { --cm4-dot: 24px; }
  .cm4-capture-progress-node { padding: 0 1px; }
  .cm4-capture-progress-label { font-size: var(--fs-sm); }
  .cm4-capture-head .meta { flex-basis: 100%; }
  .cm4-capture-actions .btn { flex: 1 1 100%; }
}

/* --------------------------- camera settings page: 相机当前参数 card */
/* The camera's own state, above the cloud's target values. Same bones as the
   拍照立即上传测试 card on the storage page — .card, .settings-status rows,
   .btn.primary, the .cm4-capture-progress* bar — so the two commands a user can
   send a camera look and behave the same. Nothing here is a new colour or a new
   font: only spacing and wrapping that the shared classes leave open. */
/* The intro, the button hint and the card's other .meta lines keep the
   --muted of .meta (CONS-A-04); prose that must read darker takes .note. */
.camera-config-intro { margin: calc(var(--sp-1) * -1) 0 var(--sp-3); overflow-wrap: anywhere; }
.camera-config-info { margin-bottom: var(--sp-3); }
.camera-config-info .st-label { min-width: 5.5em; }
/* A model name ("Canon EOS 800D") and a read time both wrap rather than widen
   the card: at 375px the label and the value stack instead of overflowing. */
.camera-config-model, .camera-config-read-at { min-width: 0; overflow-wrap: anywhere; }
.camera-config-model { font-weight: 600; }
.camera-config-notices .cm4-notice { margin: 0 0 var(--sp-2); }
.camera-config-notices:empty { display: none; }
.camera-config-notice .notice-icon { font-variant-emoji: text; }
.camera-config-actions { margin: var(--sp-1) 0 0; }
.camera-config-hint { overflow-wrap: anywhere; }
.camera-config-hint:empty { display: none; }
.camera-config-messages { margin: var(--sp-2) 0 0; }
.camera-config-messages:empty { display: none; }
/* Body ink for the words, tone only on the leading mark: the theme's green and
   red text fall under 4.5:1 on one theme or the other. */
.save-line.camera-config-line { color: var(--ink); }
.save-line.camera-config-line.success::before { color: var(--good-text); }
.save-line.camera-config-line.error::before { color: var(--critical-text); }
.save-line.camera-config-line.info::before { content: "ⓘ "; color: var(--accent-text); }
.camera-config-error-text { color: var(--ink); }
.camera-config-task { margin-top: var(--sp-3); }
.camera-config-task:empty { display: none; }
.camera-config-head { border-top: 1px solid var(--border); padding-top: var(--sp-3); }
.camera-config-head .sub-head { margin: 0; }
.camera-config-cancel { margin: 0; }
@media (max-width: 480px) {
  .camera-config-actions .btn { flex: 1 1 100%; }
  .camera-config-head .meta { flex-basis: 100%; }
}

/* ------------------------------------------------- camera settings page */
.camera-notice-body { display: grid; gap: 2px; min-width: 0; overflow-wrap: anywhere; }
.camera-model-note { margin: 0; font-size: var(--fs-md); color: var(--ink-2); }
.camera-group h2 { margin-bottom: var(--sp-1); }
.camera-param { padding: var(--sp-3) 0; border-top: 1px solid var(--border); min-width: 0; }
.camera-group h2 + .camera-param { border-top: none; padding-top: 2px; }
.camera-param:last-child { padding-bottom: 0; }
.camera-param .form-row { margin: 0; gap: var(--sp-2) var(--sp-3); }
/* The parameter name is the row head (.param-name) of a .form-stacked.is-columned
   row, not a field label: see the console-batch01-p2 T17 segment at the end. */
.camera-param select { min-width: 11em; max-width: 100%; }
.camera-param input.num { width: 9em; max-width: 100%; }
/* Disabled: dimmed and dashed, and always paired with a .camera-lock reason. */
.camera-param select:disabled, .camera-param input:disabled { border-style: dashed; }
.camera-lock {
  font-size: var(--fs-sm); color: var(--ink-2); border: 1px solid var(--border);
  border-radius: var(--radius-pill); padding: 0 var(--sp-2); overflow-wrap: anywhere;
}
/* Parameter notes are .meta and keep its --muted (CONS-A-04): no rule here
   recolours .meta; a note that reads as prose takes .note. */
.camera-help { margin-top: var(--sp-1); }
.camera-help p { margin: 0; overflow-wrap: anywhere; }
.camera-help-k { font-weight: 600; }
.camera-param .field-error { margin-top: var(--sp-1); }
.camera-warn { margin-top: var(--sp-2); }
.camera-warn .warn-list { margin: 0; }
.camera-warn .warn-list li { margin-bottom: var(--sp-1); font-size: var(--fs-md); overflow-wrap: anywhere; }
.save-result .warn-list { margin: var(--sp-2) 0 0; }
.save-result .warn-list li { margin-bottom: var(--sp-1); font-size: var(--fs-sm); overflow-wrap: anywhere; }

/* Three columns per parameter: the cloud's target value (the control), what
   the camera reported, and how the two compare. One grid row from 721px up,
   three stacked blocks below it — stacking, never a sideways scroll.
   .camera-param-row exists so the attention bar has somewhere to live without
   touching the .camera-param rule above: every row reserves the same 10px, so
   a highlighted row does not shift its text sideways. */
.camera-param-row { padding-left: var(--sp-3); margin-left: calc(var(--sp-3) * -1); }
.camera-cols {
  display: grid; gap: var(--sp-2) var(--sp-4); align-items: start;
  grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr) minmax(0, .85fr);
}
.camera-col { display: grid; gap: 2px; min-width: 0; align-content: start; }
.camera-col-head { font-size: var(--fs-xs); color: var(--ink-2); }
.camera-col-value { font-size: var(--fs-md); overflow-wrap: anywhere; }
.camera-device-value { color: var(--ink); }
/* The camera's own read-only reason: the same pill as .camera-lock, marked
   out only by a slightly stronger border — the words carry the difference. */
.camera-lock-device { border-color: var(--ink-2); }
/* Rows that need attention get a bar and a mark; the words in the status
   column say the same thing (ROW_STATUS_TEXT), so the colour is never the only
   clue. Both hues are the page's existing --warning / --critical. The words
   stay body ink; the bar takes --warning-bar (the saturated hue in dark, a
   text-dark tone in light, >= 3:1 either way — T28) and the mark, being read,
   takes the per-theme --warning-text / --critical-text. */
.camera-param-row.is-differs { box-shadow: inset 3px 0 0 var(--warning-bar); }
.camera-param-row.is-unsupported_value { box-shadow: inset 3px 0 0 var(--critical); }
/* The status column's words are a .status badge (dot + level colour, lib/ui.js
   statusEl; console-batch01-p2 T17): the "≠ " / "✕ " marks and the 600 weight that
   used to be the only difference are gone — one mark per badge. */

/* ------------------------------ camera settings page: delivering to the camera
   The second step of the save bar's 保存并下发 (its one .btn.primary: save,
   then deliver). The question before it is the shared confirm modal
   (createDeliverConfirm, mounted in .camera-apply-confirm; see the
   save-and-deliver-unify-r2 T06 block at the end), so the .cm4-confirm rules
   here, from the old inline question, no longer match anything. The answer
   has the same bones as the storage page's capture test: .save-line for the
   messages and the result rows' spacing and dividers. No new colour and no
   new font — the three outcomes are told by a mark plus Chinese words, and the
   tone only ever sits on the mark, never on the words. */
.camera-apply-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; min-width: 0; }
.camera-apply-hint { max-width: 26em; overflow-wrap: anywhere; color: var(--ink-2); }
.camera-apply-hint:empty { display: none; }
.camera-apply { min-width: 0; }
.camera-apply-confirm:empty { display: none; }
.camera-apply-confirm .cm4-confirm { margin: 0 0 var(--gap); }
.camera-apply-list { margin: var(--sp-2) 0 0; padding-left: 1.2em; font-size: var(--fs-md); overflow-wrap: anywhere; }
.camera-apply-list li { margin-bottom: 2px; }
.camera-apply-messages { margin: 0 0 var(--gap); }
.camera-apply-messages:empty { display: none; }
/* Body ink for the words, tone only on the leading mark: the theme's green and
   red text fall under 4.5:1 on one theme or the other. */
.save-line.camera-apply-line { color: var(--ink); margin: var(--sp-1) 0; }
.save-line.camera-apply-line.success::before { color: var(--good-text); }
.save-line.camera-apply-line.error::before { color: var(--critical-text); }
.save-line.camera-apply-line.info::before { content: "ⓘ "; color: var(--accent-text); }

.camera-apply-results:empty { display: none; }
.camera-apply-results {
  margin: 0 0 var(--gap); padding: var(--sp-3) var(--sp-4); min-width: 0;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.camera-result-meta { margin: calc(var(--sp-1) * -1) 0 var(--sp-2); }
.camera-result-why { margin: 0 0 var(--sp-2); font-size: var(--fs-md); overflow-wrap: anywhere; }
.camera-result-list { list-style: none; margin: 0; padding: 0; }
.camera-result-row {
  display: grid; gap: 2px var(--sp-3); align-items: baseline; padding: var(--sp-2) 0;
  border-top: 1px solid var(--border); font-size: var(--fs-md); min-width: 0;
  grid-template-columns: auto minmax(0, 1.1fr) minmax(0, 1.3fr) minmax(0, 1fr);
}
.camera-result-row:first-child { border-top: none; padding-top: 0; }
.camera-result-mark {
  flex: none; font-variant-emoji: text; font-weight: 600; color: var(--ink-2);
}
.camera-result-row.is-ok .camera-result-mark { color: var(--good-text); }
.camera-result-row.is-failed .camera-result-mark { color: var(--critical-text); }
.camera-result-field { font-weight: 600; overflow-wrap: anywhere; }
.camera-result-values { color: var(--ink-2); overflow-wrap: anywhere; }
.camera-result-text { color: var(--ink); overflow-wrap: anywhere; }
.camera-result-row.is-failed .camera-result-text { font-weight: 600; }

/* The delivery line of the status bar, and the per-field reasons it can open
   onto. <details> lives under the rows rather than inside that <p>. */
.st-delivery-extra:empty { display: none; }
.st-delivery-extra { margin-top: var(--sp-2); }
.camera-delivery-detail summary { cursor: pointer; font-size: var(--fs-md); color: var(--ink-2); }
.camera-delivery-detail[open] summary { margin-bottom: var(--sp-2); }
.camera-delivery-detail .camera-result-row { font-size: var(--fs-sm); }
/* The notice at the top keeps .notice-support's shape; its level
   (.notice.info / warning / error / success, pages/camera.js DELIVERY_NOTICE_LEVEL)
   colours the left bar and the mark together, and the words say the same thing
   (console-batch01-p2 T17; the per-tone colours that were here are gone). */
.camera-model-note[hidden] { display: none; }

@media (max-width: 720px) {
  /* Mark and name on one line, then what was asked and how it went. */
  .camera-result-row { grid-template-columns: auto minmax(0, 1fr); }
  .camera-result-values, .camera-result-text { grid-column: 2; }
}
/* ≤480px 的保存栏（一行紧凑，「相机参数历史」进「更多」）见文件末尾 T20 段。 */

/* Narrow screens: cards sit where every other page's do (content gives 12px;
   the camera page's own extra 4px went with console-batch01-p2 T17). */
@media (max-width: 480px) {
  .camera-param select, .camera-param input.num { flex: 1 1 auto; width: 100%; min-width: 0; }
}

/* Stacked: the head sits in front of its value instead of above it, so three
   blocks cost three lines rather than six. */
@media (max-width: 720px) {
  .camera-cols { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2) var(--sp-3); }
  .camera-col-device, .camera-col-status {
    grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: var(--sp-1) var(--sp-2);
  }
  /* T01（R1-CAM-ui-01）：列里的第三个子元素——相机给的 note、「未下发」徽标——
     另起一行占满两列。不给位置时它们会被自动放进第 2 行第 1 列，也就是 auto
     那一列：长 note 按内容把这一列撑满，值所在的 minmax(0, 1fr) 只剩 0 宽，
     值一个字一行竖着排；状态列里徽标把列头「状态」那一列撑宽，状态字对不齐。 */
  .camera-device-note, .camera-not-applied { grid-column: 1 / -1; }
}

/* ------------------------------ 拍摄计划页：保存并下发
   和相机参数页的下发同一套骨架（两页都由「保存并下发」发起）：.btn.primary 发起，.save-line 说结果，
   进度条直接用拍照测试那张卡片的 .cm4-capture-progress*——两者是同一件事的同一种
   表示，换一套画法只会让人以为它们不一样。这里只写间距与换行，不新增颜色与字号，
   状态一律由文字加标记承担，颜色只是重复说一遍。 */
.schedule-deliver-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; min-width: 0; }
.schedule-deliver-hint { max-width: 26em; overflow-wrap: anywhere; color: var(--ink-2); }
.schedule-deliver-hint:empty { display: none; }
.schedule-deliver { min-width: 0; }
.schedule-deliver-messages { margin: 0 0 var(--sp-2); }
.schedule-deliver-messages:empty { display: none; }
/* 字用正文色，成败只落在行首的勾叉上：主题里的绿和红当正文都过不了 4.5:1。 */
.save-line.schedule-deliver-message { color: var(--ink); margin: var(--sp-1) 0; }
.save-line.schedule-deliver-message.success::before { color: var(--good-text); }
.save-line.schedule-deliver-message.error::before { color: var(--critical-text); }
.save-line.schedule-deliver-message.info::before { content: "ⓘ "; color: var(--accent-text); }
.schedule-deliver-task:empty { display: none; }
.schedule-deliver-task {
  margin: 0 0 var(--gap); padding: var(--sp-3) var(--sp-4); min-width: 0;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.schedule-deliver-head { margin-bottom: 0; }
.schedule-deliver-head .sub-head { margin: 0; }
.schedule-deliver-failed { margin-top: var(--sp-1); }
.schedule-deliver-failed .note { margin: 0 0 var(--sp-1); }
.schedule-deliver-frames { list-style: none; margin: 0 0 var(--sp-2); padding: 0; font-size: var(--fs-md); }
.schedule-deliver-frames li {
  display: flex; gap: var(--sp-2); align-items: baseline; padding: 2px 0;
  min-width: 0; overflow-wrap: anywhere;
}
.schedule-frame-mark { flex: none; font-variant-emoji: text; font-weight: 600; color: var(--critical-text); }
.schedule-frame-more .schedule-frame-mark { color: var(--ink-2); }
.schedule-deliver-retry { margin: 0; }
.schedule-deliver-error:empty { display: none; }
/* 两个分区说法不同时才分行；分区名借状态行自己的 .st-label 对齐。 */
.schedule-deliver-status { display: block; }
.schedule-deliver-status + .schedule-deliver-status { margin-top: 2px; }
/* 下发行的标记跟着结论走：本页也照三个设置页的公共写法，图标带 data-level，颜色在文件末尾
   console-batch01-p2 T08 段（原来这里有一个本页独有的 data-delivery-tone 钩子，特异性更高，
   把 data-level 盖掉了，console-batch01-p2 T16 删）。 */

/* ≤480px 的保存栏（一行紧凑，一个「历史记录」进「更多」）见文件末尾 T20 段。 */

/* ------------------------------ 拍摄计划页：相机时钟卡片
   设备状态页那些读数卡片的骨架：一个 .card，里面是「标签 / 值」的两列，标签用
   --ink-2，值用正文色。没有新的颜色字面量和新的字号。卡片顶上的结论是全站的 .status
   （console-batch01-p2 T16）：每一种都先有一个 ✓ / ⚠ / ✕ 的字符标记或灰点，颜色只是
   把同一件事再说一遍。 */
.schedule-online-note { margin: calc(var(--sp-2) - var(--gap)) 0 var(--gap); }
/* 结论行是全站的 .status（console-batch01-p2 T16，FD10）：颜色、图标与排法见文件末尾 T16 段。 */
.schedule-clock-state { margin: 0; min-width: 0; }
/* 标签一列固定宽度，值一列吃掉剩下的并自己换行：值再长也撑不宽卡片。 */
.schedule-clock-rows { display: grid; gap: var(--sp-1) 0; margin: var(--sp-3) 0 0; min-width: 0; }
.schedule-clock-row {
  display: grid; grid-template-columns: 7.5em minmax(0, 1fr);
  gap: 2px var(--sp-3); align-items: baseline; min-width: 0;
}
.schedule-clock-row dt { margin: 0; color: var(--ink-2); font-size: var(--fs-sm); }
.schedule-clock-row dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.schedule-clock-compare, .schedule-clock-note, .schedule-clock-auto {
  margin: var(--sp-2) 0 0;
}
/* 时区那一块：一行结论 + 一行控件（.form-row 的换行规则照用）+ 两句说明。输入框
   在宽屏留够 "Australia/Sydney" 的位置，再宽就不必了；窄屏整行占满，保存按钮跟着
   掉到下一行，谁也压不到谁。控件本身沿用 input.text 与 .btn，没有新样式。 */
.schedule-clock-zone { margin: var(--sp-3) 0 0; }
.schedule-clock-zone-line { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.schedule-clock-zone-row { margin: var(--sp-2) 0 0; }
.schedule-clock-zone-label { color: var(--ink-2); font-size: var(--fs-sm); }
.schedule-clock-zone-input { flex: 1 1 14em; min-width: 10em; max-width: 22em; }
.schedule-clock-zone-hint, .schedule-clock-zone-note {
  display: block; margin: var(--sp-1) 0 0; max-width: 42em; overflow-wrap: anywhere;
}
.schedule-clock-zone-hint:empty { display: none; }
.schedule-clock-blocked { margin: var(--sp-3) 0 0; }
.schedule-clock-actions { margin: var(--sp-3) 0 0; }
.schedule-clock-hint { max-width: 32em; overflow-wrap: anywhere; }
.schedule-clock-hint:empty { display: none; }
.schedule-clock-messages:empty { display: none; }
.schedule-clock-messages { margin: var(--sp-2) 0 0; }

@media (max-width: 480px) {
  /* 标签一列收窄，按钮占满一行并给足 44px 的点击高度。 */
  .schedule-clock-row { grid-template-columns: 6em minmax(0, 1fr); }
  .schedule-clock-actions .btn { flex: 1 1 100%; width: 100%; min-height: var(--control-h-touch); }
  .schedule-clock-hint { max-width: none; }
  /* 时区输入框与保存按钮各占一行：14em 的输入框加一个按钮挤在 390px 里，
     两个都只剩几个字宽。 */
  .schedule-clock-zone-input { flex: 1 1 100%; max-width: none; min-height: var(--control-h-touch); }
  .schedule-clock-zone-row .btn { flex: 1 1 100%; width: 100%; min-height: var(--control-h-touch); }
  .schedule-clock-zone-hint, .schedule-clock-zone-note { max-width: none; }
}

/* ------------------------------ 管理页的两个共用弹窗
   一次性密钥展示（lib/secret_reveal.js）与输入 id 确认删除
   （lib/confirm_delete.js）。Both live inside .modal-body, so the width and
   the scrolling are already handled; what is added here is the plain-text
   block that must wrap rather than widen the panel, and a delete button that
   reads as destructive. No new colour: the danger tone is the page's
   --critical, filled so the label is white on red (4.8:1) in both themes
   instead of red text on a dark surface (3.6:1). */
.admin-secret { display: grid; gap: var(--sp-3); min-width: 0; }
.admin-secret-warn { margin: 0; border-left-color: var(--critical); }
.admin-secret-warn .notice-icon { color: var(--critical-text); }
.admin-secret-label { margin: 0; font-size: var(--fs-sm); color: var(--ink-2); }
/* The secret itself: never truncated, never a horizontal scrollbar. A tap or
   a click selects the whole thing, which is the fallback when the browser
   has no clipboard API. */
.admin-secret-value {
  display: block; margin: 0; padding: var(--sp-3) var(--sp-3); min-width: 0; max-width: 100%;
  background: var(--plane); border: 1px solid var(--border); border-radius: var(--radius-sm);
  font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: var(--fs-base); line-height: 1.5; overflow-wrap: anywhere; word-break: break-all;
  user-select: all; -webkit-user-select: all;
}
.admin-secret-note { margin: 0; font-size: var(--fs-md); color: var(--ink-2); overflow-wrap: anywhere; }
/* A ready-to-run command that carries the secret: longer, so a step smaller. */
.admin-secret-command { font-size: var(--fs-sm); }
.admin-secret-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.admin-secret-status { margin: 0; font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
.admin-secret-status:empty { display: none; }

.admin-danger { display: grid; gap: var(--sp-3); min-width: 0; }
.admin-danger-warn { margin: 0; border-left-color: var(--critical); }
.admin-danger-warn .notice-icon { color: var(--critical-text); }
.admin-danger-items {
  margin: 0; display: grid; grid-template-columns: auto minmax(0, 1fr);
  gap: var(--sp-1) var(--sp-3); font-size: var(--fs-md);
}
.admin-danger-items:empty { display: none; }
.admin-danger-items dt { color: var(--muted); }
.admin-danger-items dd { margin: 0; overflow-wrap: anywhere; }
.admin-danger-field { display: grid; gap: var(--sp-1); min-width: 0; }
.admin-danger-field label { font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
/* The id to type reads as data, not as prose, so it is easy to compare. */
.admin-danger-target {
  font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: var(--fs-sm); color: var(--ink); overflow-wrap: anywhere;
}
.admin-danger-input { width: 100%; min-width: 0; }
.admin-danger-error:empty { display: none; }
.admin-danger-error { overflow-wrap: anywhere; }
.admin-danger-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); justify-content: flex-end; }
.btn.admin-danger-go { background: var(--critical); border-color: var(--critical); color: var(--accent-ink); }
.btn.admin-danger-go:hover:not(:disabled) { filter: brightness(.92); }

@media (max-width: 480px) {
  /* Long secrets keep breaking inside the panel, and one button per line in
     the order they are used. */
  .admin-secret-value { font-size: var(--fs-md); padding: var(--sp-2) var(--sp-3); }
  .admin-secret-actions .btn { flex: 1 1 100%; }
  .admin-danger-items { grid-template-columns: minmax(0, 1fr); gap: 2px; }
  .admin-danger-items dt { font-size: var(--fs-sm); }
  .admin-danger-items dd + dt { margin-top: var(--sp-2); }
  .admin-danger-actions { justify-content: stretch; }
  .admin-danger-actions .btn { flex: 1 1 100%; }
}

/* ------------------------------ 壳层：管理分组、改密弹窗与初始密码横幅
   导航里的"管理"分组只对 role=admin 渲染（app.js buildNav），所以这里只管
   它与上面九项之间的分隔；链接本身沿用 .sidenav a 的全部规则，不另起一套。 */
.admin-nav-group { margin-top: var(--sp-2); padding-top: var(--sp-2); border-top: 1px solid var(--border); }
/* The links' text starts after their 3px left border (.sidenav a), so the
   title's left padding carries the same 3px to start on that line too. */
.admin-nav-title {
  margin: 0; padding: 2px var(--sp-5) var(--sp-1) calc(var(--sp-5) + 3px);
  font-size: var(--fs-sm); color: var(--muted);
}

/* 初始密码横幅：沿用 .notice 的形状与警告色，只补窄屏换行。 */
.shell-banner { overflow-wrap: anywhere; }

/* 改密弹窗：与删除确认弹窗同一套栅格，按钮靠右，窄屏一行一个。 */
.admin-password-form { display: grid; gap: var(--sp-3); min-width: 0; }
.admin-password-field { display: grid; gap: var(--sp-1); min-width: 0; }
.admin-password-field label { font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
.admin-password-field input { width: 100%; min-width: 0; }
.admin-password-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); justify-content: flex-end; }

@media (max-width: 480px) {
  .admin-password-actions { justify-content: stretch; }
  .admin-password-actions .btn { flex: 1 1 100%; }
}

/* ------------------------------ 管理页：用户管理（pages/admin_users.js）
   表格沿用 .cm4-table 的字号、行高、边框与表头颜色，这里只改对齐（人名和
   邮箱靠左读起来才顺）、加一层行选中态，以及右侧的详情抽屉。颜色全部取现
   有变量：选中用 --accent，过期与危险动作用 --critical，没有新色。 */
.admin-head { align-items: center; }
.admin-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); min-width: 0; }
.admin-search-field { display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.admin-search-field label { font-size: var(--fs-sm); color: var(--ink-2); white-space: nowrap; }
.admin-search { width: 200px; min-width: 0; max-width: 100%; }
.admin-count { white-space: nowrap; }

.admin-table th, .admin-table td { text-align: left; }
.admin-table th.admin-num, .admin-table td.admin-num { text-align: right; }
/* 只有邮箱那一列折行：一个 40 字的外包邮箱不该把"最近活动"推出卡片，而它
   又是一个断不开的词。其余列沿用 .cm4-table 的 nowrap——姓名折行只会折出
   "erin@exa / mple.co / m" 这种读不成的碎块。

   表格有下限宽度，窄屏上宁可让外层 .scroll 横向滚动，也不要把每一列压成
   两三个字一折的碎块——那样七列都在，却一列都读不成。 */
.admin-users-table { min-width: 700px; }
.admin-table .admin-cell-email { white-space: normal; overflow-wrap: anywhere; min-width: 13em; }
.admin-user-row { cursor: pointer; }
.admin-user-row:hover th, .admin-user-row:hover td { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
.admin-user-row.is-selected th, .admin-user-row.is-selected td {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.admin-user-row.is-selected th:first-child { box-shadow: inset 3px 0 0 var(--accent); }
/* 邮箱本身就是打开抽屉的按钮：全站的 .btn.link.row-link（console-batch01-p2 T13，
   CONS-C-04；原来的 .admin-user-open 规则已删）。 */
.admin-status { margin: 0; font-size: inherit; }
/* 过期的 studio 与"待改密"标记都不靠颜色单打独斗，旁边有字。 */
.admin-expired { color: var(--critical-text); }
.admin-flag {
  display: inline-block; margin-left: var(--sp-2); padding: 0 var(--sp-2); border-radius: var(--radius-pill);
  font-size: var(--fs-xs); color: var(--ink);
  border: 1px solid var(--warning); background: var(--surface);
}

/* 详情抽屉：全站通用的 .drawer（lib/modal.js createDrawer），头部吸顶、分节
   .drawer-section 与危险区红线都在文件末尾 console-batch01-p2 T13 段；原来的
   .admin-drawer* 规则已删。 */

.admin-facts { margin-bottom: var(--sp-3); }
.admin-field-row {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); margin: var(--sp-2) 0; min-width: 0;
}
.admin-field-row label, .admin-field-label { font-size: var(--fs-sm); color: var(--ink-2); }
.admin-field-value { font-size: var(--fs-md); overflow-wrap: anywhere; }
.admin-name-input { flex: 1 1 140px; min-width: 0; }
.admin-note { margin: 0 0 var(--sp-2); }
.admin-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-2); }
.admin-result { margin-top: 2px; }

.admin-caps { display: grid; gap: var(--sp-2); }
/* 整行的 flex（而不是 .check 的 inline-flex）：studio 的到期日才能整条换到
   下一行去，窄屏上不被挤成一个按不中的小框。 */
.admin-cap { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-2); min-width: 0; }
.admin-cap label { font-size: var(--fs-md); }
.admin-cap-expiry {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
  flex: 1 1 100%; min-width: 0; padding-left: var(--sp-6);
}
.admin-cap-expiry label { font-size: var(--fs-sm); color: var(--ink-2); white-space: nowrap; }
.admin-cap-date { flex: 1 1 9em; min-width: 8em; max-width: 14em; }
.admin-cap-flag { font-size: var(--fs-sm); }

.admin-grants { display: grid; gap: 2px; margin-bottom: var(--sp-2); }
.admin-grant-row {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3);
  padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
}
.admin-grant-row:last-child { border-bottom: none; }
.admin-grant-name { flex: 1 1 7em; min-width: 0; font-size: var(--fs-md); overflow-wrap: anywhere; }
.admin-grant-control label { font-size: var(--fs-sm); color: var(--ink-2); }
.admin-grant-select { flex: 1 1 140px; min-width: 0; }
@media (max-width: 720px), (pointer: coarse) {
  /* 触控（AU-ux-13、AP-ux-06、AU-ux-05）：能力行与"可控制"走 .check 的整行点击区
     （见 touch targets），每行已有 44px，行间不再另加间距；Studio 的到期日那一
     行浮在点击区上面，照常能点能输。"撤销"是没有边框的链接按钮，只会变高不
     会变宽，补足 44px 宽。项目页的成员行用的是同一组类名。 */
  .admin-caps { gap: 0; }
  .admin-cap-expiry { position: relative; z-index: 1; }
  .btn.link.admin-grant-remove {
    min-width: var(--control-h-touch); padding-left: var(--sp-2); padding-right: var(--sp-2);
  }
}

/* 新建用户弹窗：与改密弹窗同一套栅格，没有密码输入框。 */
.admin-form { display: grid; gap: var(--sp-3); min-width: 0; }
.admin-form-field { display: grid; gap: var(--sp-1); min-width: 0; }
.admin-form-field label { font-size: var(--fs-sm); color: var(--ink-2); }
.admin-form-field input, .admin-form-field select { width: 100%; min-width: 0; }
.admin-form-error:empty { display: none; }
.admin-form-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); justify-content: flex-end; }

@media (max-width: 720px) {
  /* 搜索框与新建按钮各占一行的一半以上，不挤成一列文字（用户页的抽屉是全站的
     .drawer，min(420px, 100%) 在窄屏自然满宽，console-batch01-p2 T13）。 */
  .admin-toolbar { width: 100%; }
  .admin-search-field { flex: 1 1 160px; }
  .admin-search { flex: 1 1 auto; width: auto; }
}

@media (max-width: 480px) {
  .admin-actions .btn, .admin-form-actions .btn { flex: 1 1 100%; }
  .admin-form-actions { justify-content: stretch; }
  .admin-field-row .btn { flex: 1 1 100%; }
}

/* ------------------------------ 管理页：项目管理（pages/admin_projects.js）
   表格与用户页同一套（.cm4-table + .admin-table 的对齐与选中态），这里只补
   项目独有的部分：Dropbox 根目录那一列要能折行，详情抽屉里相机与成员两张列表
   的样子。详情是全站通用的右侧抽屉（lib/modal.js createDrawer，console-batch01-p2
   T14，CONS-C-05）：分节、危险区红线在文件末尾 T13 段，行内打开是全站的
   .row-link；原来的内联详情卡（.admin-detail 与 -head / -title / -body /
   -section）、.admin-project-open、.admin-camera-name、.admin-linkbtn 规则
   已删。颜色全部取现有变量，没有新色。 */
/* 下限按六列的自然宽度定：760px 比内容还宽，1024 的笔记本上凭空多出一条
   横向滚动条，最后一列还被切掉一截。 */
.admin-projects-table { min-width: 600px; }
/* id 与根目录折行：一个 /FixCamera/客户A/2026 不该把"索引状态"推出卡片，
   而它又是一个断不开的词。其余列沿用 .cm4-table 的 nowrap。根目录的下限
   16em → 12em（console-batch01-p2 T14）：索引状态改成“相对（绝对）”后那一列
   在 1024 宽下折成两行也要约 190px，根目录让出 4em，表格仍不横滑。 */
.admin-table .admin-cell-id { white-space: normal; overflow-wrap: anywhere; min-width: 8em; }
.admin-table .admin-cell-root { white-space: normal; overflow-wrap: anywhere; min-width: 12em; }
.admin-project-row { cursor: pointer; }
.admin-project-row:hover th, .admin-project-row:hover td { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
.admin-project-row.is-selected th, .admin-project-row.is-selected td {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.admin-project-row.is-selected th:first-child { box-shadow: inset 3px 0 0 var(--accent); }

.admin-root { font-variant-numeric: tabular-nums; }
.admin-hint { margin: 2px 0 0; font-size: var(--fs-sm); overflow-wrap: anywhere; }
/* Field hints in the forms (no .meta) stay --ink-2; a .meta.admin-hint would
   keep .meta's --muted (项目详情那一句已改成 .note，console-batch01-p2 T14). */
.admin-hint:not(.meta) { color: var(--ink-2); }
.admin-reindex { margin: 0 0 var(--sp-2); border-left-color: var(--warning); }
.admin-reindex .notice-icon { color: var(--warning-text); }

/* 相机与成员两张列表：与用户页的项目授权行同一个形状（一行一条、细分隔
   线），窄屏上各段自己换行。 */
.admin-cameras { display: grid; gap: 2px; margin-bottom: var(--sp-2); }
.admin-camera-row {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3);
  padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
}
.admin-camera-row:last-child { border-bottom: none; }
.admin-camera-meta { font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; }
.admin-grant-sub { display: block; font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; }

@media (max-width: 480px) {
  .admin-camera-row { gap: 2px var(--sp-2); }
}

/* ------------------------------ 管理页：相机管理（pages/admin_cameras.js）
   表格与用户页、项目页同一套（.cm4-table + .admin-table 的对齐与选中态），
   这里只补相机独有的部分：九列比前两页宽，所以下限宽度更大，窄屏靠外层
   .scroll 横向滚动。详情是全站通用的右侧抽屉（lib/modal.js createDrawer，
   console-batch01-p2 T15，CONS-C-05）：注册包、HMAC、CM4 令牌、危险区各是抽屉里
   的一节（h3.sub-head），分节线与危险区红线在文件末尾 T13 段；行内打开是全站的
   .row-link。原来的内联详情（.admin-camera-detail 与五张 .admin-camera-card）、
   .admin-cam-open 规则已删。颜色全部取现有变量，没有新色。 */
.admin-filter-field { display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.admin-filter-field label { font-size: var(--fs-sm); color: var(--ink-2); white-space: nowrap; }
.admin-project-filter { min-width: 0; max-width: 100%; }

/* 九列：id / 项目 / 名称 / 硬件代次 / 注册包 / HMAC / CM4 令牌 / 两个上报
   时间。窄屏上宁可横向滚动，也不要把每一列压成两三个字一折的碎块。 */
.admin-cameras-table { min-width: 860px; }
.admin-cameras-table .admin-cell-project { white-space: normal; overflow-wrap: anywhere; min-width: 7em; }
.admin-cameras-table .admin-cell-name { white-space: normal; overflow-wrap: anywhere; min-width: 7em; }
.admin-cam-row { cursor: pointer; }
.admin-cam-row:hover th, .admin-cam-row:hover td { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
.admin-cam-row.is-selected th, .admin-cam-row.is-selected td {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.admin-cam-row.is-selected th:first-child { box-shadow: inset 3px 0 0 var(--accent); }
/* id 本身就是打开抽屉的按钮：全站的 .btn.link.row-link（console-batch01-p2 T15，
   CONS-C-04；原来的 .admin-cam-open 规则已删）。 */

.admin-serial { font-variant-numeric: tabular-nums; }
.admin-seq { font-variant-numeric: tabular-nums; }

/* 注册包：当前值可能是一串断不开的 ASCII，窄屏上要能换行。 */
.admin-reg-current { font-size: var(--fs-md); overflow-wrap: anywhere; }
.admin-reg-field { margin-top: var(--sp-2); }
.admin-reg-input { width: 100%; min-width: 0; }
.admin-reg-hints:empty { display: none; }
/* 本地预校验只是提示，不是判决（服务端才是），所以用黄条而不是红字。 */
.admin-reg-hint {
  margin: var(--sp-2) 0 0; padding-left: var(--sp-2); font-size: var(--fs-sm); color: var(--ink-2);
  border-left: 3px solid var(--warning); overflow-wrap: anywhere;
}

.admin-move-select { flex: 1 1 160px; min-width: 0; }
.admin-form-select { width: 100%; min-width: 0; }
/* 四个固定键的序列号框，在新建/编辑弹窗里圈成一组。 */
.admin-serials {
  display: grid; gap: var(--sp-3); min-width: 0; margin: 0;
  padding: var(--sp-3) var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-sm);
}
.admin-serials legend { padding: 0 var(--sp-1); font-size: var(--fs-sm); color: var(--ink-2); }

/* 二次确认弹窗（换项目 / 生成密钥 / 签发与吊销令牌 / 重置序列号 / 清除注册
   包）：与删除确认弹窗同一套栅格，只是不用输入 id。 */
.admin-confirm { display: grid; gap: var(--sp-3); min-width: 0; }
.admin-confirm-warn { margin: 0; border-left-color: var(--warning); }
.admin-confirm-warn .notice-icon { color: var(--warning-text); }
.admin-confirm-lines { margin: 0; padding-left: 1.2em; font-size: var(--fs-md); color: var(--ink-2); }
.admin-confirm-lines li { overflow-wrap: anywhere; }

@media (max-width: 720px) {
  .admin-filter-field { flex: 1 1 160px; }
  .admin-project-filter { flex: 1 1 auto; }
}

@media (max-width: 480px) {
  .admin-move-row .btn { flex: 1 1 100%; }
  .admin-serials { padding: var(--sp-2) var(--sp-3); }
}

/* ------------------------------ 管理页：审计记录（pages/admin_audit.js）
   过滤条沿用 .filters（链路页那条），表格沿用 .cm4-table + .admin-table 的
   对齐，这里只补审计独有的三件事：邮箱与 detail 摘要两列要能折行、展开的
   JSON 块、以及底部那条分页。颜色全部取现有变量，没有新色。 */
.admin-filters { align-items: center; }
/* 下拉框按最长的那条选项撑宽自己，而一个外包的邮箱能有四十个字符——封顶，
   否则"用户"一项就把整条过滤条挤到第二行去。 */
.admin-audit-select { min-width: 8em; max-width: 12em; }
.admin-audit-action { width: 130px; min-width: 0; max-width: 100%; }
.admin-audit-date { min-width: 0; }
/* 两个快捷前缀按钮就是全站的 .seg：字号、内边距、高度与状态页的时间范围
   同一种规格（CONS-B-10），这里不再单独缩小。 */

/* 六列：时间 / 用户 / 项目 / 动作 / IP / 详情。窄屏上宁可让外层 .scroll 横
   向滚动，也不要把每一列压成两三个字一折的碎块。 */
.admin-audit-table { min-width: 960px; }
/* 只有邮箱和 detail 摘要折行。动作名（admin.set_capabilities）沿用
   .cm4-table 的 nowrap：它是一个断不开的标识符，折成
   "admin.set_capabilitie / s" 之后既读不成也扫不动，宁可横向滚动。 */
.admin-audit-table .admin-cell-user { white-space: normal; overflow-wrap: anywhere; min-width: 12em; }
.admin-audit-table .admin-cell-detail { white-space: normal; min-width: 16em; max-width: 30em; }
/* 时区写在表头上，字小一号：它是一句注解，不是第七列。 */
.admin-th-note { margin-left: var(--sp-1); font-size: var(--fs-xs); font-weight: 400; color: var(--muted); }

/* 折叠行的那一行摘要本身就是展开按钮：全站的行内主体链接 .btn.link.row-link
   （CONS-C-04）。三角与摘要首行对齐、只给摘要画下划线，写在文件末尾的
   console-batch01-p2 T12 段。 */
.admin-detail-summary { min-width: 0; overflow-wrap: anywhere; }
.admin-caret { flex: none; font-size: var(--fs-xs); color: var(--muted); }

/* 展开后另起一整行，不去挤别的列。 */
.admin-audit-detail-row > td { padding-top: 0; }
/* 框子跟着内容走（fit-content）：两行的 {"client":"web"} 不该占满一整行的
   宽度，看着像加载失败。 */
.admin-json {
  margin: 0 0 var(--sp-2); padding: var(--sp-2) var(--sp-3); width: fit-content; max-width: 100%;
  max-height: 320px; overflow: auto;
  font-size: var(--fs-sm); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere;
  background: var(--plane); border: 1px solid var(--muted); border-radius: var(--radius-sm);
}

/* 底部分页是全站的 .pager（lib/ui.js pager，console-batch01-p2 T02 段）。 */

@media (max-width: 720px) {
  /* 每个过滤项各占一行：五项挤在一行上，每个都只剩下拉箭头那么宽。 */
  .admin-filters .admin-filter-field { flex: 1 1 100%; }
  .admin-audit-select, .admin-audit-action, .admin-audit-date { flex: 1 1 auto; width: auto; }
  .admin-filters > .btn { flex: 1 1 100%; }
}

@media (max-width: 480px) {
  .admin-action-field { flex-wrap: wrap; }
  .admin-action-seg { flex: 1 1 100%; }
  .admin-action-seg button { flex: 1 1 0; }
  .admin-json { font-size: var(--fs-xs); padding: var(--sp-2); }
}

/* ------------------------------ 功耗页：电池卡片（T12，pages/energy.js）
   卡片本身就是设备状态页的读数卡片（.tile 的 .k/.v），只是单独站一行、下面
   多几行说明和一个去状态页的链接。结论行是全站的状态徽标 .status（lib/ui.js，
   console-batch01-p2 T10 由 .s/.dot 换过来）。字号、间距、圆角全部取已有
   令牌，没有新值；状态文字的颜色由 .status.good/.warning/... 负责，这里不覆盖。 */
.battery-card { margin-bottom: var(--gap); }
/* 结论文案有四五十个字，窄屏会折两三行：独占一行（flex，不是 .status 的
   inline-flex），圆点跟着第一行走，不要被 align-items: center 推到文字块的
   正中间去。calc(.8em - 4px)：行高 1.6 的第一行正中（13px 字 → 6.4px）。 */
.battery-card .battery-status { display: flex; align-items: flex-start; margin: var(--sp-2) 0 0; }
.battery-card .battery-status > .status-dot { margin-top: calc(.8em - 4px); }
.battery-line { margin: var(--sp-1) 0 0; }
.battery-next { margin: var(--sp-2) 0 0; font-size: var(--fs-sm); color: var(--ink-2); }
/* 它是一个按钮形状的入口，不必再套一层链接的蓝字加下划线。选择器写成
   .btn.battery-link（0,2,0）是必须的：浏览器自带的 a:-webkit-any-link 是
   (0,1,1)，单个类压不过它，链接会留着 #0000ee 和下划线。 */
.btn.battery-link { margin-top: var(--sp-3); text-decoration: none; color: inherit; }

/* ======================================================= T18 相机参数页
   「未下发」第三种行标记、相机自己给的逐项说明（快照 note）、以及未映射设置
   （快照 unmapped）的折叠块。颜色一律取 T07 的令牌：色条用饱和的状态色，要
   读的字用对应的 *-text；意思永远写在字里，不靠颜色单独传达。 */

/* 相机对这一项的说明，画在「相机当前值」下面。字色就是 .meta 的
   --muted（CONS-A-04 起不再改色），这里只管换行和与值之间的距离：一条 note
   可以有 200 字，绝不能把三列网格撑出横向滚动。 */
.camera-device-note { margin-top: 2px; line-height: 1.5; overflow-wrap: anywhere; }

/* 第三种行标记。「已确认」是行本身没有色条的样子，「只读」是控件旁的
   .camera-lock-device 药丸，这一条说的是第三件事：这行跟相机不一致，而这次
   下发不会写它（lib/camera_config.js 的 notApplied）。--serious 与 is-differs
   的 --warning、is-unsupported_value 的 --critical 都不同，三种一眼能分开。 */
.camera-not-applied {
  display: inline-flex; align-items: baseline; gap: var(--sp-1);
  margin-top: 2px; min-width: 0;
  font-size: var(--fs-xs); font-weight: 600; color: var(--serious-text);
  overflow-wrap: anywhere;
}
.camera-not-applied::before { content: "⊘"; flex: none; font-variant-emoji: text; }
/* 一行既「不一致」又「不会被写」时，色条听后者的：用户更需要知道的是这一项
   改了也白改（CAM-func-01）。写在 is-differs / is-unsupported_value 之后，靠
   顺序覆盖，不用 !important。 */
.camera-param-row.is-not-applied { box-shadow: inset 3px 0 0 var(--serious-bar); }

/* 顶部提示的第七种语气：下发过的项确实都确认了，但仍有项没能下发。既不能用
   「已确认」的绿，也不是失败的红：提示条级别是 warning（.notice.warning，
   console-batch01-p2 T17，FD11 只有四级）。 */

/* 确认框里的「另有 N 项不会下发」。 */
.camera-apply-held { margin: var(--sp-2) 0 0; overflow-wrap: anywhere; }

/* 未映射设置：卡片最下面的一块折叠清单，只读展示。<details> 自带键盘操作，
   不写脚本。空的时候整块不占位——一个点开什么都没有的「其它相机设置」比不
   显示更糟。 */
.camera-config-unmapped:empty { display: none; }
.camera-config-unmapped {
  margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--border);
}
.camera-unmapped summary { cursor: pointer; font-size: var(--fs-md); color: var(--ink-2); }
.camera-unmapped[open] summary { margin-bottom: var(--sp-2); }
.camera-unmapped-intro { margin: 0 0 var(--sp-2); overflow-wrap: anywhere; }
.camera-unmapped-list { list-style: none; margin: 0; padding: 0; }
.camera-unmapped-row {
  display: grid; gap: 2px var(--sp-3); align-items: baseline; min-width: 0;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) auto;
  padding: var(--sp-2) 0; border-top: 1px solid var(--border); font-size: var(--fs-md);
}
.camera-unmapped-row:first-child { border-top: none; padding-top: 0; }
.camera-unmapped-name { font-weight: 600; overflow-wrap: anywhere; }
.camera-unmapped-value { color: var(--ink); overflow-wrap: anywhere; }
.camera-unmapped-writable { white-space: nowrap; }

@media (max-width: 720px) {
  /* 名字和可写性一行，值另起一行：三列挤在 375 宽下，机型名那种长值会被压成
     一字一行。三格都写死行列，不靠自动排布——否则可写性会被挤到第三行去。 */
  .camera-unmapped-row { grid-template-columns: minmax(0, 1fr) auto; }
  .camera-unmapped-name { grid-column: 1; grid-row: 1; }
  .camera-unmapped-writable { grid-column: 2; grid-row: 1; }
  .camera-unmapped-value { grid-column: 1 / -1; grid-row: 2; }
}

/* ------------------------------ 管理页：相机的接入方式与开通状态
   （T19，pages/admin_cameras.js）
   状态徽标是全站的 .status 圆点（lib/ui.js statusEl，console-batch01-p2 T15，
   CONS-C-01 / FD10：已开通 good、待开通 warning、未配置 muted），原来的 ✓ ⚠ ⓘ
   图标与 .admin-prov-none 灰字已删；这里只补多出来一列之后表格的下限宽度。颜色
   全部取令牌，没有新色。 */
/* 十列（多了"开通状态"）：860 → 940，窄屏仍旧靠外层 .scroll 横向滚动，不
   把每一列压成两三个字一折的碎块。 */
.admin-cameras-table { min-width: 940px; }
/* 三档结论都是三个字，断开读不成，整列不折行。 */
.admin-cameras-table .admin-cell-prov { white-space: nowrap; }
/* 详情里的"开通状态"：徽标一行，"下一步"（.note）另起一行跟在下面。 */
.admin-prov-next { margin: var(--sp-1) 0 0; }
/* 凭据一节里的"不适用"说明：与同一节里其它 .admin-note 同级，只是排在前面。 */
.admin-na-note { margin-top: var(--sp-2); }

/* 多出来的一列会把 id 更快地滚出可视区（AC-ui-02 建议②）：把 id 列钉在左
   边，横向滚动时"这一行是哪台相机"始终在。表格是 border-collapse，粘住的
   单元格必须自带不透明底色，否则下面的行会透上来；hover 与选中态各配一份
   同色底，滚动时颜色不跳。表头吸顶不在这里：超过 15 台时由全站的长表规则
   （.table-scroll.is-long，console-batch01-p2 T02 段）给，首列表头同时吸顶、吸左，
   叠在其它表头之上（CONS-C-07，T15）。 */
.admin-cameras-table .admin-cell-id,
.admin-cameras-table thead th:first-child {
  position: sticky; left: 0; z-index: 1; background: var(--surface);
  /* 一道竖线把"钉住的"和"滚动的"分开，否则右邻列的字会从 id 底下半个半个
     地钻出来，像是渲染坏了。 */
  box-shadow: 1px 0 0 var(--border);
}
.admin-cameras-table thead th:first-child { z-index: 2; }
.admin-cam-row:hover .admin-cell-id { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
/* 选中行左边那道 accent 竖条（.admin-cam-row.is-selected th:first-child）与
   这里的分隔线是同一个 box-shadow，要一起写，不然后写的会把前一条抹掉。 */
.admin-cam-row.is-selected .admin-cell-id {
  background: color-mix(in srgb, var(--accent) 10%, var(--surface));
  box-shadow: inset 3px 0 0 var(--accent), 1px 0 0 var(--border);
}

/* ------------------------------ 审计页：字段改动清单（pages/admin_audit.js，T26）
   展开一条记录后先是一张"字段：旧值 → 新值"的清单，下面才是原来那块原始
   JSON。值用等宽字体：旧值和新值是拿来逐字比对的，等宽才对得齐。字号、
   间距、圆角、颜色全部取现有令牌，没有新值。
   旧 / 新 各带一个字的标记，颜色和箭头都只是帮忙——红绿分不开的人照样读
   得出哪个是改之前的。 */
.admin-changes {
  list-style: none; margin: 0 0 var(--sp-3); padding: 0;
  display: grid; gap: var(--sp-2); min-width: 0;
}
/* 表格为了六列不碎掉留了 960px 的最小宽度，可这张清单是拿来读的：它得按
   **容器**的实际宽度收窄。上一版拿 calc(100vw - …) 估，少算了 .scroll 与单元
   格自己的内边距，末尾那颗"展开"按钮正好落在被裁掉的十来个像素里。这里把
   审计表的 .scroll 声明成尺寸容器，清单按 100cqw 减去单元格左右内边距
   （.cm4-table td 的 var(--sp-3)）来收——都是量得到的数，不是猜的。宽屏上表
   格不需要内部滚动，min() 取 100%，这两行等于不存在。
   white-space 也要在这里显式收回来：展开行的 <td> 是 .cm4-table 的单元格，那
   条通用规则的 nowrap 会继承下来，把值上写的 overflow-wrap / word-break 一并
   架空——一条 Dropbox 路径于是顶穿值框、压在"展开"按钮上。动词是"收回"不是
   "覆盖"：通用规则原样不动，只在这张清单里改回 normal。 */
.admin-audit-card .scroll { container-type: inline-size; }
.admin-changes, .admin-change-raw {
  max-width: min(100%, calc(100cqw - var(--sp-3) * 2)); white-space: normal;
}
.admin-change {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-2);
  min-width: 0; font-size: var(--fs-md); line-height: 1.5;
}
.admin-change-field { color: var(--ink-2); }
.admin-change-arrow { flex: none; color: var(--muted); }
.admin-change-val {
  display: inline-flex; align-items: baseline; gap: var(--sp-1);
  max-width: 100%; min-width: 0;
  padding: 2px var(--sp-2); border-radius: var(--radius-sm);
  background: var(--plane); border: 1px solid var(--border);
}
/* 一个字的标记，不是装饰：它是"哪个是改之前"的唯一文字依据。 */
.admin-change-tag { flex: none; font-size: var(--fs-xs); color: var(--muted); }
/* white-space 在这里再写一次：值是唯一一处断不断行看得出来的地方，将来谁把
   清单挪出表格、或者又来一条 nowrap，这一行都还在。 */
.admin-change-value {
  font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: var(--fs-sm); min-width: 0;
  white-space: normal; overflow-wrap: anywhere; word-break: break-all;
}
.admin-change-old .admin-change-value { color: var(--ink-2); }
.admin-change-new { border-color: var(--accent-fill); }
.admin-change-new .admin-change-value { color: var(--ink); font-weight: 600; }
/* 就地展开，不弹窗：这颗按钮跟着值走，点开的那一段留在同一行里。
   它自己不参与换行（flex: none + nowrap）：值换行了才轮得到它被挤，而"展开"
   两个字竖着排成两行，看着像坏掉。 */
.btn.link.admin-change-more {
  flex: none; min-height: 0; padding: 0; font-size: var(--fs-xs); white-space: nowrap;
}
.admin-change-items { min-width: 0; overflow-wrap: anywhere; }
.admin-change-note { font-size: var(--fs-sm); color: var(--muted); }
/* 密钥类只写"已更改"。用警示色而不是灰：它是一条真改动，不是缺数据。 */
.admin-change-hidden .admin-change-note { color: var(--warning-text); }
.admin-change-raw { margin: 0 0 var(--sp-1); font-size: var(--fs-xs); color: var(--muted); }

@media (max-width: 480px) {
  .admin-change { font-size: var(--fs-sm); }
}

/* ------------------------------ T23 危险操作的二次确认
   lib/confirm_delete.js 的 createConfirmAction（重置密码、提权降权、停用
   启用、吊销会话、撤销授权、生成密钥、签发/吊销令牌、清除注册包）。
   骨架与形状已经在 .admin-confirm / .admin-form-actions 里，这一段只做两件
   事：一是警告条跟着确认按钮一起换成危险色（原来无论多重都是黄的，而
   删除是红的，同一页两套危险语义，报告 AC-ui-05）；二是把“取消”做成默认
   选项：它拿到焦点（组件里 focus 的就是它），这里再把边框加深、字重提到
   600，让它在红按钮旁边也看得见。颜色全部取现有令牌，没有新色。 */
.admin-confirm.is-danger .admin-confirm-warn { border-left-color: var(--critical); }
.admin-confirm.is-danger .admin-confirm-warn .notice-icon { color: var(--critical-text); }
/* 事实行里的邮箱 / 相机 id 很长，窄屏上要断行而不是撑宽面板。 */
.admin-confirm-lines li { overflow-wrap: anywhere; }
.btn.admin-confirm-cancel { border-color: var(--ink-2); font-weight: 600; }

@media (max-width: 480px) {
  /* 一行一个按钮，顺序是“取消”在上，跟 .admin-danger-actions 同一口径。 */
  .admin-confirm-actions { justify-content: stretch; }
  .admin-confirm-actions .btn { flex: 1 1 100%; }
}

/* ======================================================= T25 三层权限说明
   （pages/admin_users.js 的 permissionHelp，文案在 lib/admin_users.js）
   同一套类挂两处：页面顶部一张卡片、用户抽屉里贴着能力勾选区的一块。这里
   全部取现有令牌（字号 --fs-xs/sm/md、间距 --sp-*、圆角 --radius-sm），没有
   新值；颜色只用 --ink-2 / --muted 这一层，不借状态色——它是说明，不是告警，
   不该比"新建用户"和表格更抢眼。 */
.perm-block { min-width: 0; }
/* 三层那张表按**容器**宽度决定排一行还是堆一列：抽屉在 1440 的屏幕上也只有
   420 宽，按视口判断会让它在宽屏上排出四列再被挤碎。 */
.perm-panel { container-type: inline-size; margin-top: var(--sp-3); }
.perm-card { padding: var(--sp-3) var(--sp-4); }
.perm-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); min-width: 0; }
/* 标题（console-batch01-p2 T13，CONS-A-01 / FD17）：页面顶部那张卡片的标题就是
   卡片标题 h2（17/600/--ink），抽屉里复用时是“能力”一节里的分组 h3.sub-head
   （14/600/--ink-2）——字号颜色都交给这两个全站规则，这里只去掉外边距（标题与
   “展开说明”同一行）。那一句摘要是全站的 .note（14/400/--ink-2，CONS-A-04）。 */
.perm-title { margin: 0; }
.perm-summary { margin: var(--sp-1) 0 0; overflow-wrap: anywhere; }

.perm-layers { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-1); min-width: 0; }
.perm-layer {
  display: grid; gap: var(--sp-1) var(--sp-3); min-width: 0;
  padding: var(--sp-2) 0; border-top: 1px solid var(--border);
}
.perm-layer:first-child { border-top: none; padding-top: 0; }
.perm-layer-head { min-width: 0; }
.perm-layer-name { font-size: var(--fs-md); }
.perm-layer-role { margin: 2px 0 0; font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
/* 每格自带一个小标题（"不管什么"…）。窄屏堆成一列时它就是这一格的说明，
   宽屏排成四列时它又是那一列的表头——省掉一行真表头，也省掉"表头滚没了就
   读不懂"的老问题。 */
.perm-fact { display: grid; gap: 2px; min-width: 0; }
.perm-fact-k { font-size: var(--fs-xs); color: var(--muted); }
.perm-fact-v { font-size: var(--fs-sm); line-height: 1.5; color: var(--ink-2); overflow-wrap: anywhere; }

.perm-examples-head { margin: var(--sp-3) 0 var(--sp-1); font-size: var(--fs-sm); font-weight: 600; color: var(--ink-2); }
.perm-examples { margin: 0; padding-left: 1.2em; font-size: var(--fs-sm); color: var(--ink-2); }
.perm-examples li { margin-top: 2px; line-height: 1.5; overflow-wrap: anywhere; }

@container (min-width: 700px) {
  /* 够宽就是一张四列的表：层名一列，三个问题各一列，三行上下对齐。 */
  .perm-layer { grid-template-columns: minmax(9em, .9fr) 1.2fr 1fr 1.2fr; align-items: start; }
}

/* 抽屉里的那块：底色比抽屉深一档，与下面的勾选框分开；它是勾选区的注脚，
   不是又一张卡片，所以没有边框也没有阴影。 */
.perm-inline {
  margin: 0 0 var(--sp-3); padding: var(--sp-2) var(--sp-3);
  background: var(--plane); border-radius: var(--radius-sm);
}
.perm-grants-note { margin: 0 0 var(--sp-2); line-height: 1.5; }

/* T33 表单字段的内联错误。错误文字就在出错的那个输入框下面（.admin-form-field
   是 grid，gap 已经把两者隔开，不再另加 margin），长文案照样换行，390px 下不
   会顶出弹窗。红色不是唯一线索：.field-error p::before 已经有一个 ✕，而且焦点
   会落到第一个出错的字段上。文字用 --critical-text（深色主题另有一版，两边都
   >= 4.5:1），框线用 --critical——原来只有 input 有这条规则，下拉框和多行文本
   跟上；再加一道 inset 阴影当第二像素，1px 的红边在浅色主题下太细。 */
.admin-field-error p { overflow-wrap: anywhere; }
select[aria-invalid="true"], textarea[aria-invalid="true"] { border-color: var(--critical); }
input[aria-invalid="true"], select[aria-invalid="true"], textarea[aria-invalid="true"] {
  box-shadow: inset 0 0 0 1px var(--critical);
}

/* =========================================== T08 设备状态页：电源状态瓦片
   （pages/status.js 的 powerTile）
   形状就是同一行里的读数瓦片：.tile 的 .k / .v / .s + .dot 一个不换，所以两
   块电源瓦片与电压、温度那几块在同一个网格里对齐，窄屏也一起换行。这里只补
   三件读数瓦片没有的事：值是词不是数、"未知"这一档的颜色、以及提示句怎么
   占一整行。字号、间距、颜色全部取现有令牌，没有新值。 */

/* 值是"已通电"这样的词：--fs-num 是给数字的，等宽数字和负字距在汉字上只会
   显得松垮，所以退一档到 --fs-xl（页面标题那一档），字重与 .v 一致。 */
.power-tile .v { font-size: var(--fs-xl); font-variant-numeric: normal; letter-spacing: normal; }
.power-state { display: flex; align-items: baseline; gap: var(--sp-1); min-width: 0; }
/* 图标是第二条线索（色块之外），比字小一档，不抢词的位置；
   font-variant-emoji: text 防止 ✔ ✕ 被系统换成彩色 emoji。 */
.power-icon { font-size: var(--fs-md); flex: none; font-variant-emoji: text; }

/* 第五档：既不好也不坏，只是没读到。颜色用 --muted，与其它三档一样是既有
   令牌；文字里已经写着"未知"，颜色不是唯一线索。 */
.s.unknown { color: var(--muted); }
.s.unknown .dot { background: var(--muted); }

/* "尚未真机验证"那句话是网格里的一格，横跨整行：放在 .tiles 外面会在瓦片与
   时间范围之间多出一个 --gap，也会在窄屏上被当成又一块瓦片。 */
.power-note { grid-column: 1 / -1; margin: 0; overflow-wrap: anywhere; }

/* =========================================== T09 拍摄计划页：电源状态卡片
   （pages/schedule.js 的 createPowerCard）
   骨架照抄同页「相机时钟」卡：一个 .card，里面是「标签 / 值」的两列 dl，标签用
   13px --ink-2（字段标签那一档，与时钟卡同），窄屏标签列收窄。两张卡挨着放，行高、
   列宽、间距必须对得上，所以这里的值与 .schedule-clock-row 的一模一样——只是换了
   一组类名，免得在时钟卡的选择器上再挂一层别的意思。
   值那一格是全站的 .status（console-batch01-p2 T16，与设备状态页的电源瓦片同一张
   级别表：已通电 good ✓、已断电 serious ⚠、未知 muted 灰点），排法见文件末尾 T16 段。 */
.schedule-power-rows { display: grid; gap: var(--sp-1) 0; margin: 0; min-width: 0; }
.schedule-power-row {
  display: grid; grid-template-columns: 7.5em minmax(0, 1fr);
  gap: 2px var(--sp-3); align-items: baseline; min-width: 0;
}
.schedule-power-row dt { margin: 0; color: var(--ink-2); font-size: var(--fs-sm); }
.schedule-power-row dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* 矛盾提示与"尚未真机验证"那句：前者是 warning 级 .notice（与时钟卡的熔断提示同一个
   样式，颜色在 console-batch01-p2 T02 段），后者是卡片脚注（.note）。 */
.schedule-power-conflict { margin: var(--sp-3) 0 0; }
.schedule-power-note { margin: var(--sp-3) 0 0; max-width: 42em; overflow-wrap: anywhere; }

@media (max-width: 480px) {
  /* 与时钟卡同步收窄：两张卡的标签列在窄屏也得对齐。 */
  .schedule-power-row { grid-template-columns: 6em minmax(0, 1fr); }
  .schedule-power-note { max-width: none; }
}

/* =========================================== T11 拍摄计划页：STM32 命令卡片
   （pages/schedule.js 的 createSystemCard）
   骨架与同页「相机时钟」「电源状态」两张卡一致：一个 .card、一排 .form-row 按钮、
   一个「标签 / 值」的两列 dl、一行"标记 + 文字"的结论。按钮用既有的 .btn 与
   .btn.admin-danger-go（管理页那个红色确认按钮），弹窗整个来自
   lib/confirm_delete.js，样式在上面的 .admin-confirm* 里，这里一个字不改。
   新增的只有阶段那一行：它不是进度条（阶段之间没有帧数可数，硬凑百分比就是编的），
   而是一串会换行的小格子，每格一个 ✓ / ▶ / ✗ / 序号加一个词。字号、颜色、间距
   全部取现有令牌，没有新值。 */
.schedule-system-intro { margin: var(--sp-2) 0 0; max-width: 42em; overflow-wrap: anywhere; }
.schedule-system-actions { margin: var(--sp-3) 0 0; }
.schedule-system-hint { max-width: 24em; overflow-wrap: anywhere; }
.schedule-system-readonly { margin: var(--sp-3) 0 0; max-width: 42em; overflow-wrap: anywhere; }
/* 后果说明：列宽与 .schedule-power-row 一样，三张卡的标签列对得上。 */
.schedule-system-notes { display: grid; gap: var(--sp-1) 0; margin: var(--sp-3) 0 0; min-width: 0; }
.schedule-system-notes dt {
  grid-column: 1; margin: 0; color: var(--ink-2); font-size: var(--fs-md); font-weight: 600;
}
.schedule-system-notes dd {
  grid-column: 1; margin: 0 0 var(--sp-1); min-width: 0;
  color: var(--ink-2); font-size: var(--fs-md); overflow-wrap: anywhere;
}
.schedule-system-notice { margin: 0; }
/* 后果的几条事实：缩进一档，和上面那句总结分开，但仍然是同一段说明。 */
.schedule-system-lines { margin: var(--sp-1) 0 0; padding-left: 1.2em; }
.schedule-system-lines li { overflow-wrap: anywhere; }
.schedule-system-messages:empty { display: none; }
.schedule-system-messages { margin: var(--sp-3) 0 0; }
/* 进度与上面的操作区之间来一道分隔线：一张卡里"能做什么"和"上次做了什么"是两件事。 */
.schedule-system-progress {
  margin: var(--sp-4) 0 0; padding: var(--sp-3) 0 0; border-top: 1px solid var(--border);
}
.schedule-system-progress-head { margin: 0 0 var(--sp-2); }
/* 结论行是全站的 .status（console-batch01-p2 T16，FD10），排法见文件末尾 T16 段。 */
.schedule-system-state { margin: 0; min-width: 0; }
/* 阶段：一行排不下就换行，每格自己不断行（"等待重连"拆成两行没法读）。 */
.schedule-system-steps {
  list-style: none; margin: var(--sp-2) 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); min-width: 0;
}
.schedule-system-step {
  display: flex; align-items: baseline; gap: var(--sp-1);
  font-size: var(--fs-sm); color: var(--muted);
}
.schedule-system-mark {
  flex: none; min-width: 1em; font-variant-emoji: text; font-variant-numeric: tabular-nums;
}
.schedule-system-step[data-state="done"] { color: var(--ink-2); }
.schedule-system-step[data-state="done"] .schedule-system-mark { color: var(--good-text); }
.schedule-system-step[data-state="current"] { color: var(--ink); font-weight: 600; }
.schedule-system-step[data-state="current"] .schedule-system-mark { color: var(--accent-text); }
.schedule-system-step[data-state="bad"] { color: var(--ink); font-weight: 600; }
.schedule-system-step[data-state="bad"] .schedule-system-mark { color: var(--critical-text); }
.schedule-system-when:empty { display: none; }
.schedule-system-when { margin: var(--sp-2) 0 0; }
.schedule-system-empty { margin: var(--sp-3) 0 0; }

@media (max-width: 480px) {
  /* 危险按钮在窄屏独占一行：和「立即校时」「保存时区」同一口径，免得两个按钮
     挤在一行被误触。说明那一句跟着按钮走到下一行。 */
  .schedule-system-actions .btn { flex: 1 1 100%; width: 100%; min-height: var(--control-h-touch); }
  .schedule-system-hint, .schedule-system-intro, .schedule-system-readonly { max-width: none; }
}

/* T24 ------------------------------------------------------- link health
   页头链路状态徽章（LINK-ux-01）：全站的 .status（good 链路在线 / critical
   链路断开 / warning 从未上报），文字本身说清状态，颜色只是加强。页头按
   基线对齐，而圆点没有文字基线，所以徽章单独居中对齐。「刷新」在时间范围条
   行尾（lib/ui.js refreshControl，console-batch01-p2 T12）。 */
.link-page .page-head .link-badge { margin: 0; white-space: nowrap; align-self: center; }
/* 重试后焦点落到卡片标题（LINK-func-05）：用和控件一样的焦点环。 */
.link-page h2[tabindex="-1"]:focus { outline: none; }
.link-page h2[tabindex="-1"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* 分段按钮与「刷新」的悬停反馈用全站那一套（.seg 只有一种规格，CONS-B-10）：
   原来本页单独把叠色提到 10% 的那条已删（console-batch01-p2 T12）。 */

/* T19 ------------------------------------ sticky bars and keyboard focus
   吸底保存栏（设置页）和照片页的吸顶选择栏在视口里占了一条，浏览器却认为
   那一条仍是"可见区域"：焦点落到栏下面的控件、409 冲突框 scrollIntoView
   之后的「刷新」都会被盖住（WCAG 2.4.11）。页面上有这种栏时给 html 留出
   同样高度的 scroll-padding，焦点滚动与 scrollIntoView 都按它避让。
   --save-bar-h / --sel-bar-h 由 pages/settings_common.js 的 trackStickyBar
   按栏的实际高度写在 <html> 上，栏移除后撤掉；括号里是 JS 量到之前的初值。
   离开这些页面后 :has() 不再命中，别的页面仍是 auto。 */
html:has(.save-bar) { scroll-padding-bottom: calc(var(--save-bar-h, 96px) + var(--sp-4)); }
html:has(.sel-bar) { scroll-padding-top: calc(var(--sel-bar-h, 56px) + var(--sp-4)); }

/* T34 ---------------------------------------- video page: new-video form
   pages/videos.js 的 dailyBuilder（「新建：每天同一时间一张」卡片）。
   VID-ui-04：每条错误挂在出错的那个控件下面——lib/dom.js 的 createFieldErrors
   把错误节点插在控件后面，也就是 .field 里面。.field 允许换行，错误独占一行；
   contain: inline-size 让那行字不参与撑宽 .field：宽度仍由「标签 + 控件」决定，
   长句在这个宽度里折行，同一排的其它字段不会被挤到下一排。标签与控件之间的
   8px 改由标签的右外边距给：留着 column-gap 的话，浏览器算 .field 宽度时会把
   错误那一项前面的间距也算进去，一出错整排就往右挪 8px。控件与错误之间取
   管理页 .admin-form-field 的 4px（再加 .field-error p 自己的 2px）。折成两行
   时两行等长（balance），不会只剩「间」一个字掉在第二行。 */
.daily-builder .field { flex-wrap: wrap; gap: var(--sp-1) 0; }
.daily-builder .field > label { margin-right: var(--sp-2); }
.daily-builder .field > .field-error { flex: 1 1 100%; min-width: 0; contain: inline-size; }
.daily-builder .field-error p { overflow-wrap: anywhere; text-wrap: balance; }
/* 有错误的那一排顶端对齐：第一行（标签 + 控件、按钮）照旧齐平，错误只往下长，
   同排的按钮和其它字段不会被居中到错误的半腰。 */
.daily-builder .form-row:has(.field-error) { align-items: flex-start; }
/* 表单级的错误块（选图失败、参数组合超限……）与上下两排之间各 8px，和排与排
   之间一样；空着时这两个 margin 叠进相邻那排的 8px 里，不占高度。不用
   display:none：它是 role="alert"，留在无障碍树里，内容一出来就会被读到。 */
.daily-builder > .field-error, .daily-options > .field-error { margin: var(--sp-2) 0; }
/* VID-ui-15：说明文字原来是浏览器默认的 13px 上下外边距，不在 4px 网格上；
   标题下的 8px 保留，说明下面 12px。结果区空着时不留它的 12px 上边距（它是
   aria-live 区，同样不用 display:none），于是展开「视频设置」前后，卡片底部都是
   最后一排的 8px + 卡片内边距 16px。 */
.daily-builder > p.meta { margin: 0 0 var(--sp-3); }
.daily-result:empty { margin-top: 0; }
/* 勾选框、单选框用主色，不用浏览器默认的蓝（全站 .check 同一种）。 */
.check input[type="radio"], .check input[type="checkbox"] { accent-color: var(--accent); }

@media (max-width: 720px) {
  /* VID-ux-10：窄屏标签在上、控件占满一行。原来 .field 是不换行的 inline-flex，
     「相机目录」被下拉框挤成 13px 宽的竖排，下拉框右端超出卡片。 */
  .daily-builder .field {
    display: flex; flex: 1 1 100%; flex-direction: column; flex-wrap: nowrap;
    align-items: stretch; gap: var(--sp-1); min-width: 0;
  }
  .daily-builder .field > label { margin-right: 0; }
  .daily-builder .field select, .daily-builder .field input { width: 100%; min-width: 0; }
  .daily-builder .field > .field-error { flex: none; contain: none; }
  /* 缩略图不再套一层 460px 的内层滚动：手机上页面滚动里再嵌一层很难滑。 */
  .daily-strip { max-height: none; overflow: visible; padding-right: 0; }
}

/* T35 ------------------------ 管理页三张表：窄屏卡片行、横滑渐隐、行触控
   （AU-ui-01、AP-ui-02、AC-ui-02、ADMCAM-ux-08；pages/admin_users.js、
   admin_projects.js、admin_cameras.js）。颜色、分隔线、圆角全部取现有令牌，
   没有新色；mask 里的 #000 只表示"不透明"，不会画到屏幕上。 */

/* 用户表（AU-ui-01）。姓名最长 14em，超出省略，完整姓名在单元格的 title 里
   （原来 nowrap 又没有上限，一个长姓名比邮箱列还宽）。邮箱只在 "@" 后面折：
   JS 把邮箱拆成 "本地部分@" 与 "域名" 两段、中间插 <wbr>。表格宽度下每段是
   一个 inline-block：外面这一行只把它当一整块，放不下时只能在两块之间的
   <wbr> 处换行，"mirs-innov.com" 的连字符不再是外层的折行点（否则照样折成
   "…@mirs-" / "innov.com"）。兜底（第 2 轮，F1）：一段本身比整列还宽时
   （69 字符的 "very.long.…@construction-partner.example.org"），这一段在
   自己的块里先按连字符、再按任意字符折，不撑宽列——anywhere 让块的最小宽
   度只剩一个字，列宽由下面的 10em 下限说了算。列的下限 13em → 10em：原来
   的 13em 在 1024 宽下让表格多出 17px 横滑。卡片里（≤720px）整行都给邮箱，
   两段照常折行。邮箱包在按钮里的一个 span 中：按钮是 inline-flex，<wbr>
   直接放进去会变成一个空的弹性项，折行点就没了。 */
.admin-users-table .admin-name-text {
  display: block; max-width: 14em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.admin-table .admin-cell-email { overflow-wrap: break-word; min-width: 10em; }
.admin-email-text { min-width: 0; }
@media (min-width: 721px) {
  .admin-email-part { display: inline-block; max-width: 100%; overflow-wrap: anywhere; }
  /* 表格本来就要横滑（768 这类宽度，JS 加 .scroll-x）时，10em 的下限没有必要：
     所有列都被压到最小宽度，"liudongyu.uav@" 会在自己的块里被挤成两行。放回
     13em，常见的一段邮箱都放得下；放得下的宽度（1024 起）不受影响。 */
  .scroll.scroll-x .admin-table .admin-cell-email { min-width: 13em; }
}

/* 横滑提示（AP-ui-02、AC-ui-02）：右边还有没露出来的列时（JS 给 .scroll 加
   .scroll-more），右侧 40px 渐隐；滚到最右、或者本来就放得下时 JS 去掉这个
   类，渐隐消失。手机上表格是卡片，不横滑，所以只在 721px 以上生效——相机表
   在 1024~1250 宽的笔记本上也要横滑，所以不止平板这一档。 */
@media (min-width: 721px) {
  .admin-table-box .scroll.scroll-more {
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent);
    mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent);
  }
}

/* 触屏平板上仍是表格：行首的 id / 邮箱按钮原来只有一行字高（20.8px），整行
   也只有 38px（ADMCAM-ux-08）。按钮补到触控高度，这一格的上下内距收成 4px，
   行高 52px。鼠标用户不受影响。 */
@media (pointer: coarse) and (min-width: 721px) {
  .admin-card-table > tbody > tr > th { padding-top: var(--sp-1); padding-bottom: var(--sp-1); }
  .admin-card-table .btn.link.row-link {
    min-height: var(--control-h-touch);
  }
}

/* ≤720px：每条记录一张卡片（AU-ui-01、AP-ui-02、AC-ui-02、ADMCAM-ux-08）。
   写法照照片页的瓦片（--surface 底、--border 边、--radius 圆角）；卡片里
   "标签 值"一行一对，标签照状态页 .meta-list 的 dt（--muted、--fs-sm）。标签
   是单元格的 data-label（列名），用伪元素写出来，不经 innerHTML。表头对读屏
   仍在，只是看不见。选中、悬停从单元格挪到整张卡片上，颜色不变。
   只认 .admin-card-table（用户、项目、相机三张表）：审计表也是 .admin-table，
   它有展开行（colspan），不在本段范围。 */
@media (max-width: 720px) {
  .admin-card-table.cm4-table { display: block; min-width: 0; }
  .admin-card-table.cm4-table > thead {
    display: block; position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    border: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
  }
  .admin-card-table.cm4-table > tbody { display: grid; gap: var(--sp-2); }
  .admin-card-table.cm4-table > tbody > tr {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3);
    min-width: 0; min-height: var(--control-h-touch); padding: var(--sp-2) var(--sp-3);
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  }
  .admin-card-table.cm4-table > tbody > tr:hover { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
  .admin-card-table.cm4-table > tbody > tr.is-selected {
    background: color-mix(in srgb, var(--accent) 10%, var(--surface));
    box-shadow: inset 3px 0 0 var(--accent);
  }
  /* 单元格原来的底色、竖线、sticky、最小宽度、nowrap、右对齐全部收回：选择
     器要压过 .admin-cam-row.is-selected .admin-cell-id 与
     .admin-user-row.is-selected th:first-child 这类三个类的写法。 */
  .admin-card-table.cm4-table > tbody > tr:is(.admin-user-row, .admin-project-row, .admin-cam-row) > :is(th, td) {
    position: static; min-width: 0; padding: 0; border: 0; background: none; box-shadow: none;
    text-align: left; white-space: normal; overflow-wrap: anywhere;
  }
  .admin-card-table > tbody > tr > td {
    flex: 1 1 100%; display: grid; grid-template-columns: var(--admin-card-label) minmax(0, 1fr);
    column-gap: var(--sp-3); align-items: baseline;
  }
  .admin-card-table > tbody > tr > td::before { content: attr(data-label); color: var(--muted); }
  /* 卡片标题：行首那一格（邮箱 / 项目 id / 相机 id），按钮补到触控高度。 */
  .admin-card-table > tbody > tr > th {
    flex: 1 1 100%; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-2);
    font-size: var(--fs-md);
  }
  .admin-card-table .btn.link.row-link { min-height: var(--control-h-touch); }
  .admin-card-table .admin-flag { margin-left: 0; }
  /* 标签列宽按各表最长的列名定（"最近活动" / "Dropbox 根目录" / "最近上报 STM32"）。 */
  .admin-users-table { --admin-card-label: 5em; }
  .admin-projects-table { --admin-card-label: 8em; }
  .admin-cameras-table { --admin-card-label: 8.5em; }
  /* 卡片里有整行的宽度，姓名不必截断。 */
  .admin-users-table .admin-name-text { max-width: none; overflow: visible; white-space: normal; }
  /* 相机卡片第一行：id + 开通状态，紧跟注册包 / HMAC / CM4 令牌三个带圆点的
     状态（一行放不下就接着折），其余字段在下面一行一对。 */
  .admin-cameras-table > tbody > tr > .admin-cell-id { flex: 0 1 auto; }
  .admin-cameras-table > tbody > tr > .admin-cell-prov { flex: 0 1 auto; display: block; }
  .admin-cameras-table > tbody > tr > .admin-cell-prov::before { content: none; }
  .admin-cameras-table > tbody > tr > :is(.admin-cell-reg, .admin-cell-hmac, .admin-cell-token) {
    flex: 0 1 auto; display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1);
  }
  .admin-cameras-table > tbody > tr > td:not(.admin-cell-prov, .admin-cell-reg, .admin-cell-hmac, .admin-cell-token) {
    order: 1;
  }
}

/* T38 ---------------------------------- 审计页：展开区的位置、对象行、未变的字段
   （pages/admin_audit.js）。颜色、字号、间距全部取现有令牌，没有新值。

   展开区贴着滚动框的可视区走：表格留着 960px 的最小宽度，窄屏上要先横向滚到
   "详情"列才点得到摘要，而展开行的 <td colspan=6> 从表格最左边排起，内容落在
   左边看不见的地方（375 宽实测清单在 x = −584）。把对象行、清单与原始 JSON 包
   在一个盒子里，宽度按容器收成可视区那么宽（100cqw 减去单元格左右内边距，与
   .admin-changes 同一个算法），再 sticky 贴在可视区左边——sticky 只能在自己的
   包含块（那个 960px 宽的 <td>）里挪，盒子比它窄才挪得动。宽屏上表格没有内部
   滚动，盒子就是 100% 宽，sticky 不起作用，位置与改动前一致。 */
.admin-audit-detail-body {
  position: sticky; left: var(--sp-3);
  max-width: min(100%, calc(100cqw - var(--sp-3) * 2)); min-width: 0;
  white-space: normal;
}
/* 清单上方那一行对象："相机 A00001 · 分区 相机参数 · 版本 34"。字号与折叠那
   一行的摘要相同（表格正文 --fs-sm）：它就是摘要前缀的展开写法。标签用次要色，
   值用正文色，一眼先看到的是 A00001。 */
.admin-object {
  margin: 0 0 var(--sp-2); font-size: var(--fs-sm); line-height: 1.5;
  color: var(--ink); overflow-wrap: anywhere;
}
.admin-object-label, .admin-object-sep { color: var(--muted); }
.admin-object-value { color: var(--ink); font-weight: 600; }
/* 前后值相同的一行（升级前的老记录）：整行退成次要色，新值不再加粗、不再
   描主色边——它不是一条改动。"（未变）"三个字才是依据，灰色只是帮忙。 */
.admin-change-same .admin-change-field,
.admin-change-same .admin-change-arrow,
.admin-change-same .admin-change-tag,
.admin-change-same .admin-change-val .admin-change-value { color: var(--muted); font-weight: 400; }
.admin-change-same .admin-change-val { background: transparent; border-color: var(--border); }
.admin-change-same .admin-change-note { color: var(--muted); }

/* T21 ------------------------------------- voltage status: short + details
   （lib/chart.js 的 voltageStatus / voltageTextNode，pages/status.js 的 tile，
   pages/overview.js 的状态行）
   - 状态行原来 align-items: center：文字一折行，8px 圆点就跑到段落正中间，
     像第二行的列表符号（R1-OV-ui-03、R1-ST-ui-01）。改成贴第一行：圆点上边距
     = (行高 1.6em − 8px) / 2，单行时与居中完全重合。只管读数卡片和总览卡片里
     的 .s；表格、页头徽章里的 .s 不动。
   - .tiles 原来按同一行最高的卡片拉伸（stretch），电压卡片多一行字就把同一行
     5 张卡片一起撑高（R1-STATUS-ux-01）。改成各按内容高。
   - 结论按 voltageStatus 的 parts 分成 inline-block 小段：卡片不够宽时只在
     "电压很低 < 11.0 V" 和 "· CM4 在线" 之间换行，末行不会只剩一个字。
   颜色、字号、间距全部取已有令牌；ⓘ 与站内其它 ⓘ 提示同为 --accent-text，
   展开区的"下一步"和链接直接用功耗页电池卡片的 .battery-next / .battery-link。 */
.tile .s, .cam-card .s { align-items: flex-start; }
.tile .s .dot, .cam-card .s .dot { margin-top: calc((1.6em - 8px) / 2); }
.tiles { align-items: start; }
.s-text { min-width: 0; }
.s-part { display: inline-block; max-width: 100%; }

/* 设备状态页的电压卡片：整张卡片是 <details>，<summary> 就是卡片原来的三行
   （标签、读数、结论），点卡片任何地方都能展开。展开后占满一整行，完整说明
   不必挤在 140px 宽的一列里折十几行。 */
.voltage-summary { display: block; cursor: pointer; list-style: none; border-radius: var(--radius-sm); }
.voltage-summary::-webkit-details-marker { display: none; }
.voltage-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.voltage-summary .k { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
.voltage-summary .v { display: block; }
.voltage-more-mark { flex: none; color: var(--accent-text); letter-spacing: normal; white-space: nowrap; }
.voltage-summary:hover .voltage-more-mark { text-decoration: underline; text-underline-offset: 3px; }
.voltage-tile[open] { grid-column: 1 / -1; }
.voltage-more {
  margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--border);
  max-width: 44em;
}
.voltage-detail { margin: 0; font-size: var(--fs-sm); color: var(--ink); overflow-wrap: anywhere; }
/* 宽屏（读数网格正好 6 列）让电压卡片占两列：结论连同"· CM4 最近无上报"一行
   写得下，卡片仍是一行高，10 张卡片也还是两行排满。低于这个宽度网格是 5 列或
   更少，占两列会多出一整行，不如让结论折成两行。 */
@media (min-width: 1220px) {
  .voltage-tile { grid-column: span 2; }
}

/* T41 ====================================== 相机管理：新建 / 编辑相机弹窗
   （pages/admin_cameras.js 的 openCameraForm）
   十一个输入框排成一列时，1440×900 下弹窗要滚 100 多 px，375 下要滚一屏
   多，按钮和标题都滚走了（AC-ui-04）。这里做三件事，只作用于这一个弹窗
   （.admin-camera-modal 由页面加在面板上），其它弹窗不动：
   1. 面板加宽到 640，短字段两两并排、序列号两列，一屏基本放得下；
   2. 标题栏与底部那一条（表单级错误 + 取消 / 创建）sticky 常驻，底色就是
      面板的 --surface-raised（T03），深浅主题都与面板同色，没有新颜色；
      分隔线用 --border；
   3. 面板的 scroll-padding 取这两条的实测高度（页面写进两个变量），焦点滚
      动、Tab 和 scrollIntoView 都让开常驻区，不会把出错的字段藏在它们底下。
   面板自己有 --sp-4 的内边距：常驻的两条用负外边距把底色铺到面板边缘，
   再用同样的内边距把内容放回原位，滚到它们底下的内容不会从两侧露出来。 */
.modal.admin-camera-modal {
  width: min(640px, 100%);
  scroll-padding-top: var(--admin-form-head-h, 0px);
  scroll-padding-bottom: var(--admin-form-foot-h, 0px);
}
.admin-camera-modal > .modal-head {
  position: sticky; top: calc(-1 * var(--sp-4)); z-index: 1;
  margin: calc(-1 * var(--sp-4)) calc(-1 * var(--sp-4)) var(--sp-3);
  padding: var(--sp-4) var(--sp-4) var(--sp-3);
  background: var(--surface-raised); border-bottom: 1px solid var(--border);
}
.admin-camera-form .admin-form-foot {
  position: sticky; bottom: calc(-1 * var(--sp-4)); z-index: 1;
  display: grid; gap: var(--sp-2); min-width: 0;
  margin: 0 calc(-1 * var(--sp-4)) calc(-1 * var(--sp-4));
  padding: var(--sp-3) var(--sp-4) var(--sp-4);
  background: var(--surface-raised); border-top: 1px solid var(--border);
}
.admin-camera-form .admin-form-foot .admin-form-error p { overflow-wrap: anywhere; }
.admin-form-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); min-width: 0; align-items: start; }
.admin-camera-form .admin-serials { grid-template-columns: repeat(2, minmax(0, 1fr)); }

@media (max-width: 600px) {
  .admin-form-pair, .admin-camera-form .admin-serials { grid-template-columns: minmax(0, 1fr); }
}

/* 新建相机的整机编号：FixCamera- 固定前缀 + 代次字母 + 5 位编号，一行排开；
   窄屏也不换行（三段加起来不到 260px）。 */
.admin-unit-id { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.admin-unit-prefix {
  font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  color: var(--ink-2); white-space: nowrap;
}
.admin-unit-id .admin-unit-gen { flex: 0 0 auto; width: auto; min-width: 4.5em; }
.admin-unit-id .text { flex: 1 1 auto; min-width: 0; max-width: 10em; font-variant-numeric: tabular-nums; }
.admin-unit-preview { margin-top: var(--sp-1); color: var(--ink); overflow-wrap: anywhere; }

@media (max-width: 480px) {
  /* 其它管理表单在这个宽度把按钮叠成两行；常驻在底部的这一条叠两行会多吃
     52px 的可视高度，两个按钮并排仍各有 140px 以上，取消在左、创建在右。 */
  .admin-camera-form .admin-form-actions .btn { flex: 1 1 0; }
}

/* T20 ------------------------ 手机上的吸底保存栏 / 吸顶选择栏只占一行
   （pages/settings_common.js 的 createSaveBar / createMoreMenu / watchCompact，
   pages/photos.js 的选择栏；R1-SCH-ui-01、R1-SCH-ux-02、R1-CAM-ui-02、
   R1-CAM-ux-01、R1-STO-ux-02、R1-PH-ui-02、R1-PH-ux-04）。
   原来 ≤480px 时保存栏里每个按钮独占一行（拍摄计划 279px、相机参数 205–227px），
   照片选择栏折成三行（143px），吸住以后一直挡着一大块屏幕。现在 ≤480px 时 JS
   把次要按钮（各个「历史」、全选 / 清空）收进「更多」，并给栏加 .is-compact；
   宽屏不生成「更多」，下面带 .is-compact 的规则都不命中，保存栏与原来一样。
   颜色、阴影、圆角、间距全部取现有令牌，没有新值。 */

/* 「更多」弹出层：照 .chart-tip / .toast 的弹出样式，底色用弹窗那一层的
   --surface-raised（深色主题下比栏本身的 --surface 亮一级）。保存栏在底部，
   向上弹；选择栏在顶部，向下弹。里面就是页面原来的那几个按钮，整行宽、文字靠左。 */
.more-wrap { position: relative; flex: none; }
.more-menu {
  position: absolute; left: 0; z-index: 6;
  display: flex; flex-direction: column; gap: var(--sp-1);
  min-width: max-content; max-width: calc(100vw - var(--sp-6));
  background: var(--surface-raised); border: 1px solid var(--border); border-radius: var(--radius-sm);
  padding: var(--sp-2); box-shadow: var(--shadow-pop);
}
.more-menu[data-placement="up"] { bottom: calc(100% + var(--sp-1)); }
.more-menu[data-placement="down"] { top: calc(100% + var(--sp-1)); }
.more-menu > .btn { justify-content: flex-start; width: 100%; white-space: nowrap; }
.more-btn[aria-expanded="true"] { border-color: var(--muted); }

/* 照片选择栏：没选照片时不吸顶，只在有选择时吸住（R1-PH-ux-04）。T19 给 html
   留的 scroll-padding-top 也只在它吸住时才需要。 */
.sel-bar:not(.has-selection) { position: static; }
html:has(.sel-bar:not(.has-selection)) { scroll-padding-top: auto; }

@media (max-width: 480px) {
  /* 保存栏一行两格：「更多」（「历史记录」收在里面）与「保存并下发」，后者占满其余宽度。
     三个设置页都已没有留在栏里的项；下面 [data-save-keep] 几条是给留下的项的通用排法：
     再有页面留下一项时，它与「保存并下发」各占一个 1fr。用网格而不是 flex: 1 1 0 平分
     ——按钮有左右内边距、留下的那一格没有，flex 分出来差 18px；网格的 1fr 两格严格等宽。
     按钮顶端对齐，留下那一项下面的说明出现或消失时，「保存并下发」不上下挪。
     "● 有未保存的更改"（下面改成绝对定位）和空的 .spacer 不占格子；灰按钮的说明
     （.save-idle-hint，为什么「保存并下发」是灰的）这一行放不下，隐藏，整句在按钮的 title
     里（见末尾 save-and-deliver-unify-v1 T01 段）。 */
  .save-bar.is-compact { padding: var(--sp-2) var(--sp-3); }
  .save-bar.is-compact .save-row {
    display: grid; grid-auto-flow: column; grid-template-columns: auto;
    grid-auto-columns: minmax(0, 1fr); align-items: start;
  }
  .save-bar.is-compact .save-row > .spacer { display: none; }
  .save-bar.is-compact .save-row .btn { padding-left: var(--sp-2); padding-right: var(--sp-2); }
  .save-bar.is-compact .save-row > .save-btn { min-width: 0; }
  .save-bar.is-compact [data-save-keep] { align-items: stretch; }
  .save-bar.is-compact [data-save-keep] > .btn { width: 100%; }
  /* 说明只在按钮不可用时显示（写的是为什么不能下发），一行，放不下用省略号，
     完整的一句在 title 里（JS 同步），读屏经 aria-describedby 照常读全句。
     这行说明和保存结果行距收到 1.35（字号不变），相机参数页载入时就有一行
     结果，两行加起来也不超过 130px。 */
  .save-bar.is-compact [data-save-keep] .meta {
    max-width: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.35;
  }
  .save-bar.is-compact .save-line { margin-top: var(--sp-1); line-height: 1.35; }
  .save-bar.is-compact [data-save-keep] > .btn:not(:disabled) ~ .meta { display: none; }
  /* "● 有未保存的更改" 不再占位置（原来一出现就把保存按钮挤到下一行最左边，
     R1-STO-ux-02）：文字留给读屏，屏幕上改成「保存并下发」右上角的一个圆点，颜色就是
     这行字前面那个 ● 的 --serious，外圈一道 --surface 把它和按钮分开。圆点伸出保存行的
     那几 px 由末尾 save-and-deliver-unify-v1 T06 段收回。 */
  .save-bar.is-compact .dirty {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    border: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
  }
  .save-bar.is-compact .save-btn { position: relative; }
  .save-bar.is-compact.is-dirty .save-btn::after {
    content: ""; position: absolute; top: -4px; right: -4px;
    width: 10px; height: 10px; border-radius: var(--radius-pill);
    background: var(--serious); box-shadow: 0 0 0 2px var(--surface);
  }

  /* 照片选择栏一行：已选 N 张、「更多」（全选已加载 / 清空选择）、创建延时视频。
     提示只在不吸顶（没选照片）时另起一行；有选择时它本来就是空的，超出上限的
     那句错误仍单独占一行显示。 */
  .sel-bar.is-compact .sel-hint { order: 1; flex: 1 1 100%; min-width: 0; }
  .sel-bar.is-compact.has-selection .sel-hint:not(.over) { display: none; }
}

/* T39 ------------------- 审计页：筛选条（动作类型 / 相机 / 校验）与窄屏卡片
   （AUD-func-03、AUD-func-05、AUD-ui-03、AUD-ux-06；pages/admin_audit.js）。
   颜色、字号、间距、圆角全部取现有令牌，没有新值，所以不涉及三处主题块。

   字号与全站同一口径（CONS-A-03 / CONS-B-10，console-batch01-p2 T01 改）：
   标签 13（.admin-filter-field label）、「刷新 / 重置」是 .btn.small，下拉、
   输入框与日期框 15/400（全站控件规则），快捷前缀是全站 .seg（15px、左右
   12px、高 34）——筛选条不再把整条收到 --fs-sm。底色：下拉、动作前缀框、
   日期框都是 --plane（T03 统一过的输入控件底色），按钮仍是 --surface——按钮
   与输入框是两类东西，全站同一个口径。标签列宽按 13px 标签计（与所在元素
   字号无关），窄屏里控件列与「重置」「起晚于止」的缩进仍在同一条竖线上。 */
.admin-audit-filters { --audit-label-w: calc(var(--fs-sm) * 4.5); }
/* 重置靠右：放不进一行时它跟着最后一项换行，而不是孤零零落在第二行最左边
   （R1-AUD-ui-01 顺带）。 */
.admin-audit-filters > [data-action="reset"] { margin-left: auto; }
/* 起晚于止：贴在日期那一格下面，单独占一行（排在重置之后）；平时是空的。 */
.admin-audit-range-error { order: 1; flex: 1 1 100%; min-width: 0; }
.admin-audit-range-error:empty { display: none; }
.admin-audit-hint { margin: 0; text-align: center; }
/* 手机上收起过滤条的按钮：宽屏过滤条始终展开，按钮不出现。三角跟着
   aria-expanded 翻转（与详情摘要的 ▸ / ▾ 同一种写法），看得出现在是收着还是
   展开着。 */
.admin-filter-toggle { display: none; }
/* 三角只是装饰：带替代文字的写法让读屏不把它念进按钮名（不认这种写法的
   浏览器用前一条）。 */
.admin-filter-toggle::after { content: "▾"; content: "▾" / ""; font-size: var(--fs-xs); color: var(--muted); }
.admin-filter-toggle[aria-expanded="true"]::after { content: "▴"; content: "▴" / ""; }
/* 表格布局下邮箱只在放不下整个词时才折：anywhere 会把列的最小宽度压到一个
   字，自动表格布局又不理单元格的 min-width，1024 宽下 "admin@example.co / m"
   就这样折开了。换成 break-word，放不下的列由外层 .scroll 横向滚动。 */
.admin-audit-table .admin-cell-user { overflow-wrap: break-word; }

/* ≤720px：过滤条默认收成页头里的"筛选"按钮；展开后每项一行，标签同宽，
   所有控件从同一条竖线开始、一直伸到右边缘（原来下拉封顶 12em，只有 180px）。
   动作前缀的快捷按钮、起止两个日期框各在控件那一列里另起一行；重置与"起晚于
   止"也缩进到这一列，整条过滤条只有一条左边缘。 */
@media (max-width: 720px) {
  .admin-filter-toggle { display: inline-flex; }
  .admin-audit-filters.is-collapsed { display: none; }
  .admin-audit-filters .admin-filter-field {
    display: grid; grid-template-columns: var(--audit-label-w) minmax(0, 1fr);
    align-items: center; gap: var(--sp-2); flex: 1 1 100%; min-width: 0;
  }
  .admin-audit-filters .admin-audit-select,
  .admin-audit-filters .admin-audit-action,
  .admin-audit-filters .admin-audit-date { width: 100%; min-width: 0; max-width: none; }
  .admin-audit-filters .admin-action-seg { grid-column: 2; display: flex; min-width: 0; }
  .admin-audit-filters .admin-action-seg button { flex: 1 1 0; }
  .admin-audit-filters > [data-action="reset"],
  .admin-audit-filters > .admin-audit-range-error {
    flex: 0 1 calc(100% - var(--audit-label-w) - var(--sp-2));
    margin-left: calc(var(--audit-label-w) + var(--sp-2));
  }
}

/* ≤1023px：每条记录一张卡片（AUD-ux-06）。平板上滚动框 479 宽、表格 960 宽，
   "动作"只露出 31px；手机上只看得到"时间 / 用户"。卡片第一行是时间与动作，
   下面"用户 / 项目 / IP / 详情"一行一对，标签是单元格的 data-label（伪元素，
   不经 innerHTML）。一条记录一个 <tbody>，看得见的那一行与展开的详情同在一张
   卡片里。写法照 T35 的管理页卡片（--surface 底、--border 边、--radius 圆角，
   标签 --muted），但只认 .admin-audit-table：审计表不带 .admin-card-table，
   T35 那一段不作用在这里。表头对读屏仍在，只是看不见。 */
@media (max-width: 1023px) {
  .admin-audit-table.cm4-table { display: block; min-width: 0; --audit-card-label: 3em; }
  .admin-audit-table.cm4-table > thead {
    display: block; position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    border: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
  }
  .admin-audit-table.cm4-table > tbody.admin-audit-entry {
    display: block; min-width: 0; padding: var(--sp-2) var(--sp-3);
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  }
  .admin-audit-table.cm4-table > tbody.admin-audit-entry + tbody.admin-audit-entry { margin-top: var(--sp-2); }
  .admin-audit-table.cm4-table > tbody > tr.admin-audit-row {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px var(--sp-3); min-width: 0;
  }
  /* 单元格原来的内距、分隔线、nowrap、最小 / 最大宽度全部收回：卡片里有整行
     的宽度，邮箱与摘要照常折行。 */
  .admin-audit-table.cm4-table > tbody > tr > :is(th, td) {
    min-width: 0; max-width: none; padding: 0; border: 0;
    text-align: left; white-space: normal; overflow-wrap: anywhere;
  }
  .admin-audit-table.cm4-table > tbody > tr > .admin-cell-time {
    flex: 0 1 auto; font-weight: 400; color: var(--ink-2);
  }
  .admin-audit-table.cm4-table > tbody > tr > .admin-cell-action {
    flex: 1 1 auto; font-weight: 600; color: var(--ink);
  }
  .admin-audit-table.cm4-table > tbody > tr.admin-audit-row > td:not(.admin-cell-action) {
    flex: 1 1 100%; display: grid; grid-template-columns: var(--audit-card-label) minmax(0, 1fr);
    column-gap: var(--sp-3); align-items: baseline;
  }
  .admin-audit-table.cm4-table > tbody > tr.admin-audit-row > td:not(.admin-cell-action)::before {
    content: attr(data-label); color: var(--muted);
  }
  /* 展开的详情接在同一张卡片里，一道分隔线隔开。[hidden] 在全站是
     display: none !important，收着的时候这里的 display 不起作用。 */
  .admin-audit-table.cm4-table > tbody > tr.admin-audit-detail-row {
    display: block; margin-top: var(--sp-2); padding-top: var(--sp-2); border-top: 1px solid var(--border);
  }
  .admin-audit-table.cm4-table > tbody > tr.admin-audit-detail-row > td { display: block; }
}
/* 触屏平板与手机：详情那一格的摘要就是展开按钮，补到触控高度。 */
@media (max-width: 1023px) and (pointer: coarse) {
  .admin-audit-table.cm4-table .admin-cell-detail > .btn.link.row-link { min-height: var(--control-h-touch); align-items: center; }
  .admin-audit-table.cm4-table > tbody > tr.admin-audit-row > td.admin-cell-detail { align-items: center; }
}

/* T36 ---------------------------------------------------------------------
   用户抽屉的授权行：“可控制”后面多了一句“（可保存设置与下发，勾选即生效）”，
   勾选框这一格宽到 270px 左右。768 触屏下项目名会先和它挤在第一行，“撤销”
   被挤到第三行、和勾选框脱开（T36 布局 F1）。改成固定两行：项目名独占第一
   行；勾选框与“撤销”在第二行。flex-wrap 是按内容宽度决定换不换行的，光靠
   收缩留不住“撤销”，所以给勾选框这一格一个上限：整行减去“撤销”的触控宽度
   与列间距，放不下时 label 里的字自己折行（320 宽也是两行）。它不放大
   （flex-grow 0），整行点击区（T03 的 label::before）不会铺满到“撤销”旁边
   的空白。只作用于用户抽屉（.admin-user-drawer，console-batch01-p2 T13 起是全站
   的 .drawer）；项目页成员行同名的类不受影响。 */
.admin-user-drawer .admin-section-grants .admin-grant-name { flex-basis: 100%; }
.admin-user-drawer .admin-section-grants .admin-grant-control {
  /* “撤销”实宽 46（两个字 + 左右 --sp-2 内距，比 44 的下限宽一点），再加列间距 --sp-3。 */
  flex: 0 1 auto; min-width: 0; max-width: calc(100% - var(--control-h-touch) - var(--sp-3) - var(--sp-2));
}

/* T23 ------------------------------------------------------------- 功耗页
   pages/energy.js。只用已有令牌，没有新颜色。 */
/* EN-ux-09：键盘聚焦的柱子原来只有 --border × .45 的底色（对比度 1.1:1）。
   命中区再描一圈 --accent（与全站焦点环同色，两套主题对卡片底都 ≥ 4:1）；
   悬停、点按的 .active 不描边，描边只说"键盘焦点在这里"。 */
.energy-bar:focus-visible .energy-hit { stroke: var(--accent); stroke-width: 2; }
/* EN-ux-11：取数期间旧内容留着、变淡，完成后原地替换。变淡晚 0.2 秒才开始：
   本地或 4G 下几十毫秒就回来的刷新不会闪一下；恢复不等。 */
.energy-body { transition: opacity .15s ease; }
.energy-body[aria-busy="true"] { opacity: .55; transition-delay: .2s; }
/* "刷新"加载中是 aria-disabled（不用 disabled，焦点才不会掉回 body）：
   样子照 .btn:disabled 的 .6，同样晚 0.2 秒。console-batch01-p2 T10：刷新
   控件（.refresh-ctl）从页头移到了时间范围条的行尾；"更新于"的不折行由
   .refresh-ctl-at 负责。 */
.energy-page .refresh-ctl > .btn { transition: opacity .15s ease; }
.energy-page .refresh-ctl > .btn[aria-disabled="true"] { opacity: .6; cursor: progress; transition-delay: .2s; }
/* 电池卡片的趋势行："24 小时前 … → 现在 …"与推算各是一块，窄屏放不下时
   在两块之间折行；某一块自己比卡片还宽时才在块内折行。标签"电压趋势："
   保持 .meta 的 --muted，数字用 .battery-next 的 --ink-2，读起来分得开。 */
.battery-trend-part { display: inline-block; max-width: 100%; color: var(--ink-2); }

/* T29 ------------------------------------------ 存储页：键值对与表单标签对齐
   storage-ui-07。只作用于存储页（pages/storage.js 的 .storage-page），只用
   已有令牌，没有新颜色、新字号。
   ① 标签一种样子：键值对的标签（.st-label、测试结果 dl 的 dt）、字段标签、
      勾选/单选的文字、分组 legend 统一 --fs-sm / --ink-2——与全站 .field
      label、字号令牌注释里"form labels"一致，字号同设备状态页 .meta-list。
   ② 键值对共用一条对齐线：同一张卡片里的行都是"标签列 + 值列"两列网格，
      标签列固定 7 个 --fs-sm 字宽（与所在元素字号无关，几组网格才能对齐）；
      一行有几段值的（核实结果、最近上报 + 徽章）由 JS 包进一个 .st-value。
      值统一 --fs-md（描述列表的字号）。
   ③ 上传策略、SSD 容量两张表单卡（.storage-form）：每个 .form-row 是
      "标签 | 控件 | 说明"三列网格，.field 拆开（display: contents）让标签
      落第一列、控件落第二列，所以各行输入框左缘同一个 x；勾选框放在控件
      列，与输入框左缘对齐；字段错误缩进到控件列下面。 */
.storage-page {
  --kv-col: calc(var(--fs-sm) * 7);
}
.storage-page .st-label,
.storage-page .cm4-capture-rows dt,
.storage-page label,
.storage-page legend { font-size: var(--fs-sm); color: var(--ink-2); }

.storage-page .cm4-capture-card .st-row:has(> .st-label),
.storage-page .cm4-status .st-row {
  display: grid; grid-template-columns: var(--kv-col) minmax(0, 1fr);
  align-items: baseline; gap: var(--sp-1) var(--sp-3); font-size: var(--fs-md);
}
.storage-page .cm4-capture-card .st-row:has(> .st-label) > .st-label,
.storage-page .cm4-status .st-label,
.storage-page .path-root .st-label { min-width: 0; }
.storage-page .cm4-capture-card .st-row:has(> .st-label) > :not(.st-label),
.storage-page .cm4-status .st-row > :not(.st-label),
.storage-page .path-root > code { grid-column: 2; min-width: 0; }
.storage-page .path-root code { font-size: var(--fs-sm); }
.storage-page .cm4-capture-rows { grid-template-columns: var(--kv-col) minmax(0, 1fr); }
.storage-page .st-value {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); min-width: 0;
}

/* ③ 的表单网格已删（console-batch01-p2 T18，CONS-B-15）：三张存储表单改用全站的
   .form-stacked.is-columned（标签列 --form-col，≤720 标签在上），见文件末尾 T18 段。 */

/* T30 ---------------------------------------------------------------------
   照片页：单日 / 日期范围切换、快捷范围、单日框两侧的前一天 / 后一天，以及
   网格里按拍摄日插入的日期小标题。分段按钮沿用全站 .seg，按钮沿用 .btn
   small，颜色只用现有令牌，不新增颜色。
   - 单日框与两侧按钮是一组（.photo-day-nav），不在中间折行：窄屏上整组占满
     一行、日期框吃掉剩余宽度，"日期"标签在它上面一行，不会出现按钮一上一下
     的错位。
   - 日期小标题占满网格整行（grid-column: 1 / -1），左缘与瓦片对齐；字号、
     颜色与筛选卡里的 .sub-head 同一级。 */
.photo-date-row .photo-range { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); }
.photo-day-field { flex-wrap: wrap; }
.photo-day-nav { display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.photo-day-nav .btn { flex: none; white-space: nowrap; }
.photo-day-nav input { min-width: 0; }
.photo-grid > .photo-day { grid-column: 1 / -1; min-width: 0; }
.photo-day-head {
  margin: var(--sp-2) 0 0; font-size: var(--fs-md); font-weight: 600; color: var(--ink-2);
  font-variant-numeric: tabular-nums;
}
.photo-grid > .photo-day:first-child .photo-day-head { margin-top: 0; }
/* 今天还没有照片：那一句（.empty）下面的三个按钮，和那一句一样居中，紧挨着它。 */
.photo-today-actions {
  display: flex; flex-wrap: wrap; justify-content: center; gap: var(--sp-2);
  margin-top: calc(-1 * var(--sp-3)); padding-bottom: var(--sp-5);
}
@media (max-width: 720px) {
  .photo-date-row .photo-range, .photo-day-field { flex: 1 1 100%; min-width: 0; }
  .photo-day-nav { flex: 1 1 100%; }
  .photo-day-nav input { flex: 1 1 auto; width: auto; }
}

/* T42 ================ 相机管理：详情的事实行、确认框的在线提醒、CM4 配置行
   四节（基本信息、注册包、HMAC、CM4 令牌）的事实行都是 dl.meta-list（照设备
   状态页）。.meta-list 的标签列是 auto，每一节按自己最长的标签定宽，值的起点
   就各不相同（AC-ui-07）；这里给相机详情统一一个标签列宽（放得下“最近上报
   STM32”），四节的值从同一条竖线开始，窄屏上收窄一些把宽度让给值。详情从五
   张 .admin-camera-card 改成抽屉（.admin-camera-drawer）里的五节之后，选择器跟着
   换（console-batch01-p2 T15）；状态徽标是全站的 .status（本身就是 --fs-sm），
   原来给 .s.admin-status 定字号的那条已删。注册包的当前值原来单独放大到
   --fs-md，现在跟 dd 同字号；它和服务器地址两个 <code> 只写 monospace 时浏览器
   会把它缩到 13/16，这里给一串字体。没有新颜色：在线提醒用 --critical-text
   （三处主题块都已定义），配置行的底色与边框照一次性密钥框。 */
.admin-camera-drawer .meta-list.admin-facts { grid-template-columns: 8.5em minmax(0, 1fr); }
.admin-camera-drawer .admin-cred-facts code {
  font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: inherit; overflow-wrap: anywhere;
}
.admin-config-lines {
  margin: 0 0 var(--sp-2); padding: var(--sp-2) var(--sp-3); min-width: 0; max-width: 100%;
  background: var(--plane); border: 1px solid var(--border); border-radius: var(--radius-sm);
  font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: var(--fs-sm); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere;
}
/* 确认框里“X 前还在上报，确认后会立即断开”：单独一行、加粗、红字，排在后果
   那句话前面（ADMCAM-ux-06）。 */
.admin-confirm-warn .admin-online-warn {
  display: block; margin-bottom: var(--sp-1); color: var(--critical-text); font-weight: 600;
}
@media (max-width: 480px) {
  .admin-camera-drawer .meta-list.admin-facts { grid-template-columns: 7em minmax(0, 1fr); }
}

/* T37 ----------------------- 用户抽屉：姓名字段错误、下一步、登录信息
   只作用于用户抽屉与一次性密码弹窗里的登录信息块。没有新颜色，全部取现有令牌，
   不涉及三处主题块。console-batch01-p2 T13：抽屉改成全站的 .drawer 之后，区块
   标题回到全站的 h3.sub-head（14/600/--ink-2，CONS-C-05 / FD17，原来这里把它
   提到 15px / --ink 的覆盖已删）；标题栏不折行、吸顶、scroll-padding 与
   .sub-head 的焦点样子挪到文件末尾 T13 段，对所有 createDrawer 抽屉生效。 */

/* AU-ui-09：姓名框包一层 .admin-name-control（grid），字段错误落在框的正下方，
   间距与新建用户弹窗的 .admin-form-field 相同（--sp-1）。整行顶端对齐：出错时
   “姓名”与“保存姓名”不跟着往下挪；“姓名”两个字用控件高度撑起来，仍与框的
   中线对齐。 */
.admin-field-row.admin-name-row { align-items: flex-start; }
.admin-name-row > label { display: inline-flex; align-items: center; min-height: var(--control-h); }
.admin-name-control { flex: 1 1 140px; min-width: 0; display: grid; gap: var(--sp-1); }
.admin-name-control > .admin-name-input { width: 100%; min-width: 0; }
@media (max-width: 720px), (pointer: coarse) {
  .admin-name-row > label { min-height: var(--control-h-touch); }
}

/* AUD-ux-04：基本信息里的“查看审计记录”。 */
.admin-audit-entry { margin: 0 0 var(--sp-2); }

/* AU-ux-04 ③：新建后抽屉最上面的“下一步”。浅底 + 左侧 accent 竖条，与下面的
   区块分得开；做完的一步打 ✓ 并换成 --good-text。 */
.drawer-section.admin-section-next {
  padding: var(--sp-3); background: var(--plane);
  border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--radius-sm);
}
.admin-next-steps {
  margin: 0 0 var(--sp-1); padding-left: 1.4em; display: grid; gap: var(--sp-2);
  font-size: var(--fs-md); line-height: 1.5;
}
.admin-next-step { overflow-wrap: anywhere; }
.admin-next-text { display: block; }
.admin-next-step.is-done .admin-next-text { color: var(--good-text); }
.admin-next-step.is-done .admin-next-text::before { content: "✓ "; }

/* 一次性密码弹窗里的登录信息：五行字按行显示。 */
.admin-secret-login { white-space: pre-wrap; }

/* ------------------------------------------------ T28 相机参数页
   （pages/camera.js；CAM-ui-04 收尾、CAM-ui-05、CAM-ux-02、CAM-ux-11。
   CAM-ui-05 的色条令牌 --warning-bar / --serious-bar 在三处主题块里。） */

/* CAM-ui-04（T27 交来）的「≠」同色规则已随状态列改为 .status 删除：未下发行的
   状态徽标是 serious 级（lib/camera_config.js ROW_STATUS_LEVEL），与它的 --serious
   色条同色（console-batch01-p2 T17）。 */

/* CAM-ux-11 ①：值前面的「相机当前值：」「状态：」只给读屏（camera.js 的
   SR_PREFIX）。绝对定位、1px 裁掉：不占三列网格的格子，也不撑宽行。 */
.camera-sr-prefix {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* CAM-ux-02：下拉的候选值从哪来、下一步怎么做，写在目标值列里、字段错误
   之后。字色就是 .meta 的 --muted（CONS-A-04 起不再改色）。 */
.camera-choice-hint { margin-top: 2px; line-height: 1.5; overflow-wrap: anywhere; }

/* CAM-ux-11 ②：「其它相机设置（未映射）」折叠行在窄屏 / 触屏下够一个拇指
   （44px）。保留 <summary> 的 list-item 与原生三角，不改成 flex；用上下
   内边距把单行文字放在中间。 */
@media (max-width: 720px), (pointer: coarse) {
  .camera-unmapped summary {
    min-height: var(--control-h-touch);
    padding-block: max(0px, calc((var(--control-h-touch) - 1lh) / 2));
  }
}

/* T26 ------------------------ 拍摄计划页：错误就近、无效时段标记、窄屏时段行
   （pages/schedule.js；SCH-ux-03、SCH-ui-08、SCH-ux-06、R1-SCH-ux-03）。
   出错字段的红框就是全站 input / select[aria-invalid="true"] 那两条（管理页字段
   错误同一套），这里不另写。颜色只用现有令牌：无效时段 --critical 画线、
   --critical-text 写字；没有新的颜色、阴影、字号。 */

/* 间隔卡里的那一份警告（底部汇总照旧）：在卡片里面，不再留 --gap 的外边距。 */
.schedule-interval-warnings .warn-list { margin: var(--sp-2) 0 0; }
.schedule-interval-warnings .warn-list li:last-child { margin-bottom: 0; }

/* 开始晚于结束的那一天：时间条红框 + 左侧 3px 标记 + 条里一句"✕ 开始晚于结束"
   ——不只靠颜色；这种时段画不出色块，不标的话看上去就像"这天不拍"。 */
.tb-bar.is-invalid { border-color: var(--critical); box-shadow: inset 3px 0 0 var(--critical); }
.tb-row.is-invalid .tb-day { color: var(--critical-text); }
.tb-invalid-mark {
  position: absolute; left: var(--sp-2); top: 50%; transform: translateY(-50%);
  padding: 0 var(--sp-1); border-radius: 2px; background: var(--plane);
  font-size: var(--fs-xs); line-height: 1.4; color: var(--critical-text); white-space: nowrap;
}

/* 保存栏（宽屏）：原来保存按钮与下发块挨着（先保存、再下发；现已合并为「保存并下发」），下发块
   下面还有一行理由，整行居中对齐时两个按钮上下差 12px。改成顶端对齐，
   "● 有未保存的更改"给一个控件高，字仍和按钮居中。≤480px 的一行排法见 T20 段。 */
.schedule-page .save-bar:not(.is-compact) .save-row { align-items: flex-start; }
.schedule-page .save-bar:not(.is-compact) .dirty { min-height: var(--control-h); }
@media (max-width: 720px), (pointer: coarse) {
  .schedule-page .save-bar:not(.is-compact) .dirty { min-height: var(--control-h-touch); }
}

@media (max-width: 480px) {
  /* 时段行一行排齐（原来 113px，「结束」折到星期下面）：星期 | 勾选 | 开始 | 结束，
     两个下拉各分一半余宽；左内边距收到 --sp-1、右边不留，"20:00" 在 375 宽度下
     不被箭头盖住。行内上下边距收到 --sp-1，44px 的触控下拉加上它一行 53px。
     提示与字段错误另起一行占满。 */
  .win-row {
    display: grid; grid-template-columns: 2.2em auto minmax(0, 1fr) minmax(0, 1fr);
    gap: var(--sp-1) var(--sp-2); padding: var(--sp-1) 0;
  }
  .win-day { width: auto; }
  .win-row > .field { min-width: 0; gap: var(--sp-1); }
  .win-row > .field label { flex: none; white-space: nowrap; }
  .win-row > .field select {
    flex: 1 1 auto; width: 100%; min-width: 0; padding-left: var(--sp-1); padding-right: 0;
  }
  .win-row > .win-hint, .win-row > .field-error { grid-column: 1 / -1; }
  .win-row > .win-hint:empty { display: none; }
  /* 「关闭定时拍照」独占一行，「分钟」「秒」并排在下一行（原来「秒」折到「分钟」下面）。 */
  .schedule-interval-row > .check { flex-basis: 100%; }
}
/* 再窄（360 及以下）两个下拉放不下"开始 / 结束"四个字：字只留给读屏（label 仍
   关联），两个下拉之间用一个"–"。 */
@media (max-width: 370px) {
  .win-row > .field label {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    border: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
  }
  .win-row > .field + .field::before { content: "–"; flex: none; color: var(--ink-2); }
}

/* T31 ---------------------------------------------------------------------
   照片页的拍摄时间（pages/photos.js）：EXIF 时间明显不对（exif_suspect）的
   瓦片时间行、筛选卡底部的覆盖率 +「读取拍摄时间」一行、已加载照片里
   "时间异常"的张数。颜色只用已有令牌（--warning-text / --critical-text /
   --ink-2，三处主题块里都有），不新增颜色。"⚠"由 CSS 加，替代文字为空，
   读屏只念文字本身（文字里已有「相机时钟可能不准」）。 */
.tile-time { overflow-wrap: anywhere; }
.tile-time.is-suspect { color: var(--warning-text); }
.tile-time.is-suspect::before,
.photo-suspect-note::before {
  content: "⚠ ";
  content: "⚠ " / "";
  font-variant-emoji: text;
}

/* 说明占剩下的宽度，按钮和进度跟在后面；放不下时按钮与进度整行换到下面，
   不在字中间挤。 */
.photo-capture-row { gap: var(--sp-1) var(--sp-3); margin: var(--sp-2) 0 0; }
.photo-capture-text {
  flex: 1 1 18em; min-width: 0;
  font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere;
}
.photo-capture-status { overflow-wrap: anywhere; min-width: 0; }
.photo-capture-status:empty { display: none; }
.photo-capture-status.is-error { color: var(--critical-text); }
.photo-suspect-note { margin: var(--sp-1) 0 0; color: var(--warning-text); overflow-wrap: anywhere; }
/* 创建视频弹窗里的同一组按钮与进度：接在"还没有读取到 EXIF"那句下面。 */
.video-form .photo-capture-row { margin: 0 0 var(--sp-2); }

/* T32 ---------------------------------------------------------------------
   照片页（pages/photos.js）：选中的瓦片、键盘所在的瓦片、筛选卡的日期错误、
   大图预览、选择栏对齐。颜色只用已有令牌（--accent、--critical-text），不新增
   颜色，不动三处主题块（深色遮罩 .6 与浅色描边阴影 T03 已给）。 */

/* PH-ux-10：选中的瓦片一眼认得出，与管理页选中行同一种 accent 表达——边框换成
   --accent，里面再描一道 2px。内描边画在 ::after 上、压在缩略图之上：直接给
   瓦片 inset box-shadow 会被缩略图盖掉上面四分之三。::after 不接指针，点击照旧
   落到缩略图与勾选行。.is-selected 由 JS 维护，:has() 是同一件事的兜底。 */
.photo-tile { position: relative; }
.photo-tile.is-selected, .photo-tile:has(.check input:checked) { border-color: var(--accent); }
.photo-tile.is-selected::after, .photo-tile:has(.check input:checked)::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  border-radius: calc(var(--radius) - 1px); box-shadow: inset 0 0 0 2px var(--accent);
}
/* PH-ux-09：每张瓦片只有一个 Tab 停靠点（勾选框），方向键在瓦片之间走。焦点
   在勾选框上时整张瓦片描一圈焦点环（和全站 :focus-visible 同一种 2px
   --accent、外移 2px），看得出现在停在哪一张；网格间距 12px 放得下。 */
.photo-tile:has(.check input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }

/* PH-ui-07：照片页的错误行本身是 <p class="field-error">，.field-error p 的
   "✕"和边距都匹配不上它。补上"✕"；筛选卡里的那一行不用浏览器默认的 13px
   上下边距：上面借日期行已有的 8px，下面的 8px 做成它自己的内边距、后面的
   说明不再加上边距——错误出现时筛选卡只多出这一行本身的高度，上面的控件
   一动不动。 */
.photos-page p.field-error::before { content: "✕ "; }
.photo-filters > p.field-error { margin: 0; padding-bottom: var(--sp-2); }
.photo-filters > p.field-error:not([hidden]) + .hint { margin-top: 0; }

/* PH-ui-11：大图四角圆起来，和外框同一个 --radius-sm（外框原来有圆角，图片
   却是直角地铺满它）。 */
.photos-page .preview-image { overflow: hidden; }
.photos-page .preview-image img { border-radius: var(--radius-sm); }

/* 大图要看得清（用户 10-12：「照片查看大图的时候不够大」）。原来面板最宽
   1000px、照片只占左边 2/3 列：1390 宽的窗口里，1600px 的预览图只显示到
   636×425。.photo-viewer 由照片页加在这一个弹窗的面板上，其它弹窗不动：
   1. 面板放宽到 1900px（1600 的图 + 信息栏 + 边距），不到就铺满窗口宽度；
   2. 默认照片在上、信息在下，照片用满面板宽度（最高仍是 70vh）；
   3. 横向的桌面窗口（≥900px 且宽高比 ≥ 3:2）里面板占满窗口高度，照片占满
      标题和右侧信息栏以外的全部空间——这种窗口里照片受高度限制，信息栏放右边
      不占照片的地方；信息栏自己滚动。
   照片只缩不放：不超过预览图本身的像素，放大只会变糊。 */
.modal.photo-viewer { width: min(1900px, 100%); }
.photo-viewer .preview { grid-template-columns: minmax(0, 1fr); }
@media (min-width: 900px) and (min-aspect-ratio: 3/2) {
  .modal.photo-viewer {
    height: calc(100vh - 2 * var(--sp-4));
    display: flex; flex-direction: column; overflow: hidden;
  }
  .photo-viewer > .modal-head { flex: none; }
  .photo-viewer > .modal-body { flex: 1 1 auto; min-height: 0; }
  .photo-viewer .preview {
    height: 100%; grid-template-columns: minmax(0, 1fr) 280px; grid-template-rows: minmax(0, 1fr);
  }
  .photo-viewer .preview-image { min-height: 0; }
  .photo-viewer .preview-image img { max-height: 100%; min-width: 0; min-height: 0; }
  .photo-viewer .preview-meta { min-height: 0; overflow-y: auto; }
}

/* 点击放大（用户 10-12：「加上点击放大，可以看清照片细节」）。点照片或「放大」
   按钮，照片区换成服务端的高清图（tier=zoom，最长边 4096px），照片区变成可滚动
   的窗口：滚轮、触屏拖动、鼠标按住拖动、聚焦后方向键都能移动；再点一下还原。
   逻辑在 pages/photos.js 的 previewZoom，这里是它用到的类：
   - img.can-zoom：这张照片可以放大，指针是放大镜；.is-zoom-loading：高清图还在路上。
   - .is-zoomed：放大中。照片区从 flex 居中改成 block + overflow:auto——居中时
     溢出到左边和上边的部分滚不到。上下排的布局里照片区原来跟着照片的高度走，
     放大后给它一个确定的高度（70vh），不然会被几千像素的图撑开；横向桌面布局
     里高度本来就由面板决定，改回 auto。.is-dragging：鼠标正按着拖。
   - img.preview-zoomed：高清图。尺寸由脚本按 zoomedSize 写在 style 上，这里只
     解除预览图的 max-width / max-height；比照片区窄时水平居中。
   - .preview-side：右边（上下排时在下面）一列，「放大」按钮和状态一行在上，
     照片信息在下；横向桌面布局里仍是照片信息自己滚动。
   两张图用 hidden 轮换；.preview-image img 写了 display:block，hidden 要补一条。 */
.photo-viewer .preview-image img[hidden] { display: none; }
.photo-viewer .preview-image img.can-zoom { cursor: zoom-in; }
.photo-viewer .preview-image.is-zoom-loading img { cursor: progress; }
.photo-viewer .preview-image.is-zoomed {
  display: block; height: 70vh; overflow: auto; overscroll-behavior: contain;
  cursor: zoom-out; user-select: none;
}
.photo-viewer .preview-image.is-zoomed.is-dragging { cursor: grabbing; }
.photo-viewer .preview-image.is-zoomed:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.photo-viewer .preview-image img.preview-zoomed { max-width: none; max-height: none; margin: 0 auto; border-radius: 0; }
.photo-viewer .preview-side { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
.photo-viewer .preview-zoom { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2); }
/* 高清图在路上时按钮是 aria-disabled（不用 disabled，焦点才不会掉回 body），
   样子照 .btn:disabled 的 .6。 */
.photo-viewer .preview-zoom > .btn[aria-disabled="true"] { opacity: .6; cursor: progress; }
.photo-viewer .preview-zoom-status:empty { display: none; }
@media (min-width: 900px) and (min-aspect-ratio: 3/2) {
  .photo-viewer .preview-image.is-zoomed { height: auto; }
  .photo-viewer .preview-side { min-height: 0; }
  .photo-viewer .preview-meta { flex: 1 1 auto; }
}

/* 大图查看模块（lib/photo_viewer.js；用户 10-13：「加上上一张下一张」「关闭和
   放大按键不要使用文字，要使用图标展示」「照片查看功能应该做成单独模块」）。
   哪个页面调这个模块，弹窗面板都带 .photo-viewer，下面的规则就都有：
   - .icon-btn：只显示图标的按钮（关闭、上一张、下一张、放大 / 还原）。方的，和
     别的按钮一样高（鼠标 36px，触屏 44px）。名字写在 .icon-text 里——收起来不
     显示，读屏照念。上一张 / 下一张停用用 aria-disabled（焦点不会掉出弹窗），
     样子同 :disabled；这条只管 .preview-nav 里的两个，放大按钮等高清图时的样子
     是上面 .preview-zoom 那条（指针是 progress），不要盖住它。
   - .preview-tools：信息栏最上面那一行，.preview-nav（上一张、下一张、第几张）
     和放大按钮排在一起，放不下就换行；只有一张照片时 .preview-nav 整个不显示。
   - .preview-stage / .preview-zoom-slot / .preview-meta-slot：模块换下一张照片时
     只换这三处里面的东西。它们自己不占盒子（display: contents），所以照片区仍然
     是 .preview 的格子、信息仍然是 .preview-side 的一项，上面两段排布规则原样生效。
   - .preview-status：照片区里「正在加载…」「没取到 + 重试」那几行，居中，留白在
     这几行自己身上（照片区本身不留白，不然照片会小一圈）；字用 --ink-2。
   照片区的圆角和裁切原来按页面各写一份（照片页一份、存储页测试照片一份），这里
   给模块写一份通用的。 */
.photo-viewer .icon-text {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.photo-viewer .btn.icon-btn { position: relative; min-width: var(--control-h); padding: 0; }
.photo-viewer .preview-nav > .btn[aria-disabled="true"] { opacity: .6; cursor: default; }
.photo-viewer .preview-stage,
.photo-viewer .preview-zoom-slot,
.photo-viewer .preview-meta-slot { display: contents; }
.photo-viewer .preview-image { overflow: hidden; }
.photo-viewer .preview-image img { border-radius: var(--radius-sm); }
.photo-viewer .preview-tools { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); }
.photo-viewer .preview-nav { display: flex; align-items: center; gap: var(--sp-2); }
.photo-viewer .preview-nav[hidden] { display: none; }
.photo-viewer .preview-count { position: relative; min-width: 9ch; text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; }
.photo-viewer .preview-nav-status { margin: 0; }
.photo-viewer .preview-nav-status:empty { display: none; }
.photo-viewer .preview-status { display: grid; gap: var(--sp-2); justify-items: center; padding: var(--sp-3); text-align: center; }
.photo-viewer .preview-status p { margin: 0; }
.photo-viewer .preview-status .thumb-text { color: var(--ink-2); padding: 0; }
@media (max-width: 720px), (pointer: coarse) {
  .photo-viewer .btn.icon-btn { min-width: var(--control-h-touch); }
}

/* PH-ui-12：选择栏换行后主按钮仍在最右；同一行按钮一个字号（全选已加载 /
   清空选择 / 更多原来是 13px 的 .small，主按钮 15px）。1023px 以下提示单独
   放在最后一行，主按钮留在第一行右边；提示空着时不占一行（否则只剩一道行距）。
   ≤480px 的紧凑一行（T20）照旧：那里的 .sel-hint 规则选择器更具体。 */
.sel-bar .btn.small { font-size: var(--fs-base); padding-left: var(--sp-3); padding-right: var(--sp-3); }
.sel-bar > .btn.primary { margin-left: auto; }
.sel-bar > .sel-hint:empty { display: none; }
@media (max-width: 1023px) {
  .sel-bar > .sel-hint { order: 10; flex-basis: 100%; min-width: 0; }
}

/* storage-delivery-v1 T08 */
/* 存储页"CM4 SSD 状态"卡片里的 SSD 可用空间限制区（pages/storage.js 填进
   storage_cm4.js 的 limitSlot），以及"保存并下发"用到的设备值、确认框清单与
   红字。只用已有令牌，没有新颜色、新字号。
   - 挂载点空着时不占高度（加载中、出错、没有限制区的页面都看不出它）。
   - 限制区与上方容量区用一道 --border 分开；标题沿用 .sub-head，与卡片内
     "各月份数据"同级、同样的上边距。从未上报时上方是 .cm4-empty（底部只有
     4px），分隔线前再补 12px。
   - 设备值一行是 .meta 的字号与颜色；与表单不同的一行照参数页
     .camera-param-row.is-differs：左侧 --warning-bar 内阴影、正文色加粗、
     "≠ "前缀用 --warning-text——文字里另有"（与设备不同）"，不只靠颜色。
   - 确认框红字只用 --critical-text。
   - ≤480px：限制区的勾选行与数字框不低于 --control-h-touch（全站 720px 的
     触控规则之外再钉一次，这一块的触控高度不依赖那条规则的选择器）。 */
.cm4-limit-slot:empty { display: none; }
.cm4-limit {
  margin: 0 0 var(--sp-4); padding-top: var(--sp-3);
  border-top: 1px solid var(--border);
}
.cm4-limit .sub-head { margin-top: var(--sp-1); }
.cm4-status-part:has(> .cm4-empty) + .cm4-limit-slot > .cm4-limit { margin-top: var(--sp-3); }
.storage-device-value {
  margin: var(--sp-1) 0 0; min-width: 0; overflow-wrap: anywhere;
  color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums;
}
.storage-device-value.is-diff {
  padding-left: var(--sp-2); box-shadow: inset 3px 0 0 var(--warning-bar);
  color: var(--ink); font-weight: 600;
}
.storage-device-value.is-diff::before { content: "≠ "; color: var(--warning-text); }
.storage-limit-help { margin: var(--sp-2) 0 0; overflow-wrap: anywhere; }
.storage-confirm-list { margin: var(--sp-1) 0 var(--sp-2); padding-left: var(--sp-5); overflow-wrap: anywhere; }
.storage-confirm-list li { margin: 2px 0; }
.storage-confirm-danger { margin: var(--sp-2) 0 0; color: var(--critical-text); font-weight: 600; overflow-wrap: anywhere; }
@media (max-width: 480px) {
  .cm4-limit .check,
  .cm4-limit input[type="number"] { min-height: var(--control-h-touch); }
}

/* storage-delivery-v1 T09 */
/* 存储页「保存并下发」（pages/storage.js）：只补 T08 段没有覆盖的排版，不新增
   颜色、字号。
   - 表单卡（上传策略、限制区）里字段下方的"设备当前：…"与字段错误一样缩进到
     控件列，窄屏上同样取消缩进。
   - ≤480px：.storage-form 行改成"标签 | 控件"两列，控件列可收缩，
     起始小时下拉（最长项"24:00（受限模式下不上传）"）占满控件列而不撑出卡片。
   - 状态条的下发行与设备值行：记号与文字不拆成两行，文字自己折行（设备值
     一句在 390px 下很长）。
   - 确认框：分组标题与清单的间距；确认框与下面的保存条留出一个 --gap。
   - 逐项结果：记号 | 字段 | 结果三列（camera 页是四列）。 */
.storage-page .st-delivery,
.storage-page .st-device { flex-wrap: nowrap; }
.storage-page .st-delivery > :last-child,
.storage-page .st-device > :last-child { min-width: 0; }
.storage-confirm-slot:empty { display: none; }
.storage-confirm-slot .cm4-confirm { margin: 0 0 var(--gap); }
.storage-confirm-title { margin: var(--sp-2) 0 0; font-weight: 600; }
.storage-confirm-note { margin: var(--sp-2) 0 0; overflow-wrap: anywhere; }
.storage-confirm-box .form-row { margin-top: var(--sp-3); }
.storage-result-list .camera-result-row { grid-template-columns: auto minmax(0, 1fr) minmax(0, 2fr); }
.storage-limit-note, .storage-limit-usage { margin: var(--sp-1) 0 0; overflow-wrap: anywhere; }
@media (max-width: 720px) {
  .storage-result-list .camera-result-row { grid-template-columns: auto minmax(0, 1fr); }
  .storage-result-list .camera-result-text { grid-column: 2; }
}
@media (max-width: 480px) {
}

/* save-and-deliver-unify-v1 T01 */
/* 三页共用的「保存并下发」确认框（lib/deliver_common.js createDeliverConfirm）、保存条上
   按钮为什么是灰的那句（settings_common.js createSaveBar().setSaveState），以及历史抽屉里的
   分区小标题与「取消下发」（createHistory）。取值照抄存储页确认框的 storage-confirm-*、
   .sub-head 与相机页结果行，不新增颜色、字号。
   - 确认框外层沿用 .notice.cm4-confirm（左边框强调色、? 图标）；分组标题加粗，清单缩进与
     存储页一致；红字只用 --critical-text，前面另有 ⚠，不只靠颜色。槽空着时不占高度。
   - 灰按钮的说明站在"● 有未保存的更改"的位置（两者不会同时出现：有修改时按钮总能点），
     字用 --ink-2，按钮顶端对齐的行里也与按钮居中。≤480px 的紧凑一行放不下它：隐藏，
     整句在按钮的 title 里，读屏经 aria-describedby 照读。
   - 历史抽屉：两组时每组一个小标题（同 .sub-head），组与组之间空一个 --sp-4；「取消下发」
     是 .btn.small，不能取消时一行 .meta 说明；取消的结果沿用 .save-line 的 ✓ / ✕ 记号，
     字用正文色、成败只落在记号上（同 .camera-apply-line）。 */
.deliver-confirm-slot:empty { display: none; }
.deliver-confirm-slot .cm4-confirm { margin: 0 0 var(--gap); }
.deliver-confirm-title { margin: var(--sp-2) 0 0; font-weight: 600; }
.deliver-confirm-list { margin: var(--sp-1) 0 var(--sp-2); padding-left: var(--sp-5); overflow-wrap: anywhere; }
.deliver-confirm-list li { margin: 2px 0; }
.deliver-confirm-empty { margin: var(--sp-1) 0 var(--sp-2); overflow-wrap: anywhere; }
.deliver-confirm-note { margin: var(--sp-2) 0 0; overflow-wrap: anywhere; }
.deliver-confirm-danger { margin: var(--sp-2) 0 0; color: var(--critical-text); font-weight: 600; overflow-wrap: anywhere; }
.deliver-confirm-box .form-row { margin-top: var(--sp-3); }
.save-idle-hint { align-self: center; min-width: 0; overflow-wrap: anywhere; }
.save-bar.is-compact .save-idle-hint { display: none; }
.hist-section + .hist-section { margin-top: var(--sp-4); }
.hist-section-title { margin: 0; font-size: var(--fs-md); font-weight: 600; color: var(--ink-2); }
.hist-cancel { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); margin-top: var(--sp-2); }
.hist-cancel-note { margin: 0; overflow-wrap: anywhere; }
.save-line.hist-cancel-msg { margin: 0; color: var(--ink); }
.save-line.hist-cancel-msg.success::before { color: var(--good-text); }
.save-line.hist-cancel-msg.error::before { color: var(--critical-text); }
/* save-and-deliver-unify-v1 T06 */
/* 三页的窄屏保存条都是一行「更多」+「保存并下发」。有未保存的更改时按钮右上角那个
   圆点（.save-bar.is-compact.is-dirty .save-btn::after，top / right 各 -4px）伸出保存行，
   保存行因此横向溢出 3px（T01 在相机页、T08 在存储页实测）。保存行向外借 --sp-1、再用
   同样的内边距还回来：按钮位置和大小都不变，圆点落进保存行自己的盒子。 */
.save-bar.is-compact .save-row {
  margin: calc(var(--sp-1) * -1) calc(var(--sp-1) * -1) 0 0;
  padding: var(--sp-1) var(--sp-1) 0 0;
}
/* save-and-deliver-unify-r2 T06 */
/* 「保存并下发」的确认改为弹窗（deliver_common.js createDeliverConfirm）：面板、标题、按钮
   与「重启 STM32 / 照片计数清零」的 createConfirmAction 同一套（.modal、.admin-form-actions
   .admin-confirm-actions；「取消」在左、默认焦点，≤480px 时两个按钮各占一行、「取消」在上）。
   清单比那几个弹窗长，面板放宽到 560px；内容超出时照旧在 .modal 里滚动。分组、说明、⚠ 红字
   沿用上面 T01 段的 .deliver-confirm-*，不新增颜色、字号。 */
.modal.deliver-confirm-modal { width: min(560px, 100%); }
.deliver-confirm-intro { margin: 0; overflow-wrap: anywhere; }
.deliver-confirm-actions { margin-top: var(--sp-3); }
/* 全局提示条（.toasts，右下角 fixed）与吸底保存条：保存成功后约 4 秒的提示条曾盖住保存条右侧的
   「历史记录 / 保存并下发」，真实点击被吃掉（两轮测试都见到）。页面有保存条时把提示条抬到它上方：
   --save-bar-h 是 trackStickyBar 量到的条高（量到之前按 0，即原位置）；再加上条停在页面末尾时离
   视口底边的距离——条自己的 margin-bottom（--gap）加 .content 的底部内边距（宽屏 --sp-5，≤720px
   --sp-3）——这样无论条吸在底边还是停在末尾，提示条都与它隔开至少 --sp-4。只改位置，不改提示条的
   组件逻辑与展示时长；没有保存条的页面不受影响。 */
html:has(.save-bar) .toasts { bottom: calc(var(--sp-4) + var(--save-bar-h, 0px) + var(--gap) + var(--sp-5)); }
@media (max-width: 720px) {
  html:has(.save-bar) .toasts { bottom: calc(var(--sp-4) + var(--save-bar-h, 0px) + var(--gap) + var(--sp-3)); }
}
/* 修复轮次 2（T06 美观 F1）：确认弹窗的按钮行不吸底——吸底会把长清单末尾的整行盖住且没有提示。长清单
   打开时从标题开始（deliver_common.js 聚焦「取消」时 preventScroll、scrollTop 归零），按钮在清单
   末尾，滚过全部改动才点得到，与「重启 STM32」等弹窗一致。 */

/* console-batch01-p2 T01 */
/* 全站控件与文字的基础规格（CONS-C-02；CONS-A-01～A-04、CONS-B-10、CONS-B-15、
   CONS-C-04 的样式层）。页面任务只需换类名。只用令牌：没有新颜色、新字号。
   同一文件前面的精确替换：hover 排除禁用（:where，特异性不变）、.btn.primary 的
   hover、控件 15/400、审计筛选条与快捷前缀回到全站规格、.meta 不再被改色、
   拍摄计划时钟卡标签 13px、--form-col 与字号令牌注释。 */

/* CONS-C-02：照片目录按钮与分段按钮也有禁用态，与 .btn:disabled 同一种。 */
.folder-btn:disabled, .seg button:disabled { opacity: .6; cursor: default; }

/* CONS-B-10：.seg 只有一种规格——15px、左右 --sp-3、高 --control-h 减 2（触控
   另见 touch targets），不随所在容器（筛选条、工具条）缩小。 */
.seg button { font-size: var(--fs-base); }

/* CONS-A-02：字段标签（.field label）、复选 / 单选的文字、工具条里的标签文字
   同一档 13/400 --ink-2。工具条里的按钮、下拉自带 --ink，完成提示是 .meta。 */
.check > label { font-size: var(--fs-sm); font-weight: 400; color: var(--ink-2); }
.win-toolbar, .win-toolbar label { color: var(--ink-2); font-weight: 400; }

/* CONS-A-04：说明文字只有两档。.note 解释“这是什么”（14/400 --ink-2，行高 1.6）；
   .meta 是时间、计数、来源（13/400 --muted，任何规则都不给它改色）。.note 自己
   不带外边距：:where() 只清掉浏览器给 p 的默认边距，页面给它的间距照样生效。 */
.note { font-size: var(--fs-md); font-weight: 400; line-height: 1.6; color: var(--ink-2); }
:where(.note) { margin: 0; }

/* 相机参数页每行的参数名是行头，不是字段标签（:root 字号注释已登记）。 */
.param-name,
.field label.param-name,
.form-stacked .field > label.param-name { font-size: var(--fs-md); font-weight: 600; color: var(--ink); }

/* CONS-C-04：链接型按钮只有两种。
   ① .btn.link：文字动作与导航型动作，15px、下划线、--ink-2，hover --ink。
   ② .btn.link.row-link：表格行内打开本行详情，13/600 --ink、无下划线，hover 与
      键盘焦点时才画下划线。 */
.btn.link { font-size: var(--fs-base); }
.btn.link.row-link {
  min-height: 0; justify-content: flex-start; padding: 0;
  font-size: var(--fs-sm); font-weight: 600; color: var(--ink); text-align: left; text-decoration: none;
}
.btn.link.row-link:hover:where(:not(:disabled)), .btn.link.row-link:focus-visible { text-decoration: underline; }

/* CONS-B-15：两种表单。
   .form-inline：筛选条与短字段。一行、可换行；每个 .field 是一组（标签在左、
     自然宽），整组放不下时整组换到下一行，组内不拆开。
   .form-stacked：设置与弹窗。每个 .field 标签在上（13/400 --ink-2，下距
     --sp-1），控件保持原来的宽度。
   .form-stacked.is-columned：三个设置页的宽屏写法。>720px 每个 .field 是两列
     网格——标签占 --form-col（8.5em，15px 下 127.5px，允许折行、不截断），其余
     子元素（控件、单位、说明、错误）各占第二列一行，所以各行控件左缘同一个 x；
     几个控件要并排的，由页面包成一个元素再放进第二列。≤720px 与 .form-stacked
     相同（标签在上）。
   字段与字段之间的间距仍由所在容器给（.admin-form 的 gap、.form-row 的外边距），
   这里不加，免得与它们叠成双倍。 */
.form-inline {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); min-width: 0;
}
.form-inline > .field { flex-wrap: nowrap; min-width: 0; max-width: 100%; }
.form-inline > .field > label { flex: none; }
.form-inline > .field > :not(label) { min-width: 0; }
.form-inline label { font-size: var(--fs-sm); font-weight: 400; color: var(--ink-2); }

.form-stacked .field {
  display: flex; flex-direction: column; align-items: flex-start; gap: 0; min-width: 0; max-width: 100%;
}
.form-stacked .field > label {
  display: block; margin: 0 0 var(--sp-1); font-size: var(--fs-sm); font-weight: 400; color: var(--ink-2);
}
.form-stacked.is-columned .field {
  display: grid; grid-template-columns: var(--form-col) minmax(0, 1fr);
  align-items: center; justify-items: start; gap: var(--sp-1) var(--sp-3);
}
.form-stacked.is-columned .field > label {
  grid-column: 1; justify-self: stretch; margin: 0; min-width: 0; overflow-wrap: anywhere;
}
.form-stacked.is-columned .field > :not(label) { grid-column: 2; min-width: 0; max-width: 100%; }
@media (max-width: 720px) {
  .form-stacked.is-columned .field { display: flex; flex-direction: column; align-items: flex-start; gap: 0; }
  .form-stacked.is-columned .field > label { margin: 0 0 var(--sp-1); }
}

/* console-batch01-p2 T02 */
/* 全站共用组件（lib/ui.js）的样子：状态徽标 .status、分级提示条 .notice.<级别>、保存条 info /
   warning 结果行、页头操作区 .page-head-actions 与刷新控件 .refresh-ctl、时间范围条 .range-bar、
   分页 .pager 与加载更多 .more-row、长表 .table-scroll.is-long、空值 .empty-value，以及所有数据表
   的行悬停与单元格对齐（CONS-C-01 / C-06 / C-07 / C-08 / B-02 / B-10）。颜色全部取现有令牌，
   没有新色；--critical-text 浅色改值与 .notice-icon 的 15px 在上面各自的原规则里。 */

/* 状态徽标：8px 圆点 + 13px 文字，颜色只有五档——文字取 --*-text，圆点取饱和色，muted 两者都
   是 --muted。图标（✓ ✕ ⚠ ⏳ ■）只顶替圆点、不与圆点并存：15px、行高 1、与文字同色。 */
.status {
  display: inline-flex; align-items: center; gap: var(--sp-1);
  font-size: var(--fs-sm); min-width: 0; max-width: 100%;
}
.status-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.status-icon { flex: none; font-size: var(--fs-base); line-height: 1; font-style: normal; font-variant-emoji: text; }
.status-text { min-width: 0; overflow-wrap: anywhere; }
.status.good     { color: var(--good-text); }
.status.warning  { color: var(--warning-text); }
.status.serious  { color: var(--serious-text); }
.status.critical { color: var(--critical-text); }
.status.muted    { color: var(--muted); }
.status.good > .status-dot     { background: var(--good); }
.status.warning > .status-dot  { background: var(--warning); }
.status.serious > .status-dot  { background: var(--serious); }
.status.critical > .status-dot { background: var(--critical); }
.status.muted > .status-dot    { background: var(--muted); }

/* 历史抽屉的下发状态胶囊（原来三态同一个灰）：边框与文字取同一级别色。 */
.hist-tag.good     { color: var(--good-text);     border-color: var(--good-text); }
.hist-tag.warning  { color: var(--warning-text);  border-color: var(--warning-text); }
.hist-tag.serious  { color: var(--serious-text);  border-color: var(--serious-text); }
.hist-tag.critical { color: var(--critical-text); border-color: var(--critical-text); }
.hist-tag.muted    { color: var(--muted);         border-color: var(--muted); }

/* 提示条：左色条 = 图标颜色 = 级别（info 蓝、warning 黄、error 红、success 绿；色条取饱和色，
   图标取同名 --*-text）。没带级别类的 .notice 左条本来就是 --warning，图标跟着取
   --warning-text，不再继承正文的白 / 黑。这一条用 :where() 压到一个类的特异性，页面里已有的
   「.cm4-warn .notice-icon」「.admin-danger-warn .notice-icon」「.st-delivery .notice-icon」之类
   配色照旧生效；级别类只管提示条的直接子元素里的图标。 */
:where(.notice) > .notice-icon { color: var(--warning-text); }
.notice.info    { border-left-color: var(--accent); }
.notice.warning { border-left-color: var(--warning); }
.notice.error   { border-left-color: var(--critical); }
.notice.success { border-left-color: var(--good); }
.notice.info > .notice-icon    { color: var(--accent-text); }
.notice.warning > .notice-icon { color: var(--warning-text); }
.notice.error > .notice-icon   { color: var(--critical-text); }
.notice.success > .notice-icon { color: var(--good-text); }
.notice-body { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.notice-body > :first-child { margin-top: 0; }
.notice-body > :last-child { margin-bottom: 0; }

/* 保存条结果行 info / warning：与 success / error 同一写法，整行级别色 + 前缀「ⓘ 」/「⚠ 」。
   :where() 让 .save-line.camera-config-line 这类「正文 --ink、只给前缀上色」的行保留自己的正文
   色；前缀的颜色写在 ::before 上，两种行里前缀都是级别色。 */
.save-line:where(.info)    { color: var(--accent-text); }
.save-line:where(.warning) { color: var(--warning-text); }
.save-line:where(.info)::before    { content: "ⓘ "; color: var(--accent-text); }
.save-line:where(.warning)::before { content: "⚠ "; color: var(--warning-text); }

/* 页头右侧操作区（CONS-B-02）：每个页头都有一个（可空），有内容时高度等于一个控件。
   margin-left:auto 让它在窄屏整体换到下一行时仍贴右；区里
   的东西放不下时在区内换行、靠右。标题与操作区都居中对齐，空操作区由页头保证高度。 */
.page-head-actions {
  margin-left: auto; flex: none; display: flex; align-items: center; justify-content: flex-end;
  flex-wrap: wrap; gap: var(--sp-2); min-height: var(--control-h); align-self: center; max-width: 100%;
}
/* 空的操作区不单独占一行：标题长到占满一行时，零宽的它连同左边的列间距（.page-head 的
   --sp-3）会被挤到第二行，平白多出一截空白。负的左外边距正好抵掉那段间距。 */
.page-head-actions:empty { margin-left: calc(var(--sp-3) * -1); min-height: 0; }
@media (max-width: 720px), (pointer: coarse) {
  /* 触屏上按钮是 44px，页头与有内容的操作区使用相同高度。 */
  .page-head { min-height: calc(var(--control-h-touch) + var(--sp-3) + 1px); }
  .page-head-actions:not(:empty) { min-height: var(--control-h-touch); }
}

/* 刷新控件：「更新于 HH:mm:ss（UTC+8） · 每 N 秒自动刷新」+「刷新」。时间与周期各自不折行，
   窄屏只在两段之间换行（文字靠右，贴着按钮）。 */
.refresh-ctl { display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; max-width: 100%; }
.refresh-ctl-time { min-width: 0; text-align: right; }
.refresh-ctl-at, .refresh-ctl-period { white-space: nowrap; }
.refresh-ctl > .btn { flex: none; white-space: nowrap; }

/* 时间范围条（CONS-B-10）：页头下面第一块，分段按钮 + 行尾槽（刷新控件）。行尾槽总是贴右，放
   不下时整体换到下一行、仍贴右，右缘与页头操作区对齐。 */
.range-bar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3);
  margin-bottom: var(--gap); min-width: 0;
}
.range-bar > .seg { max-width: 100%; }
.range-bar > .spacer ~ * { margin-left: auto; }

/* 表格分页（CONS-C-08）：居中，上边一道细线；两侧是常规 .btn（36px / 15px）。窄屏两颗按钮
   贴两边，文字在中间。 */
.pager {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: var(--sp-3);
  margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--border);
}
.pager-text { white-space: nowrap; }
@media (max-width: 480px) {
  .pager { justify-content: space-between; }
}
/* 加载更多（照片、时间线、视频任务）：按钮与「已加载 n 条」放不下时换行，不撑宽卡片。 */
.more-row { flex-wrap: wrap; }

/* 表格（CONS-C-07）：所有数据表的行悬停同一个底色，单元格一律垂直居中，空值「—」一律 --muted。
   自带底色的单元格（选中行、吸左列、高亮行）照旧盖在上面。悬停色是卡片底上叠 4% 的 --ink
   （console-batch01-p2 FX）：浅色 --plane 对 --surface 只有约 1.02:1，指针停上去等于没反应；
   叠出来的颜色不透明，吸左的单元格也用它。管理三页的行、窄屏卡片与审计整卡的悬停同一个值。 */
tbody tr:hover { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
td { vertical-align: middle; }
.empty-value, td.is-empty { color: var(--muted); }
/* 表格外层的横滑容器。超过 15 行（lib/ui.js markLongTable）时限高、表头吸顶；首列表头压在其它
   表头之上，横向滚动时吸左的首列（相机管理）不被后面的表头盖住。 */
.table-scroll { overflow-x: auto; max-width: 100%; }
.table-scroll.is-long { max-height: min(70vh, 720px); overflow: auto; }
.table-scroll.is-long thead th { position: sticky; top: 0; z-index: 2; background: var(--surface); }
.table-scroll.is-long thead th:first-child { z-index: 3; }
/* border-collapse 的表格里，表头的下边线属于表格、不跟着吸住的 th 走，滚动后表头与第一行之间就
   没有线了。补一道 1px 的线压在原来那道线的位置上：不滚动时两者重合，看上去仍是一道。不用
   box-shadow，免得盖掉相机管理首列表头的竖分隔线。 */
.table-scroll.is-long thead th::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px;
  background: var(--border); pointer-events: none;
}
@media (max-width: 720px) {
  /* 窄屏以卡片呈现的表（.admin-card-table）表头是隐藏的，整张表随页面滚动，不限高。 */
  .table-scroll.is-long:has(.admin-card-table) { max-height: none; }
}

/* console-batch01-p2 T11 */
/* 照片与延时视频两页接入全站组件后的补充（CONS-A-01 / B-06 / B-15 / C-01 / C-03 /
   C-08；pages/photos.js、pages/videos.js）。只用令牌：没有新颜色、新字号。 */

/* 延时视频任务卡：卡片标题 h2 与状态徽标（.status）同一行。这一行不再加上外边距
   ——卡片的内边距就是标题的上边距，与其它卡片一样；h2 的下外边距也去掉：它在一行
   flex 里，下外边距只会把标题顶得比徽标高。 */
.video-job-head { margin-top: 0; }
.video-job-head > h2 { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.video-job-head > .status { flex: none; }
/* 「加载更多」摆完最后一批时焦点落在新卡片的标题上（tabindex=-1）：鼠标点的不画
   焦点环，键盘来的画全站同一种 2px --accent（与链路页的 h2 同一写法）。 */
.video-job-head > h2[tabindex="-1"]:focus { outline: none; }
.video-job-head > h2[tabindex="-1"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* 「创建延时视频」是这张卡片唯一的主按钮，永远在按钮行最右：窄屏上时长说明占满
   一行、按钮换到下一行时也贴右（CONS-C-03，同照片选择栏的 .sel-bar > .btn.primary）。 */
.daily-options .form-row > .btn.primary { margin-left: auto; }

/* 新建表单（.daily-builder）的字段行是 .form-inline。.form-inline 让每个 .field
   不换行（标签与控件是一组），但这张卡片的错误挂在 .field 里、要独占下一行（T34
   VID-ui-04）：这里的 .field 仍允许换行，第一行照旧是「标签 + 控件」。 */
.daily-builder .form-inline > .field { flex-wrap: wrap; }
@media (max-width: 720px) {
  /* 窄屏标签仍在左（.form-inline 的规矩，与照片页筛选一致，CONS-B-15）：每个字段
     占满一行，控件吃掉标签右边的剩余宽度，错误换到下一行。取代原来的「标签在上」
     （VID-ux-10 的问题是标签被挤成竖排、下拉框出界；标签 flex:none、控件
     min-width:0 之后两者都不会再发生）。 */
  .daily-builder .form-inline > .field {
    flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--sp-1) 0;
  }
  .daily-builder .form-inline > .field > label { margin-right: var(--sp-2); }
  .daily-builder .form-inline > .field > select,
  .daily-builder .form-inline > .field > input { flex: 1 1 0; width: auto; min-width: 0; }
  .daily-builder .form-inline > .field > .field-error { flex: 1 1 100%; contain: inline-size; }
}

/* 照片「创建延时视频」弹窗（.video-form.form-stacked，标签在上）：时长写在帧率下拉
   下面；按钮行 .admin-form-actions（靠右，≤480px 竖排撑满、「取消」在上）与上面的
   内容隔开 --sp-3，与确认弹窗的按钮行一样。 */
.video-form .field > .meta { margin-top: var(--sp-1); }
.video-form .admin-form-actions { margin-top: var(--sp-3); }

/* 照片页日期行是 .form-inline（标签在左）。单日这一组（‹ 前一天 [日期] 后一天 ›）
   在手机宽度上放不下左边的标签：日期框只剩约 120px，年份最后一位被日历图标盖住。
   ≤720px 时只让这一个复合字段整组换行——「日期」在上、整组占满一行（T30 原来的
   写法）；「开始日期」「结束日期」照旧标签在左。 */
@media (max-width: 720px) {
  .photo-date-row.form-inline > .photo-day-field { flex-wrap: wrap; }
}

/* console-batch01-p2 T12 */
/* 链路健康与审计两页接入全站组件（lib/ui.js）之后的页面级排版。只用令牌，没有新颜色、
   新字号。同一文件前面的精确替换：链路页删掉 .link-refresh / .link-updated 与本页分段
   按钮单独 10% 的叠色（.seg 只有一种规格），焦点环改挂到卡片标题 h2；审计删掉
   .admin-pager（改用 .pager）与 .btn.admin-detail-toggle（改用 .row-link），触屏那条改挂
   到 .admin-cell-detail > .row-link。 */

/* 链路时间线：事件名是 .status（圆点没有文字基线），与时间同一行居中对齐。 */
.link-page .hist-head { align-items: center; }
/* 「加载更多」展开到底、按钮收起时焦点落到新出来的第一条：只给键盘焦点画环。 */
.link-page .hist-item:focus { outline: none; }
.link-page .hist-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* 「加载更多」在卡片底部：与列表隔开一点；卡片自己有内边距，不再要下外边距。 */
.link-page .more-row { margin: var(--sp-3) 0 0; }

/* 审计：详情摘要是 .row-link，里面是「▸ 三角 + 摘要」两段。三角与摘要首行对齐（摘要会
   折行），按钮不超出单元格；悬停与键盘焦点时只给摘要画下划线——三角是装饰。 */
.admin-cell-detail > .btn.link.row-link { align-items: baseline; max-width: 100%; min-width: 0; }
.admin-cell-detail > .btn.link.row-link:hover,
.admin-cell-detail > .btn.link.row-link:focus-visible { text-decoration: none; }
.admin-cell-detail > .btn.link.row-link:hover:where(:not(:disabled)) > .admin-detail-summary,
.admin-cell-detail > .btn.link.row-link:focus-visible > .admin-detail-summary {
  text-decoration: underline; text-underline-offset: 3px;
}

/* 审计表 ≤1023px 是一条记录一张卡片（T39）：长表不限高、不在框里滚（表头本来就藏着），
   整张卡片随页面滚动；悬停时整张卡片换底色（与管理页的卡片表同一种），不只是卡片里的
   那一行。 */
@media (max-width: 1023px) {
  .admin-audit-card .table-scroll.is-long { max-height: none; }
  .admin-audit-table.cm4-table > tbody > tr:hover { background: none; }
  .admin-audit-table.cm4-table > tbody.admin-audit-entry:hover { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
}

/* console-batch01-p2 T09 */
/* 总览与设备状态两页接入全站组件（lib/ui.js）之后的页面级排版（CONS-B-02 / B-10 / C-01 /
   C-07 / C-08；pages/overview.js、pages/status.js）。只用令牌，没有新颜色、新字号：状态的颜色
   全由 .status.<级别> 负责，这里不覆盖。旧的 .badge / .s 规则留给 T21 在全站无引用后删除。 */

/* 在线徽标（在线 / 离线 / 从未上报）：不折行、不被标题挤窄。页头里与链路页的徽标一样居中
   对齐（.status 的圆点没有文字基线）；总览卡片里仍跟着卡片标题的第一行。 */
.page-head > .online-status { flex: none; white-space: nowrap; align-self: center; }
.cam-head > .online-status { flex: none; white-space: nowrap; }

/* 设备状态页的页头在窄屏折成两行（相机名 + 徽标 / 最近上报）。空的操作区本来用自己的
   min-height 让单行页头与放了「刷新」的页头一样高；它落到第二行时会把「最近上报」那一行撑成
   44px、下面空出一截。这里改由页头自己的 min-height 保证同一个单行高度（控件高 + 下内边距 +
   下边线，border-box），空操作区不再占高：单行时照旧 49 / 57，两行时不多出空白。 */
.status-page .page-head:has(> .page-head-actions:empty) { min-height: calc(var(--control-h) + var(--sp-3) + 1px); }
.status-page .page-head > .page-head-actions:empty { min-height: 0; }
@media (max-width: 720px), (pointer: coarse) {
  .status-page .page-head:has(> .page-head-actions:empty) { min-height: calc(var(--control-h-touch) + var(--sp-3) + 1px); }
}

/* 电压结论行（总览卡片、设备状态页的电压卡片）：独占一行（flex，不是 .status 的
   inline-flex），圆点跟着第一行文字走——结论在窄卡片里会折两三行，align-items:center 会把
   圆点推到文字块正中。calc(.8em - 4px) = 行高 1.6 的第一行正中（与原 .tile .s .dot、功耗页
   .battery-status 同一个值）。 */
.voltage-status { display: flex; align-items: flex-start; margin: var(--sp-1) 0 0; }
.voltage-status > .status-dot { margin-top: calc(.8em - 4px); }

/* 设备状态页的电源瓦片：值那一行本身就是 .status（图标顶替圆点、与文字同色），字号仍是
   .power-tile .v 的 --fs-xl；下面「读取于 …」是 .meta，与值隔一点。 */
.power-tile .power-state.status { display: flex; }
.power-tile .power-read { margin-top: var(--sp-1); overflow-wrap: anywhere; }

/* 设备状态页：曲线下面一行是「N 个数据点」与「显示数据表」，紧贴数据表卡片（原来挂在时间
   范围那一行的最右，375 下单独掉到第三行右下角）。按钮放不下时整体换行、仍贴右。 */
.status-table-bar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3);
  margin-bottom: var(--gap); min-width: 0;
}
.status-table-bar > .spacer ~ * { margin-left: auto; }

/* console-batch01-p2 T19 */
/* 存储页「手动上传指定月份」与「拍照立即上传测试」两张卡（CONS-D-01 / B-06、C-01、C-07、C-08、
   B-02）。两处内联确认与任务行的「确认取消 / 不取消」都改成了确认弹窗（lib/confirm_delete.js，
   样子在 .admin-confirm*）；状态列与核实结论是 .status，分页是 .pager，刷新是 .refresh-ctl，行悬停、
   单元格居中、空值「—」都在 T02 段。前面的精确替换：.cm4-task-table 单元格不再 vertical-align:top，
   删掉 .s.cm4-task-status 把文字压成 --ink 的覆盖与用不到的 .cm4-task-icon / .cm4-cancel-confirm，
   标题行的 .meta 规则改给刷新控件。这里只有这张卡自己的排法，只用令牌。 */

/* 标题行：「指令列表」、「显示全部指令」、行尾的刷新控件，按中线对齐（按钮没有可比的基线）；放不下
   时刷新控件整块换到下一行，仍贴右（margin-left:auto，见上面的精确替换）。 */
.cm4-task-head { align-items: center; }
.cm4-task-show-all { min-width: 0; }
.cm4-task-head > .refresh-ctl { max-width: 100%; }

/* 状态列：图标顶替圆点、与文字同一级别色；「已有同类指令」标记跟在后面。 */
.cm4-task-table td.cm4-task-state > .status { vertical-align: middle; }

/* 九列在 1440 放不下（HEAD 起就要横滑，「操作」列的「取消」被裁在卡片右边）：下发 / 完成时间两列允许
   在日期与时间之间折成两行，与「创建人 / 时间」那列一样两行高，行高不变；窄屏照旧在 .table-scroll 里横滑。 */
.cm4-task-table td.cm4-task-time { white-space: normal; }

/* console-batch01-p2 T10 */
/* 功耗页（pages/energy.js）接入全站组件之后的页面级排版（CONS-B-02 / B-10 / C-01 / C-07）。只用
   令牌，没有新颜色、新字号。同一文件前面的精确替换：电池卡片结论行 .battery-card .s → .battery-status
   （.status），「刷新」的 aria-disabled 样子从 .page-head .btn 挪到 .refresh-ctl > .btn，删掉用不到
   的 .energy-updated。时间范围条、刷新控件、行悬停、吸顶、空值都在 T02 段。 */

/* 「最近 7 天 · 按天统计 · UTC+8」（role=status）：电池卡片之后、随范围变化的指标之前，是下面这些
   内容的说明，贴着指标，不要 <p> 默认的 1em 上下外边距。 */
.energy-range-status { margin: 0 0 var(--sp-2); }

/* console-batch01-p2 T08 */
/* 设置页状态条（CONS-C-01 / C-06，FD10）：下发行图标带 data-level（pages/settings_common.js
   deliveryMark）——confirmed good ✓、failed critical ✕、unknown 与 not_deliverable warning ⚠、
   pending / sent 与等待码 waiting_read / waiting_apply muted ⏳——颜色与 .status 同一套 --*-text。
   ⏳ 这类符号要文字字形才跟颜色走（JS 已带 U+FE0E，这里兜底）。「相机当前值」行的 ⓘ 是 info。
   同一文件前面的精确替换：旧的 .st-delivery .notice-icon 由 serious 改为缺省 warning。拍摄计划页
   原来自己的 data-delivery-tone 钩子（特异性更高）已由 T16 改用 data-level 后删除。 */
.st-delivery .notice-icon { font-variant-emoji: text; }
.st-delivery .notice-icon[data-level="good"]     { color: var(--good-text); }
.st-delivery .notice-icon[data-level="warning"]  { color: var(--warning-text); }
.st-delivery .notice-icon[data-level="serious"]  { color: var(--serious-text); }
.st-delivery .notice-icon[data-level="critical"] { color: var(--critical-text); }
.st-delivery .notice-icon[data-level="muted"]    { color: var(--muted); }
.st-device .notice-icon { color: var(--accent-text); }

/* console-batch01-p2 T18 */
/* 存储页（pages/storage.js、pages/storage_cm4.js）：三张表单与拍摄计划、相机参数同一种写法
   （CONS-B-15），CM4 SSD 状态卡的上报徽标与从未上报块（CONS-C-01 / D-05）。只用令牌，没有新
   颜色、新字号。同一文件前面的精确替换：删掉存储页局部的 --form-col（13px × 10 = 130px）、
   .storage-form 的三列网格与字段错误 / 设备值的缩进、≤480 的两列网格，.cm4-empty-token 的
   --ink-2（现在是 info 提示条），.storage-limit-help 的字号与颜色（现在是 .note）。
   - 表单是 .form-stacked.is-columned（T01 段）：>720 标签列就是全站 --form-col（8.5em，
     15px 下 127.5px），控件左缘与另两页同一个 x；≤720 标签在上。字段下面的说明、错误、设备
     值都在控件那一列里，不再另算缩进。字段之间上下各 --sp-2（原来 .form-row 的外边距）。
   - 「项目根目录」一行与下面的「保存路径」同一列宽：>720 两列，≤720 标签在上。--form-col
     按 em 计，所以这一行用 15px；里面的标签与路径照旧 13px（.storage-page .st-label /
     .path-root code）。
   - 从未上报块：标题与「CM4 从未上报」徽标同一行，放不下就整个徽标换行；徽标不跟标题加粗。
   - 上传策略页脚是 .note，给回原来 <p> 与上一行之间的距离。 */
.storage-page .form-stacked > .field { margin: var(--sp-2) 0; }
.storage-page .path-root {
  display: grid; grid-template-columns: var(--form-col) minmax(0, 1fr);
  align-items: baseline; gap: var(--sp-1) var(--sp-3); font-size: var(--fs-base);
}
.storage-policy-footer { margin: var(--sp-3) 0 0; }
.cm4-empty-title { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); }
.cm4-empty-title > .status { font-weight: 400; }
@media (max-width: 720px) {
  .storage-page .path-root { display: block; }
  .storage-page .path-root > .st-label { display: block; margin: 0 0 var(--sp-1); }
}
/* 灰按钮的原因（AD2 那句「未修改；CM4 代理尚未上报存储配置，暂无法下发」比别的长）：占满按钮
   左边剩下的宽度，放不下就在自己里面折行，不把「保存并下发」挤到下一行（768 宽时原来整条保存条
   会变成两行高）。≤480 的紧凑栏里它本来就隐藏，原因在按钮的 title。 */
.storage-page .save-row > .save-idle-hint:not([hidden]) { flex: 1 1 0%; }
.storage-page .save-row > .save-idle-hint:not([hidden]) + .spacer { display: none; }

/* console-batch01-p2 T17 */
/* 相机参数页接入全站的表单、状态徽标与提示条（CONS-A-02 / A-04 / B-15 / C-01 / C-06；
   pages/camera.js、pages/camera_config_card.js）。只用令牌：没有新颜色、新字号。同一文件前面的
   精确替换：删掉 .camera-param .field / .field label（14/600 行头改 .param-name）与 ≤480 的两条、
   ≤720 的 .camera-view 4px 内缩、状态列的「≠ / ✕」前缀与 600 字重、顶部提示按语气单独上色的
   规则（改由 .notice.<级别> 负责），.camera-help 不再自带字号（是 .note）。 */

/* 参数行是 .form-stacked.is-columned 的一个 .field（CONS-B-15）：行头 .param-name 占 --form-col
   一列，与拍摄计划、存储的标签同一个 x；三列（云端目标值 / 相机当前值 / 状态）、警告与说明依次
   占第二列，都撑满整列——.is-columned 的 justify-items:start 会让它们按内容收窄，各行的三列就
   对不上了。行与行之间的距离仍由 .camera-param 的上下内边距给，第二列里各块之间不另加行距。 */
.form-stacked.is-columned .camera-param.field { row-gap: 0; }
.camera-param.field > :not(label) { justify-self: stretch; align-self: stretch; }
@media (min-width: 721px) {
  /* 行头与控件同一行：第二列顶上是列头「云端目标值」（12px × 行高 1.6）和 2px 间距，下面才是
     控件；行头的一行（14px × 1.6）在控件高度里居中，与另两页标签在控件旁居中是同一个样子。 */
  .camera-param.field > label.param-name {
    align-self: start;
    margin-top: calc(var(--fs-xs) * 1.6 + 2px + (var(--control-h) - var(--fs-md) * 1.6) / 2);
  }
}
@media (min-width: 721px) and (pointer: coarse) {
  .camera-param.field > label.param-name {
    margin-top: calc(var(--fs-xs) * 1.6 + 2px + (var(--control-h-touch) - var(--fs-md) * 1.6) / 2);
  }
}
/* 721–1023px：行头在左之后第二列放不下三列（768 宽下目标值列只剩约 150px，比下拉框还窄），
   三块改为上下排，与 ≤720px 的排法相同（列头在值的前面）；≥1024px 仍是三列并排。 */
@media (min-width: 721px) and (max-width: 1023px) {
  .camera-cols { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2) var(--sp-3); }
  .camera-col-device, .camera-col-status {
    grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: var(--sp-1) var(--sp-2);
  }
  .camera-device-note, .camera-not-applied { grid-column: 1 / -1; }
}

/* 状态列是 .status 徽标（CONS-C-01，FD10；级别见 lib/camera_config.js ROW_STATUS_LEVEL）。状态
   文字会折两三行（「不一致（不会下发：…）」）：圆点跟着第一行走，不在整块中间；文字是这一块的
   基线，窄屏「状态」列头与它按基线对齐（圆点没有文字基线）。 */
.camera-row-status.status { align-items: flex-start; }
.camera-row-status > .status-dot { margin-top: calc(.8em - 4px); }
.camera-row-status > .status-text { align-self: baseline; }

/* console-batch01-p2 T13 */
/* 通用抽屉（lib/modal.js createDrawer，CONS-C-05 / FD2）：管理三页的详情。DOM 与类同设置页的
   历史抽屉（.drawer-root > .drawer-backdrop + aside.drawer，头部 h2 +「关闭」，正文 .drawer-body），
   宽 min(420px, 100%)、右侧贴边、背景遮罩都用前面现成的 .drawer 规则；这里只补 createDrawer 抽屉
   多出来的三件：头部吸顶、分节、危险区红线。只用令牌，没有新颜色。 */

/* 头部吸顶：内容长（用户抽屉六节）时标题和「关闭」一直在顶上，窄屏满宽、看不到背景时也随时关得
   掉。不滚动时与历史抽屉一个样子（下边线透明）；滚过以后（.is-scrolled，createDrawer 按 scrollTop
   加上）才画出那道线。scroll-padding 让「下一步」跳到某一节时，节标题不钻到头部下面。 */
.drawer.has-sticky-head { scroll-padding-top: calc(var(--control-h-touch) + var(--sp-4) * 2); }
.drawer.has-sticky-head > .drawer-head {
  position: sticky; top: calc(var(--sp-4) * -1); z-index: 1;
  margin: calc(var(--sp-4) * -1) calc(var(--sp-4) * -1) 0;
  padding: var(--sp-4) var(--sp-4) var(--sp-2);
  background: var(--surface); border-bottom: 1px solid transparent;
}
.drawer.has-sticky-head > .drawer-head.is-scrolled { border-bottom-color: var(--border); }
/* 长标题（「用户详情 · 很长的邮箱」）自己折行，「关闭」不被挤成「关 / 闭」两行。 */
.drawer.has-sticky-head > .drawer-head > h2 { min-width: 0; overflow-wrap: anywhere; }
.drawer.has-sticky-head > .drawer-head > .btn { flex: none; white-space: nowrap; }

/* 分节：每节一个 h3.sub-head（全站的 .sub-head，14/600/--ink-2，FD17），节与节之间一道 --border
   细线；危险区（.is-danger）那道是 --critical。 */
.drawer-section { min-width: 0; }
.drawer-section + .drawer-section {
  margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--border);
}
.drawer-section.is-danger { border-top-color: var(--critical); }
/* 跳转把焦点放到某一节的标题上（tabindex=-1）：鼠标来的不画框，键盘来的画全站的焦点环。 */
.drawer .sub-head[tabindex="-1"]:focus { outline: none; }
.drawer .sub-head[tabindex="-1"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* 管理 › 用户的页头（pages/admin_users.js，CONS-B-02 / B-15）：右侧 .page-head-actions 里是搜索
   （.form-inline）和最右的「新建用户」。窄屏上操作区独占一行，搜索框伸展、「新建用户」贴右，与原来
   .admin-toolbar 的窄屏样子一致。 */
@media (max-width: 720px) {
  .admin-users-head > .page-head-actions { flex: 1 1 100%; }
  .admin-users-head .admin-search-form { flex: 1 1 auto; }
}

/* console-batch01-p2 T16 */
/* 拍摄计划页（pages/schedule.js）接入全站组件之后的排法（CONS-A-02 / A-04 / B-15 / C-01 / C-06）。
   只用令牌：没有新颜色、新字号。同一文件前面的精确替换：删掉本页状态条的 data-delivery-tone
   钩子（下发行图标改由 T08 段的 data-level 着色）；相机时钟 / 电源状态 / STM32 命令三张卡结论行
   旧的 data-tone 配色与 .power-icon 那几条；熔断、矛盾两条提示条自带的 serious 配色（改 warning
   级 .notice）；电源卡标签 14px → 13px（字段标签那一档，与时钟卡同）；失败卡的说明改 .note。 */

/* 三张卡的结论行与电源卡的读数是 .status（lib/ui.js）：结论会折两三行，独占一行（flex），圆点 /
   图标跟着第一行文字走——行高 1.6 的第一行正中：8px 圆点 calc(.8em - 4px)，15px 的图标
   13px × .8 − 7.5px（与功耗页 .battery-status、总览 .voltage-status 同一写法）。 */
.schedule-clock-state.status,
.schedule-system-state.status,
.schedule-power-row .schedule-power-state.status { display: flex; align-items: flex-start; }
.schedule-clock-state.status > .status-dot,
.schedule-system-state.status > .status-dot,
.schedule-power-state.status > .status-dot { margin-top: calc(.8em - 4px); }
.schedule-clock-state.status > .status-icon,
.schedule-system-state.status > .status-icon,
.schedule-power-state.status > .status-icon { margin-top: calc(var(--fs-sm) * .8 - var(--fs-base) / 2); }

/* 电源卡：图标与圆点占同样宽（一个 15px 的格子），「读取于」那一行的时间缩进同样宽，四行的
   文字从同一条竖线起头。 */
.schedule-power-state.status > .status-icon { min-width: var(--fs-base); text-align: center; }
.schedule-power-state.status > .status-dot { margin-inline: calc((var(--fs-base) - 8px) / 2); }
.schedule-power-read { display: block; padding-left: calc(var(--fs-base) + var(--sp-1)); }

/* 相机时区：.form-stacked.is-columned 的一个字段（>720px 标签在左、列宽 --form-col，与相机参数、
   存储两页同一个 x；≤720px 标签在上，CONS-B-15）。控件那一格（输入框 + 保存 +「未修改」）撑满
   第二列，输入框照旧 14em、最宽 22em；两句说明也在第二列，左缘与输入框对齐。 */
.schedule-clock-zone-form { margin: var(--sp-2) 0 0; }
.schedule-clock-zone-field > .schedule-clock-zone-row { margin: 0; justify-self: stretch; align-self: stretch; }
.schedule-clock-zone-reason:empty { display: none; }
/* 控件那一格折成两行时（768 左右「未修改」掉到下一行），标签跟着第一行的输入框走，不落在两行
   正中：标签一格高一个控件，字在其中居中。 */
@media (min-width: 721px) {
  .schedule-clock-zone-form.is-columned .schedule-clock-zone-field { align-items: start; }
  .schedule-clock-zone-form.is-columned .schedule-clock-zone-field > label {
    display: flex; align-items: center; min-height: var(--control-h);
  }
}
@media (min-width: 721px) and (pointer: coarse) {
  .schedule-clock-zone-form.is-columned .schedule-clock-zone-field > label { min-height: var(--control-h-touch); }
}

/* 卡片里的说明段落（.note 自己不带外边距）：上下间距与原来的 p 相当。 */
.schedule-card-note { margin: var(--sp-3) 0; }

/* 状态条里「云端已有新版本」⚠ 与「已载入别处保存的版本」ⓘ：图标按级别着色（FD11：warning /
   info），与同一张状态条下发行的图标同一套颜色。 */
.schedule-remote-line .notice-icon { font-variant-emoji: text; }
.schedule-remote-line[data-kind="newer"] .notice-icon { color: var(--warning-text); }
.schedule-remote-line[data-kind="reloaded"] .notice-icon { color: var(--accent-text); }

/* STM32 命令的阶段标记：✓ ▶ ✗ 与图标同为 15px（CONS-C-06，原来 13px）；还没走到的那几格写的是
   序号，留在阶段文字的字号。 */
.schedule-system-step:not([data-state="todo"]) > .schedule-system-mark { font-size: var(--fs-base); line-height: 1; }

/* console-batch01-p2 T14 */
/* 管理 › 项目（pages/admin_projects.js）的详情改成全站通用抽屉（lib/modal.js createDrawer，CONS-C-05）
   之后的一处补充；抽屉、分节、危险区红线都用 T13 段与前面现成的 .drawer 规则。只用令牌，没有新颜色、
   新字号。同一文件前面的精确替换：删掉内联详情卡（.admin-detail 与 -head / -title / -body / -section）、
   行内打开 .admin-project-open（改用全站 .row-link）、相机名 .admin-camera-name 与实心链接按钮
   .admin-linkbtn（改用全站 .btn.link，CONS-C-04）、≤480 的 .admin-detail-head .btn，触屏两处选择器
   列表里去掉 .admin-project-open。 */

/* 抽屉里一台相机的名称是全站的文字链 .btn.link（15px、下划线、--ink-2）：放在一行 flex 里，长名称自己
   折行、字靠左，不把 id 与「最近上报 相对（绝对）」挤出 420px 的抽屉（原 .admin-camera-name 的排法，
   字号不再单独定成 14px）。 */
.admin-camera-row > .btn.link {
  flex: 0 1 auto; min-width: 0; justify-content: flex-start; text-align: left; overflow-wrap: anywhere;
}

/* ≤480 时 .admin-actions 里的按钮各占一整行（撑满，好按）；文字链不撑：撑满后的一行字居中，与用户抽屉
   里左对齐的「查看审计记录」不是一个样子。「查看审计记录」「在此项目新建相机」保持自然宽、靠左。 */
@media (max-width: 480px) {
  .admin-project-drawer .admin-actions > .btn.link { flex: 0 1 auto; }
}

/* 索引状态是“相对（绝对）”（CONS-D-02）：「索引出错 · 4 分钟前」「（2026/09/27 18:40:23）」两段各自
   不折行，列不够宽时（1024 的笔记本、抽屉里的事实行）只在两段之间换行，数字与单位、日期与时间不被拆开；
   表格里这一格因此不再整格 nowrap。与相机管理页的 .admin-when-rel / .admin-when-abs 同一个写法，这里只管
   项目页自己的两处（表格、抽屉）。 */
.admin-projects-table .admin-cell-index { white-space: normal; }
:is(.admin-projects-table, .admin-project-drawer) :is(.admin-when-rel, .admin-when-abs) { white-space: nowrap; }
/* 表格自动排宽时多出来的宽度按各列“最长那一格”分：一个很长的根目录会把索引状态挤成两行（1440 下也是）。
   根目录列先拿 30%（实测 1280 / 1440 下索引状态一行放得下，1024 下两列都折两行、表格不横滑）；≤720 是
   卡片，不用。 */
@media (min-width: 721px) {
  .admin-projects-table .admin-cell-root { width: 30%; }
}

/* console-batch01-p2 T15 */
/* 管理 › 相机（pages/admin_cameras.js）接入全站组件之后的补充（CONS-B-02 / B-15 / C-01 / C-05 / C-07 /
   D-02）：详情是全站通用抽屉（lib/modal.js createDrawer；分节、危险区红线、头部吸顶用 T13 段），页头是
   pageHead，表格是 .table-scroll + .status 圆点。只用令牌，没有新颜色、新字号。同一文件前面的精确替换：
   删掉内联详情（.admin-camera-detail / -cards、.admin-camera-card、.admin-card-danger）、行内打开
   .admin-cam-open（改用全站 .row-link，触屏两处选择器列表同步）、开通状态的图标 .admin-prov-icon 与
   .admin-prov-none 灰字、.admin-prov-dd 的对齐、.admin-prov-next 的字号（改 .note）、T42 段给
   .s.admin-status 定字号的覆盖；T42 段的事实行选择器改挂 .admin-camera-drawer。 */

/* 页头：右侧 .page-head-actions 里是项目筛选（.form-inline）和最右的「新建相机」。窄屏上操作区独占一行，
   筛选伸展、「新建相机」贴右，与用户页同一个样子。 */
@media (max-width: 720px) {
  .admin-cameras-head > .page-head-actions { flex: 1 1 100%; }
  .admin-cameras-head .admin-filter-form { flex: 1 1 auto; }
}

/* 「相对（绝对）」两段（CONS-D-02）：相对那一段不折行（「6 分钟前」不拆成「6 分钟」「前」）；列窄时先在两段
   之间换行，再窄只在绝对时间的日期与时间之间换行——数字、「/」「:」和括号都不是断点。签发时间与两个“最近
   上报”列因此不再整列 nowrap：十列的表在 1440 宽下仍放得下，不必横滑（整句不折行时表格要 1134px，内容区
   只有 1051px）。 */
.admin-when-rel { white-space: nowrap; }
.admin-cameras-table :is(.admin-cell-token, .admin-cell-seen) { white-space: normal; }
/* 状态文字（.status-text）与卡片行的单元格带着 overflow-wrap:anywhere，会把表格列的最小宽度压到一个字、
   在「2026/09/2」「7」这种地方断开；时间这一块回到普通断行规则。 */
.admin-when { overflow-wrap: normal; }
/* 状态文字折成两三行（签发时间）时，圆点跟着第一行走，不在整块中间（与拍摄计划、相机参数的状态同一写法：
   13px × 行高 1.6 的第一行正中）。一行的时候位置与居中相同。 */
.admin-cameras-table .status, .admin-camera-drawer .admin-cred-facts .status { align-items: flex-start; }
.admin-cameras-table .status > .status-dot,
.admin-camera-drawer .admin-cred-facts .status > .status-dot { margin-top: calc(.8em - 4px); }

/* 危险区里“查看任务”文字链单独一行，与基本信息里“查看审计记录”（.admin-audit-entry）同一个间距。 */
.admin-link-entry { margin: 0 0 var(--sp-2); }

/* 存储页 9/28 测试修复（CL-01 / CL-05 / CL-09）：字段下的 ⚠ 行——路径 CM4 会拒收、上次下发被拒收、
   SSD 上限超出容量或低于当前占用；与「设备当前」行同一列、同一字号，左色条与 ⚠ 取 warning。
   .is-note 是「未启用限制，这一项不生效」这类说明：没有色条与 ⚠，--muted。
   项目根目录不在 CM4 上传前缀之下时，根目录后面的一小句同为 warning 色。 */
.storage-field-warn {
  margin: var(--sp-1) 0 0; min-width: 0; overflow-wrap: anywhere;
  padding-left: var(--sp-2); box-shadow: inset 3px 0 0 var(--warning-bar);
  color: var(--ink); font-size: var(--fs-sm);
}
.storage-field-warn::before { content: "⚠\FE0E  "; color: var(--warning-text); }
.storage-field-warn.is-note { padding-left: 0; box-shadow: none; color: var(--muted); }
.storage-field-warn.is-note::before { content: none; }
.path-root-warn { color: var(--warning-text); font-size: var(--fs-sm); font-weight: 600; }
.storage-page .path-root > .path-root-warn { grid-column: 2; min-width: 0; }

/* 存储页路径框（2026-09-29）：项目根目录固定在输入框前面（.path-prefix），用户只填后面的文件夹。
   前缀与输入框拼成一个控件：同一条边框，前缀是灰底、--muted 字；前缀隐藏（框里是整条路径）时输入框
   恢复完整圆角。≤480px 放不下时前缀单独一行在上。 */
.path-group { display: flex; align-items: stretch; min-width: 0; width: 100%; }
/* .is-columned 的字段按内容收窄（justify-items:start）：路径框这一组撑满控件列，与原来的 .path-input 一样宽。 */
.form-stacked.is-columned .field > .path-group { justify-self: stretch; align-self: stretch; }
.path-prefix {
  display: flex; align-items: center; flex: 0 1 auto; min-width: 0;
  padding: var(--sp-1) 0 var(--sp-1) var(--sp-2);
  border: 1px solid var(--control-border); border-right: 0;
  border-radius: var(--radius-sm) 0 0 var(--radius-sm);
  background: var(--surface); color: var(--muted); overflow-wrap: anywhere;
}
.path-prefix[hidden] { display: none; }
.path-group > .path-prefix:not([hidden]) + .path-input { border-top-left-radius: 0; border-bottom-left-radius: 0; }
.path-group > .path-input { flex: 1 1 8em; }
@media (max-width: 480px) {
  .path-group { flex-wrap: wrap; }
  .path-prefix { flex-basis: 100%; border-right: 1px solid var(--control-border); border-bottom: 0;
    border-radius: var(--radius-sm) var(--radius-sm) 0 0; padding-right: var(--sp-2); }
  .path-group > .path-prefix:not([hidden]) + .path-input { border-radius: 0 0 var(--radius-sm) var(--radius-sm); }
}
.path-root-warn { margin: var(--sp-1) 0 0; }
/* 「将保存为 <完整路径>」单独一行（复测 R8a），空时不占高度。 */
.path-full { margin: 2px 0; overflow-wrap: anywhere; }
.path-full:empty { display: none; }

/* T15 拍摄计划复测 */
/* 下发进度（R-CL-09 / FD12）：仍是 .cm4-capture-progress* 那一套，这里只补本页多出来的几样。
   v2.4 多一格「读回核对」：五个节点一行排开，轨道从第一格圆心到最后一格圆心（两头各 1/10），
   条纹段是一格宽（1/4）。走完时最后一格「完成」用 good（完成节点 good、失败节点 critical、当前节点
   与拍照测试卡一致）；v2.4 未能读回确认的那一格是 serious 边框加「!」，不填色——不是失败。
   节点下的字不在"12/31"中间断开（中文字之间照常折行，窄屏下一格也放得下）。
   进度头标题 tabindex=-1（确认「重新下发」后焦点落在这里）：鼠标来的不画框，键盘来的画全站的焦点环。
   进度条下面那一行与自动重发原因那一行都是 .meta，这里只管间距与折行。 */
.schedule-page .schedule-deliver-progress.steps-5 .cm4-capture-progress-nodes {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}
.schedule-page .schedule-deliver-progress.steps-5 .cm4-capture-progress-track { left: 10%; right: 10%; }
.schedule-page .schedule-deliver-progress.steps-5 .cm4-capture-progress-flow { width: 25%; }
.schedule-page .schedule-deliver-progress .cm4-capture-progress-label { overflow-wrap: normal; }
/* 「完成」那一格：--good-text 填底，勾用卡片自己的底色 --surface（像在绿圆上镂空出来）。只用令牌（J34：
   本段不写颜色值）。--good-text 按主题调到与 --surface 至少 4.5:1，所以勾对填底、填底对卡片底，在浅色
   和深色两个主题下都是同一个比值、都过 AA；白勾在深色主题的 --good-text 上只有 3.35:1，不用白勾
   （T15 美观 F1，浅深两色实测）。 */
.schedule-page .schedule-deliver-progress.is-ok .cm4-capture-progress-node:last-child .cm4-capture-progress-dot {
  background: var(--good-text); border-color: var(--good-text); color: var(--surface);
}
.schedule-page .schedule-deliver-progress .cm4-capture-progress-node.warn .cm4-capture-progress-dot {
  border-color: var(--serious); color: var(--serious-text);
}
.schedule-page .schedule-deliver-progress .cm4-capture-progress-node.warn .cm4-capture-progress-label {
  color: var(--ink); font-weight: 600;
}
.schedule-page .schedule-deliver-head .sub-head[tabindex="-1"]:focus { outline: none; }
.schedule-page .schedule-deliver-head .sub-head[tabindex="-1"]:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.schedule-page .schedule-deliver-trigger { margin: var(--sp-1) 0 0; min-width: 0; overflow-wrap: anywhere; }
.schedule-page .schedule-deliver-detail {
  margin: calc(var(--sp-2) - var(--sp-4)) 0 var(--sp-2); min-width: 0; overflow-wrap: anywhere;
}

/* T21 拍摄计划复测 */
/* R-CL-27：表单更靠前、下发进度紧跟状态条、三张相机卡放到表单之后（顺序在 pages/schedule.js），这里是
   随之要的排法。只用令牌（J34：本段不写颜色值），选择器都限定在 .schedule-page 里；不在 @media 里，
   1440 与 375 同一套。
   - 状态条里以图标开头的行（下发行、相机当前值、相机固件）：图标一格、文字一格，文字只在自己那一格
     里折行。原来是 .st-row 的 flex 折行，文字一长就整块掉到下一行，375 / 390 宽下 ✓ ⓘ 自己占一行
     （J26）。行里另有按钮时（v2.4 读回不一致时的「重新下发当前版本」）它排在文字下面、与文字左缘
     对齐。「云端已有新版本」那几行的图标本来就在句子里（remoteSentence），不受影响。 */
.schedule-page .settings-status .st-row:has(> .notice-icon:first-child) {
  display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline;
}
.schedule-page .settings-status .st-row:has(> .notice-icon:first-child) > :not(.notice-icon) {
  grid-column: 2; min-width: 0;
}
.schedule-page .settings-status .st-row > .btn { justify-self: start; }
/* - 固件规则：<details> 卡片默认折叠。标题仍是卡片的 h2，放在 <summary> 里改成行内，原生三角与它同一行、
     同一字号。summary 向外借卡片的内边距再还回来：折叠时整张卡片都是点击区（触屏够一个拇指），高度与
     只有标题的卡片相同；展开后清单在标题下 --sp-2，与 h2 原来的下边距一样。键盘焦点环画在卡片里面。 */
.schedule-page .schedule-rules > summary {
  margin: calc(var(--sp-4) * -1); padding: var(--sp-4);
  border-radius: var(--radius); font-size: var(--fs-lg); cursor: pointer;
}
.schedule-page .schedule-rules[open] > summary { margin-bottom: 0; padding-bottom: var(--sp-2); }
.schedule-page .schedule-rules > summary > h2 { display: inline; margin: 0; }
.schedule-page .schedule-rules > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* - 紧凑保存条（≤480px 的 .is-compact）：灰按钮旁那句原来一律藏起来，整句只在按钮的 title 里。缺能力、
     云端已有新版本时（schedule.js 给保存条加 .schedule-save-blocked）改了表单按钮也还是灰的，这句要
     看得见（J28）：另起一行，占满「更多」与「保存并下发」两格，自己折行。"未修改；…"那几句状态条已经
     说了，照旧藏起来，保存条仍是一行。 */
.schedule-page .save-bar.is-compact.schedule-save-blocked .save-row > .save-idle-hint:not([hidden]) {
  display: block; grid-row: 2; grid-column: 1 / span 2; line-height: 1.35;
}
/* - 电源卡读数已旧时标题下那一行（.status 灰点）：独占一行，与下面几行读数隔 --sp-3，同时钟卡结论行
     （J37：原来是 createPowerCard 写在元素上的两条行内样式）。 */
.schedule-page .schedule-power-offline.status { display: flex; margin: 0 0 var(--sp-3); }

/* overview-map-v1 T08 */
/* 总览页地图区（pages/overview.js）：页头 → 错误框 → .overview-map-slot → .cam-grid。槽自己不设任何
   盒子样式：空着时高度 0、没有外边距，页面与没有地图时一样；里面放的东西各自带 --gap 的下边距，与
   卡片网格隔开。两句 warning 提示就是全站的 .notice.warning（下边距本来就是 --gap），这里不另写。
   地图容器的底色、边框、圆角同 .cam-card，深色主题跟着令牌走（本段不写颜色值）；overflow: hidden
   让 Google 的底图也按圆角裁掉。高度跟着窗口：宽屏取视口高度的 60%，夹在 420–720px 之间（用户 10-05
   嫌 360px 太小）；≤640px 固定 360px，下面的卡片仍露得出来。 */
.overview-map {
  height: clamp(420px, 60vh, 720px); min-width: 0; margin: 0 0 var(--gap); overflow: hidden;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
@media (max-width: 640px) {
  .overview-map { height: 360px; }
}
/* 管理员那一行提示：颜色是 .muted（与页头 meta 同一个灰），字号取说明文字的 --fs-md，窄屏折行。 */
.overview-map-hint { margin: 0 0 var(--gap); font-size: var(--fs-md); overflow-wrap: anywhere; }

/* overview-map-v1 T06 */
/* 管理 → 相机（pages/admin_cameras.js）：新建 / 编辑弹窗里的「安装位置」组，和详情抽屉「基本信息」里
   安装位置那一格。只用令牌，没有新颜色、新字号；前面的规则一条不改。
   - fieldset.admin-location 照「部件序列号」.admin-serials（T41 之前那段）：同样的细边框、小圆角、
     legend 字号与颜色、内边距，≤480 同样收紧。组内一列：地址一整行 → 纬度 + 经度一对（.admin-form-pair，
     ≤600 上下排，T41 段）→ 粘贴提示。
   - 粘贴提示是 .admin-hint（字段提示本来的样子）。它说的是上面那一对框，所以往上收：与框的距离同别的
     字段提示（.admin-form-field 的 --sp-1 间距 + .admin-hint 的 2px 上边距），而不是组内 --sp-3 的间距。
   - 抽屉里那一格：地址一段独占一行；坐标和「在 Google 地图中打开」同一行，放不下时链接折到下一行；
     255 个字符、中间没有空格的地址也能断开。链接是全站的 .btn.link（颜色、下划线、hover 同“查看审计
     记录”），这里只在鼠标宽屏上去掉按钮的最小高度和上下内边距，让它与坐标排在一行字里；窄屏 / 触屏
     仍是全站的 --control-h-touch，好按。 */
.admin-location {
  display: grid; gap: var(--sp-3); min-width: 0; margin: 0;
  padding: var(--sp-3) var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-sm);
}
.admin-location legend { padding: 0 var(--sp-1); font-size: var(--fs-sm); color: var(--ink-2); }
.admin-location > .admin-location-hint { margin-top: calc(var(--sp-1) + 2px - var(--sp-3)); }
@media (max-width: 480px) {
  .admin-location { padding: var(--sp-2) var(--sp-3); }
}
.admin-location-dd { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px var(--sp-3); min-width: 0; }
.admin-location-address { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; }
.admin-location-coords { min-width: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.admin-location-dd .btn.link.admin-location-link { min-height: 0; padding: 0; }
@media (max-width: 720px), (pointer: coarse) {
  .admin-location-dd .btn.link.admin-location-link { min-height: var(--control-h-touch); }
}

/* camera-geocode T01 */
/* 安装位置组里的「根据地址查坐标」（2026-10-06）：地址框下面一行，按钮在前、状态句跟在后面，放不下时
   状态句折到下一行；找到的地点一条一行，地址与坐标在左、「填入坐标」在右，窄屏按钮折到下面。只用令牌：
   状态句平时是 .admin-hint 的灰，警告用 --warning-text、出错用 --critical-text；「大致位置」同警告色。 */
.admin-location .admin-geocode {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); min-width: 0;
  margin-top: calc(var(--sp-1) - var(--sp-3));
}
.admin-location .admin-geocode-status { margin: 0; flex: 1 1 12em; min-width: 0; overflow-wrap: anywhere; }
.admin-location .admin-geocode-status:empty { display: none; }
.admin-location .admin-geocode-status[data-tone="warn"] { color: var(--warning-text); }
.admin-location .admin-geocode-status[data-tone="error"] { color: var(--critical-text); }
.admin-location .admin-geocode-results { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); min-width: 0; }
.admin-location .admin-geocode-results[hidden] { display: none; }
.admin-location .admin-geocode-item {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-2) var(--sp-3);
  padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 0;
}
.admin-location .admin-geocode-text { display: grid; gap: 2px; flex: 1 1 14em; min-width: 0; }
.admin-location .admin-geocode-address { overflow-wrap: anywhere; }
.admin-location .admin-geocode-coords { font-size: var(--fs-sm); color: var(--ink-2); font-variant-numeric: tabular-nums; }
.admin-location .admin-geocode-approx { font-size: var(--fs-sm); color: var(--warning-text); }

/* camera-replace-v1 T10 */
/* 管理 › 相机替换（pages/admin_camera_replace.js）：选择、预检、结果、替换记录四张卡。卡片、表格、提示条、
   状态圆点、按钮、勾选行、表单都沿用全站的类（.card / .cm4-table.admin-table / .table-scroll / .notice /
   .status / .btn / .btn.link.row-link / .check / .form-stacked / .meta-list），这里只补本页的排法。只用
   令牌，没有新颜色、新字号；选择器都以 .replace- 开头，前面的规则一条不改。 */

/* 页头「测试组：FixCamera-ALL（根目录）」：根目录很长时自己折行，不撑宽页面。 */
.replace-pool-meta { overflow-wrap: anywhere; }

/* 第 1 张卡：两个下拉。窄屏上下排，≥1024 并排各占一半；下拉撑满自己那一列（「ID · 名称 · 项目」读得全）。
   「显示全部」是全站的 .check 勾选行（触屏整行 44px）。九项说明是 .meta-list，没选时是 muted 的「—」。
   并排时两列共用三行（subgrid）：下拉 | 「显示全部」（旧相机那一列空着）| 九项说明，两张说明表从同一条线开始，
   逐行对得上。 */
.replace-pickers { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-4) var(--sp-6); }
.replace-picker { display: grid; align-content: start; gap: var(--sp-2); min-width: 0; }
@media (min-width: 1024px) {
  .replace-pickers { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(3, auto); row-gap: var(--sp-2); }
  .replace-picker { grid-row: span 3; grid-template-rows: subgrid; }
  .replace-picker > .replace-facts { grid-row: 3; }
}
.replace-picker > .replace-field { width: 100%; }
.replace-picker select { width: 100%; min-width: 0; }
.replace-show-all { justify-self: start; }
.replace-facts { margin-top: var(--sp-1); }
.replace-facts dd.is-empty { color: var(--muted); }
.replace-select-notice > .notice, .replace-select-notice > .error-box { margin-bottom: var(--sp-3); }

/* 第 2 张卡：阻塞项（红）与提醒项（黄）在表格上面，每条一行。对照表的格子里是整句与路径：改回可折行
   （.cm4-table 默认 nowrap），整表有下限宽度——窄屏在 .table-scroll 里横滑，页面本身不横滑，也不把四列
   压成一两个字一行。「处理」格的第二行是下发说明。 */
.replace-preview-body > .notice, .replace-preview-body > .error-box { margin-bottom: var(--sp-3); }
.replace-preview-body > .notice p { margin: 0; }
.replace-preview-idle { margin: 0; }
.replace-issues { margin: var(--sp-1) 0 0; padding-left: 1.2em; }
.replace-issues li { overflow-wrap: anywhere; }
.replace-issues li + li { margin-top: 2px; }
.replace-table { min-width: 800px; }
.replace-table th, .replace-table td { white-space: normal; overflow-wrap: anywhere; vertical-align: top; }
.replace-table thead th { white-space: nowrap; }
.replace-table .replace-cell-label { width: 8em; }
.replace-table :is(.replace-cell-old, .replace-cell-new) { width: 24%; }
.replace-action-text, .replace-delivery { display: block; }
.replace-delivery { margin-top: var(--sp-1); color: var(--ink-2); }
/* 横滑提示：右边还有没露出来的列时（createScrollHint 给 .scroll 加 .scroll-more）右侧 40px 渐隐，滚到头就去掉。
   与管理三页的表格同一个样子；蒙版只取透明度，用不透明的 --ink 起头，深浅主题一样，不写颜色值。 */
.replace-table-box > .scroll.scroll-more {
  -webkit-mask-image: linear-gradient(to right, var(--ink) calc(100% - 40px), transparent);
  mask-image: linear-gradient(to right, var(--ink) calc(100% - 40px), transparent);
}
.replace-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); margin-top: var(--sp-3); }
.replace-action-hint:empty { display: none; }

/* 确认框：输入框撑满；「旧相机 ID：…」提示与两个勾选行各占一行，长标签在自己那一格里折行。 */
.replace-confirm-field, .replace-confirm-input { width: 100%; }
.replace-confirm-target { margin: var(--sp-1) 0 0; font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
.replace-confirm-text > span { min-width: 0; overflow-wrap: anywhere; }
.replace-confirm-options { display: grid; gap: var(--sp-1); }
.replace-confirm-options > .check { display: flex; min-width: 0; }
.replace-confirm-error:empty { display: none; }

/* 第 3 张卡：标题行左边是「替换结果」与记录概要，右边是全站的刷新控件（更新于 … · 每 15 秒自动刷新）。
   五路一行一条：显示名与相机 | 状态圆点与原因 | 「下发 / 重新下发」；≤720 时按钮留在名字那一行的右边，
   状态与原因另起一整行。状态文字折成两行时圆点跟着第一行走（与相机页同一写法）。 */
.replace-result-head {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3);
}
.replace-result-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); flex: 1 1 16em; min-width: 0; }
.replace-result-meta { min-width: 0; overflow-wrap: anywhere; }
/* 替换完成、打开记录、提交被拒时焦点落到卡片标题上（tabindex=-1）：鼠标来的不画框，键盘来的画全站的焦点环。 */
.replace-result-heading > h2[tabindex="-1"]:focus, .replace-preview > h2[tabindex="-1"]:focus { outline: none; }
.replace-result-heading > h2[tabindex="-1"]:focus-visible, .replace-preview > h2[tabindex="-1"]:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.replace-result-head > .refresh-ctl { margin-left: auto; }
.replace-result-body > .notice { margin-bottom: var(--sp-3); }
.replace-refresh-error { margin: 0 0 var(--sp-2); }
.replace-parts { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); }
.replace-part {
  display: grid; grid-template-columns: minmax(0, 13em) minmax(0, 1fr) auto;
  grid-template-areas: "name state action"; align-items: start;
  gap: var(--sp-1) var(--sp-4); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
}
.replace-part-name { grid-area: name; display: flex; flex-direction: column; min-width: 0; }
.replace-part-label { font-size: var(--fs-md); font-weight: 600; overflow-wrap: anywhere; }
.replace-part-camera { overflow-wrap: anywhere; }
.replace-part-state { grid-area: state; min-width: 0; padding-top: 2px; }
.replace-part-status { align-items: flex-start; }
.replace-part-status > .status-dot { margin-top: calc(.8em - 4px); }
.replace-part-note { margin: var(--sp-1) 0 0; font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
.replace-part-retry { grid-area: action; justify-self: end; white-space: nowrap; }
@media (max-width: 720px) {
  .replace-part { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name action" "state state"; }
}
.replace-links { display: flex; flex-wrap: wrap; align-items: center; gap: 0 var(--sp-4); margin-top: var(--sp-3); }

/* 第 4 张卡：替换记录。整行可点（行内的「旧 → 新」是全站的 .row-link），悬停与选中那条都与管理三页的可点行
   同一个样子（悬停 4% --ink，选中写在悬停之后、盖过它）；结果汇总可折行，其余列沿用 nowrap，窄屏在
   .table-scroll 里横滑。 */
.replace-history-table { min-width: 640px; }
.replace-history-row { cursor: pointer; }
.replace-history-row:hover th, .replace-history-row:hover td { background: color-mix(in srgb, var(--ink) 4%, var(--surface)); }
.replace-history-row.is-selected th, .replace-history-row.is-selected td {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.replace-history-row.is-selected td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
.replace-history-table .replace-history-summary { white-space: normal; min-width: 10em; }

/* 触屏：行内的「旧 → 新」补到 44px（.row-link 自己是 min-height:0），与管理三页的表格一样。 */
@media (max-width: 720px), (pointer: coarse) {
  .replace-history-table .btn.link.row-link { min-height: var(--control-h-touch); }
}

/* camera-replace-v1 T14 */
/* 确认框里「注销旧相机的注册包与 CM4 令牌」下面那句后果（提交审查 c0a54c2 第 4 条）：没勾时是一行
   .admin-hint 小字，与勾选行的文字对齐（缩进 = 16px 勾选框 + 间距）；勾上换成全站的 .notice.warning
   黄条，占满这一格、不再缩进。只用令牌，不写颜色；选择器以 .replace- 开头，前面的规则一条不改。 */
.replace-confirm-options > .replace-revoke-note { margin: 0; min-width: 0; }
.replace-confirm-options > p.replace-revoke-note { padding-left: calc(16px + var(--sp-2)); }

/* camera-replace-v1 T12 */
/* 管理 → 相机（pages/admin_cameras.js）：详情抽屉「基本信息」里“替换记录”那一格。只用令牌，没有新颜色、
   新字号；选择器都以 .admin-replacement 开头，前面的规则一条不改。
   - 一条记录一个 li，条与条之间 --sp-2。每条两行：第一行 时间 + 角色，右边是文字链「查看」；第二行
     「旧 → 新」独占一行，长相机 id 在窄屏上折行，不把抽屉撑宽。角色是次要信息，--ink-2。
   - 文字链是全站的 .btn.link（颜色、下划线、hover 同“查看审计记录”）；照「安装位置」那条链接
     （overview-map-v1 T06 段），只在鼠标宽屏上去掉按钮的最小高度和上下内边距，让它排在一行字里；
     窄屏 / 触屏仍是全站的 --control-h-touch，好按。 */
.admin-replacements { display: grid; gap: var(--sp-2); min-width: 0; margin: 0; padding: 0; list-style: none; }
.admin-replacement {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "when link" "cameras cameras";
  align-items: baseline; gap: 0 var(--sp-3); min-width: 0;
}
.admin-replacement-when { grid-area: when; display: flex; flex-wrap: wrap; gap: 0 var(--sp-2); min-width: 0; }
.admin-replacement-time { font-variant-numeric: tabular-nums; }
.admin-replacement-role { color: var(--ink-2); }
.admin-replacement-cameras { grid-area: cameras; min-width: 0; overflow-wrap: anywhere; }
.admin-replacements-dd .btn.link.admin-replacement-link { grid-area: link; min-height: 0; padding: 0; }
@media (max-width: 720px), (pointer: coarse) {
  .admin-replacements-dd .btn.link.admin-replacement-link { min-height: var(--control-h-touch); }
}

/* photo-counts T002 */
/* Photo counters are two independent quantities, never stacked. */
.photo-counts-chart { grid-column: 1 / -1; }
.photo-counts-plot { width: 100%; min-width: 0; }
.photo-counts-svg { display: block; width: 100%; height: auto; touch-action: pan-y; }
.photo-counts-group { outline: none; cursor: pointer; }
.photo-counts-group:focus > rect:first-of-type { stroke: var(--series-1); stroke-width: 2; }
.photo-counts-readout { min-height: 3em; overflow-wrap: anywhere; }

/* Detailed status first, then seven compact numeric readings. */
.status-tiles {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 10px;
}
.status-summary-tiles,
.status-reading-tiles {
  display: grid;
  gap: 10px;
  align-items: stretch;
}
.status-summary-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.status-reading-tiles { grid-template-columns: repeat(7, minmax(0, 1fr)); }
.status-summary-tiles > .tile {
  min-height: 114px;
  padding: 12px 16px;
}
.status-reading-tiles > .tile { min-height: 86px; padding: 12px; }
.status-summary-tiles > .voltage-tile { grid-column: auto; }
.status-summary-tiles > .voltage-tile[open] { grid-column: 1 / -1; }
.status-tiles .tile .k { line-height: 1.5; }
.status-tiles .tile .v { margin-top: 4px; }
.status-tiles .voltage-status { margin-top: 6px; }
.status-tiles .power-read { margin-top: 6px; font-size: var(--fs-xs); overflow-wrap: normal; }
.status-tiles .power-read-relative,
.status-tiles .power-read-stamp { display: block; }
.status-tiles .power-read-stamp { margin-top: 2px; font-variant-numeric: tabular-nums; }
@media (max-width: 1100px) {
  .status-reading-tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
  .status-summary-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .status-summary-tiles > .voltage-tile { grid-column: 1 / -1; }
  .status-reading-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 360px) {
  .status-summary-tiles { grid-template-columns: minmax(0, 1fr); }
}

/* overview-map-v2 T08 */
/* 总览地图的外框、相机标记、气泡和「N 台相机未设置位置」那一行（pages/overview.js、lib/map_markers.js）。
   只用令牌，不写颜色值，深色主题跟着变量走；overview-map-v1 T08 那一段一条不改，这里覆盖。
   - 槽里放外框 .overview-map-wrap：它是气泡定位的参照（position: relative），外边距从 .overview-map
     挪到外框上。外框下面紧跟着「未设置位置」那一行时，外框只留 --sp-2，这一行像图注一样贴着地图。
   - 标记是 Google 地图里的节点（AdvancedMarkerElement 的 content），选择器挂在 .overview-map 下面，
     字体、颜色都自己写明，不继承 Google 给地图容器的 Roboto 11px。样子取卡片的底色和细边框，胶囊形，
     浮在底图上加 --shadow-pop。单台：圆点（.status-dot，在线 --good，其余保持 --muted）+ 名称，名称
     超过约 12em 省略；合并：至少 28px 的圆或胶囊，台数 600、等宽数字，中性色，不取某一台的状态色。
     键盘聚焦与全站一样是 2px --accent 焦点环。标记设了 gmpClickable，拿焦点的是外层的
     AdvancedMarkerElement（content 只是它里面的胶囊）：焦点环画在胶囊上，外层自己不再画一圈。
     窄屏 / 触屏高度放到 32px，好点。
   - 气泡和 .cam-card 同一套底色、边框、圆角，另加 --shadow-pop；绝对定位在外框里、压在地图上面，
     left / top / max-height 由标记图层写在行内（placeBubble：靠边时换到下方，不平移地图）。宽度按内容，
     最宽 300px，且左右各留 8px（16px = lib/map_markers.js BUBBLE_INSET 的两倍）；内容多时在气泡里滚动。
     里面的 .readings、.voltage-status、.online-status 都是卡片的规则，这里只排头部、地址、链接和合并
     列表。文字链与全站 .btn.link 同一个样子（--ink-2、下划线，悬停 --ink）。合并列表三列对齐（名称、
     在线徽标、电压），行与行之间一道 --border 细线；行不加悬停底色。合并气泡里相机行排在共同地址
     前面显示（只是显示顺序，DOM 和读屏顺序不变）：标记在地图中间时气泡上下都只有一百来像素，
     地址占三行，先显示地址的话 390 宽时一行相机都看不到。用户 10-06 确认保持地址放在下面。 */
.overview-map-wrap { position: relative; min-width: 0; margin: 0 0 var(--gap); }
.overview-map-wrap > .overview-map { margin: 0; }
.overview-map-slot:has(+ .overview-map-missing:not([hidden])) > .overview-map-wrap { margin-bottom: var(--sp-2); }
.overview-map-missing { margin: 0 0 var(--gap); font-size: var(--fs-md); overflow-wrap: anywhere; }
.overview-map-missing-link, .map-bubble-link {
  color: var(--ink-2); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap;
}
.overview-map-missing-link:hover, .map-bubble-link:hover { color: var(--ink); }

.overview-map .map-marker {
  display: inline-flex; align-items: center; gap: var(--sp-1);
  min-height: 28px; max-width: 16em; margin: 0; padding: 0 var(--sp-2);
  background: var(--surface); color: var(--ink);
  border: 1px solid var(--border); border-radius: var(--radius-pill); box-shadow: var(--shadow-pop);
  font: 400 var(--fs-sm)/1.2 var(--font); white-space: nowrap; text-decoration: none; cursor: pointer;
}
.overview-map .map-marker:hover, .overview-map .map-marker-group[aria-expanded="true"] { border-color: var(--muted); }
.overview-map .map-marker:focus-visible,
.overview-map :focus-visible > .map-marker { outline: 2px solid var(--accent); outline-offset: 2px; }
.overview-map :focus-visible:has(> .map-marker) { outline: none; }
.overview-map .map-marker-single[data-level="good"] > .status-dot { background: var(--good); }
.overview-map .map-marker-name { min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; }
.overview-map .map-marker-group {
  justify-content: center; min-width: 28px; font-weight: 600; font-variant-numeric: tabular-nums;
}
@media (max-width: 720px), (pointer: coarse) {
  .overview-map .map-marker { min-height: 32px; }
  .overview-map .map-marker-group { min-width: 32px; }
}

.map-bubble {
  position: absolute; z-index: 2; left: 0; top: 0;
  width: max-content; max-width: min(300px, calc(100% - 16px));
  overflow: auto; overscroll-behavior: contain; padding: var(--sp-3);
  background: var(--surface); color: var(--ink);
  border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-pop);
  font-size: var(--fs-md);
}
.map-bubble-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); min-width: 0; }
.map-bubble-title { min-width: 0; font-size: var(--fs-base); overflow-wrap: anywhere; }
.map-bubble-head > .online-status { flex: none; white-space: nowrap; }
.map-bubble .map-bubble-seen { margin: var(--sp-1) 0 0; }
.map-bubble-address { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-sm); overflow-wrap: anywhere; }
.map-bubble-link { display: inline-block; margin-top: var(--sp-2); }
.map-bubble[data-kind="group"] { display: flex; flex-direction: column; }
.map-bubble[data-kind="group"] > .map-bubble-list { order: 1; }
.map-bubble[data-kind="group"] > .map-bubble-address { order: 2; }
.map-bubble-list {
  display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 0 var(--sp-3);
  margin: var(--sp-2) 0 0; padding: 0; list-style: none;
}
.map-bubble-row {
  grid-column: 1 / -1; display: grid; align-items: baseline;
  grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: subgrid;
  padding: var(--sp-1) 0; border-top: 1px solid var(--border);
}
.map-bubble-row-name {
  min-width: 0; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; overflow-wrap: anywhere;
}
.map-bubble-row > .online-status { white-space: nowrap; }
.map-bubble-row-voltage {
  color: var(--ink-2); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap;
}

/* overview-map-mobile T01 */
/* 手机上（≤640px，和地图高度同一个断点；lib/map.js 的 NARROW_MEDIA_QUERY）标记只画圆点和台数（用户
   10-06：「手机上只显示圆点和数字」）。单台标记藏起名称，成为 22px 的圆，里面是 10px 的状态圆点；合并
   标记是 28px 高的台数胶囊。名称照旧在 aria-label 和气泡里。看得见的部分画小，外面用 ::before 套一圈
   看不见的 44px 点击区（--control-h-touch），挤在一起的点也好点中。从宽屏拖到窄屏不用刷新，标记跟着
   媒体查询立刻切换（地图本身不重新缩放，刷新不重缩放是老规矩）。尺寸改了要同步 lib/map.js 的
   NARROW_MARKER（tests_js/map.test.mjs 会对着这一段核对）。 */
@media (max-width: 640px) {
  .overview-map .map-marker { position: relative; }
  .overview-map .map-marker::before {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: var(--control-h-touch); height: var(--control-h-touch);
    transform: translate(-50%, -50%); border-radius: 50%;
  }
  .overview-map .map-marker-single {
    justify-content: center; width: 22px; height: 22px; min-height: 0; padding: 0; gap: 0; border-radius: 50%;
  }
  .overview-map .map-marker-single > .map-marker-name { display: none; }
  .overview-map .map-marker-single > .status-dot { width: 10px; height: 10px; }
  .overview-map .map-marker-group { height: 28px; min-height: 0; min-width: 28px; padding: 0 var(--sp-1); }
}

/* ux-simplify-schedule-v1 T01 */
/* 控制台体验简化 v1 的公共部分：「?」帮助气泡（lib/help_tip.js）、线条图标（lib/icons.js）、卡片标题行、
   统一折叠样式。拍摄计划页（pages/schedule.js）和设备状态页的拍照数量图先用，别的页面以后再换。
   后面几个任务（T03 起）各接一小段，段头同样写 ux-simplify-schedule-v1 加任务号，所有小段连在一起放在文件最后。
   颜色只用 :root 里的令牌，字号只用 --fs-*，间距用 --sp-*；22px（按钮直径）、44px（--control-h-touch）、
   320px（气泡最大宽）、10px（气泡圆角）是需求直接给的数。 */

/* 图标：描边跟着文字颜色走，尺寸由 <svg> 的 width / height 属性决定（icon(name, { size })）。 */
.ui-icon { display: inline-block; flex-shrink: 0; vertical-align: -0.15em; }

/* 「?」按钮：直径 22px 的圆。平时 --muted 描边和文字，悬停、聚焦、打开时 --accent-text。
   不带 .btn：全站触控规则会把 .btn 撑到 44px 高，标题行跟着变高。 */
.help-tip { position: relative; display: inline-flex; flex-shrink: 0; vertical-align: middle; }
.help-tip-button {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; margin: 0; padding: 0;
  border: 1px solid var(--muted); border-radius: 50%; background: transparent; color: var(--muted);
  font-family: inherit; font-size: var(--fs-sm); font-weight: 600; line-height: 1; cursor: pointer;
}
.help-tip-button:focus-visible,
.help-tip-button[aria-expanded="true"] { color: var(--accent-text); border-color: var(--accent-text); }
/* 触屏点一下会留下悬停态，关掉以后「?」还是蓝的；只在真有悬停的设备上做悬停色。 */
@media (hover: hover) {
  .help-tip-button:hover { color: var(--accent-text); border-color: var(--accent-text); }
}

/* 气泡（宽屏）：按钮下方的浮层，absolute，跟着页面一起滚。align="end" 右缘对齐按钮，"start" 左缘对齐；
   离视口边不足 8px 时 help_tip.js 把挪动量写进 --help-tip-shift。hidden 是 display: none !important，
   transition 不会播放，所以淡入用 @keyframes。z-index 30：高于粘性保存条和顶栏的 5，低于提示条的 40
   和弹窗、抽屉的 60。 */
.help-tip-panel {
  position: absolute; top: calc(100% + var(--sp-1)); right: 0; z-index: 30;
  width: max-content; max-width: min(320px, calc(100vw - 16px));
  padding: var(--sp-3); background: var(--surface-raised); border: 1px solid var(--border);
  border-radius: 10px; box-shadow: var(--shadow-pop);
  color: var(--ink-2); font-size: var(--fs-md); font-weight: 400; line-height: 1.6; text-align: left;
  overflow-wrap: anywhere; translate: var(--help-tip-shift, 0px) 0;
  animation: help-tip-in 120ms ease-out;
}
.help-tip[data-align="start"] > .help-tip-panel { left: 0; right: auto; }
@keyframes help-tip-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.help-tip-body > p, .help-tip-list { margin: 0; }
.help-tip-body > * + * { margin-top: var(--sp-2); }
.help-tip-list { padding-left: 1.25em; }
.help-tip-list > li + li { margin-top: var(--sp-2); }

/* 窄屏专用的部分，宽屏不画：遮罩、底部面板顶上那一行（标题 + 完成）。 */
.help-tip-backdrop, .help-tip-head { display: none; }
.help-tip-title { color: var(--ink); font-size: var(--fs-base); font-weight: 600; }
.help-tip-done {
  margin: 0; padding: 0 var(--sp-2); border: 0; background: none; color: var(--accent-text);
  font-family: inherit; font-size: var(--fs-base); font-weight: 600; line-height: 1.6; cursor: pointer;
}

/* 手机和触屏：「?」外面套一圈看不见的 44px 点击区（--control-h-touch），看得见的圆仍是 22px，
   标题行的高度不变（::before 是 absolute，不占位）。和全站的触控惯例一样，粗指针（平板）也生效。 */
@media (max-width: 640px), (pointer: coarse) {
  .help-tip-button::before {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: var(--control-h-touch); height: var(--control-h-touch);
    transform: translate(-50%, -50%); border-radius: 50%;
  }
}

/* ≤640px（和 lib/map.js 的 NARROW_MEDIA_QUERY、help_tip.js 的 NARROW_QUERY 同值）：气泡改成从屏幕底部
   弹出的面板，上两角圆角、半透明遮罩（--overlay）、最高 70vh、内容多了在面板里滚。z-index 55，遮罩 54，
   都高于提示条的 40，低于弹窗和抽屉的 60。顶上一行（标题 + 完成）粘在面板顶部，滚动时不跑掉；
   padding-bottom 留出系统手势区。 */
@media (max-width: 640px) {
  .help-tip-backdrop { display: block; position: fixed; inset: 0; z-index: 54; background: var(--overlay); }
  /* 点遮罩关闭后，遮罩再留一小会儿（help_tip.js 的 data-closing）吞掉手指抬起后的 click，看不见，只挡点击。 */
  .help-tip-backdrop[data-closing] { background: transparent; }
  .help-tip-panel {
    position: fixed; top: auto; right: 0; bottom: 0; left: 0; z-index: 55;
    width: auto; max-width: none; max-height: 70vh; overflow-y: auto; overscroll-behavior: contain;
    padding: 0 var(--sp-4) calc(var(--sp-4) + env(safe-area-inset-bottom));
    border-width: 1px 0 0; border-radius: 10px 10px 0 0; translate: none;
    animation-name: help-tip-sheet-in; animation-duration: 160ms;
  }
  .help-tip[data-align="start"] > .help-tip-panel { left: 0; right: 0; }
  .help-tip-head {
    display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
    position: sticky; top: 0; z-index: 1; min-height: var(--control-h-touch);
    margin: 0 calc(-1 * var(--sp-4)) var(--sp-2); padding: 0 var(--sp-4);
    background: var(--surface-raised); border-bottom: 1px solid var(--border);
  }
  .help-tip-done { min-height: var(--control-h-touch); }
  /* 「完成」占满顶上那一行，行贴着面板上沿（面板 overflow-y: auto 会裁掉越界的部分），所以焦点环画在按钮里面，
     否则键盘打开面板、焦点落到「完成」时环的上边被裁掉，只剩三条边。 */
  .help-tip-done:focus-visible { outline-offset: -2px; border-radius: var(--radius-sm); }
}
@keyframes help-tip-sheet-in { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }

/* 卡片标题行：标题（h2）在左，「?」紧跟其后靠右，卡片头部有操作按钮时按钮排在「?」右边；
   DOM 顺序 h2 → 「?」→ 操作按钮。垂直居中，间距 --sp-2。h2 自己带 margin-right: auto 把后面的推到右边。 */
.card-head { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); }
.card-head > h2 { margin: 0; margin-right: auto; min-width: 0; }

/* 统一折叠样式（给 <details> 用）：summary 去掉浏览器自带的三角，整行可点，右边是 chevron
   （页面在 summary 里放 icon("chevron", { className: "disclosure-chevron" })），展开时转 180°。 */
.disclosure > summary { display: flex; align-items: center; gap: var(--sp-2); list-style: none; cursor: pointer; border-radius: var(--radius-sm); }
.disclosure > summary::-webkit-details-marker { display: none; }
.disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.disclosure > summary > .disclosure-chevron { margin-left: auto; color: var(--muted); transition: transform 150ms ease; }
.disclosure[open] > summary > .disclosure-chevron { transform: rotate(180deg); }
@media (max-width: 720px), (pointer: coarse) {
  .disclosure > summary { min-height: var(--control-h-touch); }
}

/* 减少动态效果：气泡不淡入，折叠的 chevron 直接翻过去。放在本段最后，盖过上面的动画声明。 */
@media (prefers-reduced-motion: reduce) {
  .help-tip-panel { animation: none; }
  .disclosure > summary > .disclosure-chevron { transition: none; }
}

/* ux-simplify-schedule-v1 T02 */
/* 设备状态页「拍照数量」图的「?」气泡（pages/photo_counts_chart.js 用公共的 helpTip）。气泡里的四块说明
   （3 条 ul.meta、两个 p.sub、当前时段）原来是页面上的普通块，自带 ul 的默认外边距和 .chart .sub 的底边距；
   放进气泡后外边距收掉，块与块之间统一 --sp-2，不然气泡顶部和底部比四周松一截；列表项之间同样 --sp-2，
   和公共的 .help-tip-list 一致（3 条各有好几行，只靠圆点分不清）。
   文字颜色和字号不动：.meta、.sub 仍是 --muted 加 --fs-sm。只作用在 .photo-counts-chart 里。 */
.photo-counts-chart .photo-counts-help-content > * { margin: 0; }
.photo-counts-chart .photo-counts-help-content > * + * { margin-top: var(--sp-2); }
.photo-counts-chart .photo-counts-help-content > ul { padding-left: 1.25em; }
.photo-counts-chart .photo-counts-help-content > ul > li + li { margin-top: var(--sp-2); }

/* ux-simplify-schedule-v1 T03 */
/* 拍摄计划页顶部状态条（pages/schedule.js buildForm）。
   - 「下发说明」的「?」放在卡片右上角：绝对定位，不占状态条那张网格里的一行。DOM 里它是状态条的第一个孩子
     （Tab 先到它）。第一行（拍摄计划 vN …）右边留出它的位置，窄屏折行时也压不到字。
   - 固件行前面的 info 是线条图标（lib/icons.js），仍包在 .notice-icon 里，由上面 T21 段按"图标一格、文字一格"排；
     尺寸由 .ui-icon 定，.notice-icon 的字号不动，颜色跟着 .st-device .notice-icon 走。
   - 三行以图标开头的行（下发、相机当前值、相机固件）：图标那一格固定 --sp-4（16px，与线条图标同宽），三行的文字
     左缘落在同一条线上。T21 段那条是 auto：✓ 10px、ⓘ 15px、线条图标 16px，三行文字的起点各差几个像素。
     选择器用子代组合符，和 T21 段那条（后代组合符）分开，不去改 T21 段的规则。
   - 「查看相机读回的设置」是 .disclosure（公共折叠样式，在 T01 段）：summary 整行可点，chevron 紧跟在文字右边。 */
.schedule-page .settings-status { position: relative; }
.schedule-page .settings-status > .help-tip { position: absolute; top: var(--sp-4); right: var(--sp-4); }
.schedule-page .settings-status > .help-tip + .st-row { padding-right: calc(22px + var(--sp-3)); }
.schedule-page .settings-status > .st-row:has(> .notice-icon:first-child) { grid-template-columns: var(--sp-4) minmax(0, 1fr); }
/* 读回折叠的 chevron 紧跟在文字右边（间距是 summary 自带的 gap: --sp-2），不推到整行最右端：T01 段的公共规则
   把它 margin-left: auto 推到最右，1440 宽下离文字近千像素，折叠看起来像一行普通灰字、不像能展开，还正好落在
   右上角「?」的正下方，像它的附属（美观测试 F1）。summary 仍是整行宽、整行可点，展开时照旧转 180°；
   只改本页这一处，不动 T01 的公共规则（卡片级折叠以后可能就要 chevron 靠最右）。 */
.schedule-page .settings-status .schedule-readback > summary > .disclosure-chevron { margin-left: 0; }

/* ux-simplify-schedule-v1 T04 */
/* 拍摄计划页表单卡（pages/schedule.js buildForm）：拍照间隔、拍摄时段、上传时段三张卡的标题行是公共的 .card-head
   （T01 段：h2 在左，「?」紧跟其后靠右），卡顶卡底原来常驻的说明收进「?」；时间条图例只留两个色块加字。
   - 时段卡「?」里是一整块（.schedule-window-help）：两条说明的列表、小标题「固件规则」、服务端规则清单。helpTip 只给
     .help-tip-body 的直接孩子之间留间距，这一块里的三段要自己留：段与段之间 --sp-2，和列表项之间一致；小标题比正文
     重一点（--ink、600，正文是气泡的 --ink-2），挨着它下面的清单收到 --sp-1，一眼看出清单属于谁。字号、行高继承气泡
     （--fs-md、1.6）。
   - 前面 T21 段的 .schedule-page .schedule-rules … 规则（固件规则折叠卡）现在没有元素用了，不删：前面的规则不改，
     test_console_css 的 check_t21_moved_rules 还要它在。 */
.schedule-window-help > * { margin: 0; }
.schedule-window-help > * + * { margin-top: var(--sp-2); }
.schedule-help-subhead { color: var(--ink); font-weight: 600; }
.schedule-help-subhead + .schedule-help-rules { margin-top: var(--sp-1); }
/* 三张卡标题行里的「?」气泡（拍照间隔、拍摄时段、上传时段，以及以后往标题行加的）每一条都用 text-wrap: pretty：
   拍照间隔那条「勾选「关闭定时拍照」后相机不按间隔拍照。」在 320px 宽的气泡里原来折成「…不按间隔拍」「照。」，
   最后一行只剩一个字还拆了「拍照」（美观测试 F1）。状态条「下发说明」的气泡不在 .card-head 里，不受影响。 */
.schedule-page .card-head .help-tip-body li { text-wrap: pretty; }

/* ux-simplify-schedule-v1 T05 */
/* 拍摄计划页功耗设置卡（pages/schedule.js buildForm，.schedule-power-settings）：标题行是公共的 .card-head（T01 段：h2 在左，
   「?」靠右），卡顶、勾选框后面、卡底原来常驻的说明都收进「?」。外面只剩两个勾选框，和一行 warning 级提示（v2.4 且
   modes_restored === false 时才显示）：沿用 .notice.warning（左色条 --warning，⚠ 取 --warning-text），和时钟卡的熔断提示条
   （.schedule-clock-blocked）同一种，上边距同样 --sp-3、没有下边距（卡片自己有内边距）。 */
.schedule-power-settings .schedule-power-reset-warn { margin: var(--sp-3) 0 0; }

/* ux-simplify-schedule-v1 T06 */
/* 拍摄计划页相机时钟卡（pages/schedule.js createClockCard，.schedule-clock）：标题行是公共的 .card-head（T01 段：h2 在左，
   「?」靠右），卡上原来常驻的五处说明收进「?」；外面是结论、读数、生效时区一行、相机时区输入框、立即校时加一句自动校时短句、消息。
   - 生效时区那一行（.schedule-clock-zone-line）是「生效时区：新加坡时间（UTC+8）· 全局默认」：名字是正文，来源半句
     （.schedule-clock-zone-source，「· 全局默认」「· 相机设置」）弱化成 --muted，字号不变；前面留 --sp-1 的空隙（字里没有空格），
     窄屏折行时「· 全局默认」整个挪到下一行，不拆开。
   - 操作行（.schedule-clock-actions，.form-row）：立即校时按钮里是 clock 图标加文字（图标与文字的间距是 .btn 自带的 gap），
     灰着时的原因（.schedule-clock-hint，可点时是空的、:empty 隐藏），再是自动校时短句（.schedule-clock-auto-short：
     refresh 图标加一句 .meta 灰字）。短句是 inline-flex：图标在第一行行首、文字自己折行，图标不会单独占一行；图标 16px，
     比 .meta 的一行（13px × 1.6）矮 4.8px，往下挪一半（2.4px）与第一行文字垂直居中（选择器写 svg 而不是 .ui-icon：test_console_css 的
   「JS 里的每个 class 都有规则」守卫按 .ui-icon 有没有规则判，别让本段的规则替它顶了数）。熔断提示条显示时短句带 hidden，
     [hidden] 是 display: none !important，盖得过下面的 inline-flex。 */
.schedule-clock-zone-source { margin-left: var(--sp-1); color: var(--muted); white-space: nowrap; }
.schedule-clock-auto-short { display: inline-flex; align-items: flex-start; gap: var(--sp-1); min-width: 0; }
.schedule-clock-auto-short > svg { margin-top: calc((1.6em - 16px) / 2); }
/* 修复轮次 1（布局测试 F1）：>480px 时，时区输入框的 flex-basis 从 14em 提到 22em（= 它自己的 max-width，330px）。
   placeholder 现在是「跟随全局默认（新加坡时间 UTC+8）」，按 15px 量要 248.5px，加内边距和边框要约 275px；721–880px 之间
   （768、800、820 都中招）输入框和「保存时区」挤在第二列的同一行，输入框被压到 14em 左右（230–250px），括号和「+8」被裁掉，
   读起来像「UTC」时区。基础宽度够了，第二列放不下「输入框 + 保存时区」时 .form-row 本来就 flex-wrap，「保存时区」自己折到下一行；
   放得下的宽度（≥ 约 900px）仍是同一行（灰着时旁边的「未修改」在 900px 附近会折到下一行）。≤480px 那条规则（flex: 1 1 100%）不动，所以放在 min-width: 481px 里，免得后写的
   规则把它盖掉。
   text-overflow: ellipsis：默认时区名不固定（没有中文名的直接显示 IANA 名，或窄到 320px——那里输入框占满一行也只有 262px，
   placeholder 差 4.5px，不在必测视口里，不为它改内边距），真放不下时用「…」显示被截断，不静悄悄地把括号裁掉；完整的名字在「?」
   第 4 条里。 */
@media (min-width: 481px) {
  .schedule-clock-zone-input { flex-basis: 22em; }
}
.schedule-clock-zone-input { text-overflow: ellipsis; }

/* ux-simplify-schedule-v1 T07 */
/* 拍摄计划页电源状态卡和 STM32 命令卡（pages/schedule.js createPowerCard、createSystemCard）：标题行都是公共的 .card-head（T01 段：
   h2 在左，「?」靠右），原来常驻的说明收进「?」。
   - 电源状态卡：卡底那句 p.note（服务端 POWER_UNVERIFIED_NOTE）进了「电源状态说明」。「?」里的段落仍带 .schedule-power-note，
     前面的规则（margin / max-width）排在 T01 段的 .help-tip-body > p 之前、也不比它具体，不会撑出气泡，不用再写。
   - STM32 命令卡：按钮上方的介绍句和 dl 后果组（.schedule-system-intro、.schedule-system-notes 前面的规则现在没有元素用了，不删：
     前面的规则不改）去掉。每条命令一格（.schedule-system-command）：按钮加一句短后果（.schedule-system-consequence，.meta 灰字，
     次要信息），灰着时的原因（.schedule-system-hint）排在最后；可点时那一格是空的，:empty 隐藏，不留下空隙。
       >480px：整块是两列的网格（按钮列 max-content，后果列占满），两个按钮同宽、后果文字左缘对齐；格子本身 display: contents，
       让按钮和后果直接进网格；原因跨两列，自己一行（前面 T11 段给它的 max-width: 24em 是它和按钮挤在一行时用的，现在去掉，不然
       「已有一条系统命令在排队或执行（重启 STM32），等它结束或取消后再发」会折成两行，还断在「等 / 它」中间）。
       ≤480px：前面 T11 段的规则让按钮占满一行（flex: 1 1 100%、44px 高），格子是可折行的 flex，后果自然排到按钮下方；每格占满一行。
       按钮与自己的后果之间 --sp-1，格与格之间 --sp-3（比 --sp-1 大一档，一眼看出哪句属于哪个按钮，不会挨着下一个按钮）。
   - 「STM32 命令说明」里第 2、3 条是一块（.schedule-system-help-item）：加粗小标题（--ink、600，与时段卡「?」里的小标题 .schedule-help-subhead
     同一档）接那一句后果，补充几条（.schedule-system-lines，前面的规则：缩进 1.2em、上边 --sp-1）排在下面，条与条之间 --sp-1。 */
.schedule-system-actions { row-gap: var(--sp-3); }
.schedule-system-command { display: flex; flex: 1 1 100%; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); min-width: 0; }
.schedule-system-consequence { min-width: 0; overflow-wrap: anywhere; }
.schedule-system-hint:empty { display: none; }
@media (min-width: 481px) {
  .schedule-system-actions { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; gap: var(--sp-2) var(--sp-3); }
  .schedule-system-command { display: contents; }
  .schedule-system-hint { grid-column: 1 / -1; max-width: none; }
}
.schedule-system-help-head { color: var(--ink); font-weight: 600; }
.schedule-system-help-item > .schedule-system-lines { margin-top: var(--sp-1); }
.schedule-system-lines > li + li { margin-top: var(--sp-1); }

/* ux-simplify-schedule-v1 T08 */
/* 拍摄计划页整页收尾。
   一、T06 往页面里放了两个只在脚本和测试里用到、当时没有自己规则的 class，这里补上，好让「新加的每个 class 在 console.css 里
   都有规则」（需求 §5.1）成立，由 test_console_css 的 ux8 守卫盯着：
   - .schedule-clock-sync-text：「立即校时」按钮里文字那一格（图标后面的 span）。不折行：按钮再窄，图标也不会落到两行文字
     中间；按钮 ≤480px 占满一行、宽屏按内容撑开，平时没有可见变化。
   - .schedule-clock-zone-name：生效时区那一行里的名字（「新加坡时间（UTC+8）」）。明写成正文色 --ink，与旁边来源半句
     （.schedule-clock-zone-source，--muted）成对；以后谁把整行改灰，名字不会跟着变淡。
   这两条不改变现在看到的样子（1440 / 768 / 375 / 320 宽、深浅主题的位置与颜色，补规则前后一致）。
   二、标题行到第一块内容的距离整页统一：8 张卡里 7 张是 8px（.card-head 的下边距 --sp-2），STM32 命令卡因为前面 T11 段给
   操作区写了 margin-top: --sp-3，两个外边距叠在一起取大的，成了 12px。可编辑账号下操作区紧跟在标题行后面，把它的上边距收掉，
   与别的卡一致；只读账号没有操作区，标题仍是普通 h2，不受影响。
   三、「?」气泡在宽屏向上展开（布局测试 F1）：气泡默认在按钮下方（T01 段）。页面最后一张卡（STM32 命令）的「?」，滚到底也没法
   再让它往上，下方放不下，气泡超出视口下沿，还把文档撑长、露出一截没有应用外框的底色。lib/help_tip.js 打开后量一下：下方放不下
   而上方放得下，就在 .help-tip 上写 data-placement="top"，这里把气泡贴到按钮上沿（bottom 取代 top，间隙同样 --sp-1）。
   动画沿用 T01 段的 help-tip-in（淡入加上移 4px），不另写 animation，reduced-motion 的关闭照样管用。
   两边都放不下（视口矮、气泡长，例如 1024×768 下旧固件的「STM32 命令说明」438px 高，按钮上下都只有 350–375px）时，脚本选空间大的
   一边，把气泡高度封顶写进 --help-tip-max-h，这里让气泡按它封顶、内容在气泡里滚（和窄屏底部面板 max-height: 70vh 同一个做法）；
   没写这个变量时是 none，气泡照常按内容撑开，不出滚动条。窄屏是底部面板，不用这两条（≤640px 脚本不写这两个属性，规则也只在
   ≥641px 生效）。 */
.schedule-clock-sync-text { white-space: nowrap; }
.schedule-clock-zone-name { color: var(--ink); }
.schedule-system .card-head + .schedule-system-actions { margin-top: 0; }
@media (min-width: 641px) {
  .help-tip[data-placement="top"] > .help-tip-panel { top: auto; bottom: calc(100% + var(--sp-1)); }
  .help-tip-panel { max-height: var(--help-tip-max-h, none); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }
}

/* status-versions：设备状态页第一行三张卡片底部的版本 / 型号（pages/status.js tileFoot，用户 10-07）。
   电池电压卡是 STM32 固件版本，相机电源卡是相机型号，CM4 电源卡是 CM4 软件版本。
   - 三张卡片在网格里本来就等高；卡片排成纵向 flex，.status-foot 用 margin-top: auto 落到底部，三行对齐。电压卡是 <details>，
     这一行在 <summary> 里：收起时 summary 撑满卡片、自己也排成纵向 flex；展开后电压卡独占一行，用不着对齐，summary 不撑开，
     说明紧跟在下面。不支持 <details> 用 flex 的浏览器里这一行跟在结论下面，只是不对齐。
   - 外层用 padding-top 留出与上面内容的间距（margin-top 让给 auto），里层画分隔线：标签 --muted 在左、值 --ink 在右；放不下
     时值换到下一行，长型号在任意处断开，不撑破卡片。缺值（读取失败 / 还没有读到 / 未上报机型）带 .empty-value，是 --muted。
   - ≤640px 两张电源卡并排、每张只有一百多像素宽：标签和值一律上下排。否则「相机型号 还没有读到」放得下一行、「CM4 软件版本
     还没有读到」要折成两行，两张卡底部对齐后分隔线就一高一低。
   这一段的标题故意不写成「名字 Tnn」：test_console_css 的 ux 守卫要求 ux-simplify-schedule-v1 的分段是文件里最后几个
   「名字 Tnn」分段。 */
.status-summary-tiles > .tile { display: flex; flex-direction: column; }
.status-summary-tiles > .voltage-tile:not([open]) > .voltage-summary { display: flex; flex: 1 0 auto; flex-direction: column; }
.status-foot { display: block; margin-top: auto; padding-top: 10px; }
.status-foot-line {
  display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
  gap: 2px var(--sp-2); padding-top: var(--sp-2); border-top: 1px solid var(--border);
  font-size: var(--fs-xs); line-height: 1.5;
}
.status-foot-k { color: var(--muted); letter-spacing: .04em; }
.status-foot-v { min-width: 0; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.status-foot-v.empty-value { color: var(--muted); }
@media (max-width: 640px) {
  .status-foot-line { flex-direction: column; align-items: flex-start; }
}

/* ux-helptip-fix-v1 */
/* 「?」帮助气泡的修复（lib/help_tip.js）。
   ② 窄屏底部面板的滚动锁：面板开着时背后的页面不能滚。help_tip.js 在 <html> 上写 data-help-tip-scroll-lock，关闭、卸载、
   跨 640px 时去掉；这里据此让文档不滚。overflow: hidden 不改滚动位置，所以关了以后页面停在原处，不用记位置、不用 scrollTo。
   用属性、不用 class：<html> 上原来有什么 class 都不受影响，去掉属性就和上锁前一模一样。前面的 html, body 规则只写了
   overflow-x（hidden / clip），这里写简写、盖掉它，解锁后回到原样。有经典滚动条的桌面浏览器窄窗口里，滚动条会随 overflow: hidden 消失，
   help_tip.js 另在 <html> 上写同宽的 padding-right 顶住（内联样式，量不到就不写），背后的内容不位移。
   遮罩只在这时多一条 touch-action: none：不支持 overflow 锁住触屏滚动的老 iOS 上，手指在遮罩上拖动也不会带动页面。
   这两条只管滚动，不画任何颜色、阴影或边框，不改「?」、气泡和面板的外观。 */
html[data-help-tip-scroll-lock] { overflow: hidden; }
html[data-help-tip-scroll-lock] .help-tip-backdrop { touch-action: none; }

/* ux-simplify-energy-v1 */
/* 控制台体验简化 v1：功耗页（pages/energy.js）。公共的「?」气泡和卡片标题行在上面 ux-simplify-schedule-v1 T01 段，
   这里只写本页自己的：电池卡片、耗电图卡和整页收尾往本段里接，不再另写段头（上面 ux-simplify-schedule-v1 各段是文件最后的段，
   test_console_css 的位置守卫按“段头以 T 加数字开头”认段，本段的小标题注释同样不以任务号开头）。
   颜色只用 :root 里的令牌，字号不另写，间距用 --sp-*。
   电池卡片（.battery-card，.tile 的 .k / .v）的标签行：「电池电压」在左，「电池说明」的「?」在右（pages/energy.js 的
   .battery-head）。.k 自己的字号、颜色、字距一概不动（还是 .tile .k：--fs-xs、--muted，和设备状态页的读数卡片一样），
   这里只管排法：垂直居中、「?」靠右、间距 --sp-2。不写 margin-bottom：下面的读数 .v 仍按 .tile .v 的 2px 紧跟标签行。
   「?」是 22px，比 .k 的一行（--fs-xs × 1.6 = 19.2px）高 2.8px，标签行因此比改版前高 2.8px，读数跟着往下 2.8px。
   不套公共的 .card-head：它带 margin-bottom: --sp-2，标签和读数之间会多出 8px。 */
.battery-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
/* 耗电图卡和四个指标（pages/energy.js）：接在上面电池卡片的后面，同一个功能的第二块。
   图卡标题行是公共的 .card-head（h2 在左，「耗电说明」的「?」在右），这里不再写它；原来的副标题、页底的计算方法、
   平均功率卡的小字都收进「?」，页面上不再有这几个元素（前面 .chart .sub、.energy-note 的旧规则留着，现在没有元素用，
   以后专门清理）。
   一、四个指标按同一行里最高的一张拉伸。全站的 .tiles 是 align-items: start（设备状态页：电压卡多一行字，不要把同一行
   的卡一起撑高），功耗页原来四张卡都有一行小字，高度本来就一样；平均功率卡去掉小字以后会矮一截（94px 对 115px），所以只在
   本页改回拉伸：同一行的卡等高，读数 .v 的上沿本来就对齐（都在 .k 下面 2px）。
   二、读数行（.energy-readout）没选中柱子时是空的，选中时是一条详情，页面不能因此跳高：
   - 前面的规则 min-height 只有 1.3em（13px 的 1.3 倍是 16.9px），比一行字（13px 的 1.6 倍是 20.8px）还矮，空着和有字差约 4px。
     这里把行高写明是 1.6，min-height 定成这个宽度下最长一条详情占的行数（用 em 写，跟着字号走）；
   - 最长的一条是正在进行的那根，例如「2026/10/07 11:00–12:00（进行中） · 实测 0.33 Wh · 估算 0.50 Wh · 平均 2.23 W · 覆盖 65%」，
     13px 下约 550px 宽，换成千瓦时的写法约 570px。读数行的内容宽度：视口宽大于 720px（侧栏在）时是视口宽减 274px，
     720px 及以下是视口宽减 58px。实测的行数：860px 及以上、641–720px 是 1 行；721–859px、640px 及以下是 2 行；
     340px 及以下是 3 行（320px 实测 3 行）；
   - 外边距：前面的规则是上下各 --sp-2，读数行空着时，留高外面再叠上下 8px 和卡片内边距，x 轴文字下沿到卡片边缘有 60–80px 的空白，
     比标题行加图例还高，看上去像少了一行字（美观测试 F1）。这里改成上 --sp-1、下 0：x 轴文字到读数行 11px，读数行到卡片内边距
     16px（与上面指标卡小字到卡底的距离一致）；有字时不挤，高度照样不跳。
   三、「耗电说明」宽屏气泡（320px 宽）里，第 3、5 条的末行只剩「算。」「情。」两个字（美观上的孤字）。拍摄计划页的卡片「?」
   用 text-wrap: pretty 避开（本文件前面 .schedule-page .card-head 那条），这里照同一个做法、同样限在本页图卡的标题行里，
   不碰公共的气泡样式；浏览器不认这个值时没有变化，只是回到原来的折法。 */
.energy-page .tiles { align-items: stretch; }
.energy-page .energy-readout { margin: var(--sp-1) 0 0; line-height: 1.6; min-height: 1.6em; }
@media (max-width: 640px), (min-width: 721px) and (max-width: 859px) {
  .energy-page .energy-readout { min-height: calc(1.6em * 2); }
}
@media (max-width: 340px) {
  .energy-page .energy-readout { min-height: calc(1.6em * 3); }
}
.energy-page .chart .card-head .help-tip-body li { text-wrap: pretty; }

/* ux-simplify-photos-v1 */
/* 照片页（pages/photos.js）的体验简化：解释性的说明收进卡片标题右边的「?」（lib/help_tip.js），外面只留状态和读数。
   本段是这个需求的全部样式，后面几个任务接在同一段里写，不另开段头。规则一律限定在 .photo-filters / .photos-page / .photo-tile
   之下，不碰公共的 help-tip 系列、.card-head、.ui-icon（前面 ux-simplify-schedule-v1 各段的规则不改）；颜色只用 :root 里的令牌，
   字号只用 --fs-*，间距用 --sp-*。 */

/* 筛选卡：标题行是公共的 .card-head（h2 在左，「?」在右）。「?」里的条目和拍摄计划页的「?」一样不留单字行
   （基准是 .schedule-page .card-head .help-tip-body li）。 */
.photo-filters .card-head .help-tip-body li { text-wrap: pretty; }

/* 筛选卡：覆盖率一行外面只剩一句读数（「拍摄时间：已读取 98 / 102 张」）。原来 .photo-capture-text 是 flex: 1 1 18em，把后面的
   「读取拍摄时间」推到最右边；现在句子短，按钮紧跟在字后面。放不下时仍整行换到下面，不在字中间挤。 */
.photo-filters .photo-capture-text { flex: 0 1 auto; }

/* 卡底原来的说明（p.hint）去掉以后，日期错误行后面紧跟的是覆盖率行。错误行自己带下面的 8px（padding-bottom），覆盖率行
   不再加上边距，错误出现时筛选卡还是只多出这一行本身的高度，上面的控件一动不动（PH-ui-07）。 */
.photo-filters > p.field-error:not([hidden]) + .photo-capture-row { margin-top: 0; }

/* 筛选卡：「时间异常」一行的 ⚠ 换成 16px 的 warning 线条图标（和拍摄计划页同一套），前面 T31 段加在 ::before 上的 ⚠ 字符
   去掉，免得出现两个；颜色仍是 --warning-text（图标描边跟着文字颜色走），图标与文字隔 4px。 */
.photo-filters .photo-suspect-note::before { content: none; }
.photo-filters .photo-suspect-icon { margin-right: var(--sp-1); }

/* 瓦片的时间异常标记（pages/photos.js 的 photoTile）：时间行和正常瓦片一模一样，只放括号前的时间（随宽度折行，同行等高）；
   警告标记是缩略图右上角的小徽标 .tile-warn-badge——24px 的圆，里面 16px 的 warning 线条图标，整句在它的 title 和读屏名里。
   前面 T31 段加在 .tile-time.is-suspect::before 上的 ⚠ 字符去掉（留着会有两个警告）；时间文字的颜色仍是 T31 段的 --warning-text。
   徽标离角 --sp-2：选中的 2px 描边（li::after）和键盘聚焦的 2px 焦点环（.thumb-btn 里缩 3px）都在它外面，不压到它。底色用
   --surface、描边用 --control-border，盖在照片上和缩略图还没加载的占位底色（--plane）上都看得清；图标颜色是 --warning-text，对
   --surface 浅色 5.77:1、深色 9.49:1。它盖在「查看大图」按钮上，所以光标和点击都和缩略图一致（点击由 JS 转给大图）。 */
.photos-page .tile-time.is-suspect::before { content: none; }
.photos-page .tile-warn-badge {
  position: absolute; top: var(--sp-2); right: var(--sp-2);
  display: flex; align-items: center; justify-content: center; box-sizing: border-box;
  width: var(--sp-6); height: var(--sp-6); border-radius: var(--radius-pill);
  background: var(--surface); border: 1px solid var(--control-border); color: var(--warning-text);
  cursor: zoom-in;
}

/* ux-simplify-videos-v1 */
/* 延时视频页新建卡的标题行（pages/videos.js dailyBuilder）：公共的 .card-head（ux-simplify-schedule-v1 T01 段），DOM 顺序
   h2 →「?」→「去照片页选择」（「?」在操作按钮左边，和拍摄计划页一致）。本页不新增 class，下面四条只在 .videos-page 里补公共规则管不到的地方。
   - 「去照片页选择」不折行：原先是页头下提示条里的一个按钮，现在和 h2、「?」挤在一行。≤360px 宽时 flex 会把这个按钮压到最小内容宽度，
     六个字折成两行；不折行以后由 h2 自己让出宽度（h2 带 min-width: 0，可以折成两行）。按钮仍是全站的 .btn.link（15px、下划线、--ink-2），
     触屏宽度下 44px 高（全站触控规则）不动。
   - h2 折成两行时两行等长（balance）：窄屏下「新建：每天同一时间一张」十一个字不会落下一个「张」单独一行。
   - 「?」气泡里每一条都用 text-wrap: pretty，和拍摄计划页卡片标题行的气泡一致：避免最后一行只剩一个字或一个标点
     （第 5 条「…过期后要重新创建。」在 320px 宽的气泡里原来折成「…重新创」「建。」）。
   - 选图后未读提示条里那一句（.notice .st-row，pages/videos.js drawCoverage）也等长折行（balance）：375px 宽原来折成
     「…这些照片不会被选」「中。」，最后一行只剩一个字还拆了「选中」（布局测试 F1）。用 balance 而不是 pretty：pretty 在 375 宽
     只把断点前移一个字（「…不会被」「选中。」），仍拆开「选中」；balance 在 320 / 360 / 375 都折成三行等长，390 起两行等长，
     1440 一行放得下，不受影响。只作用在新建卡的提示条里，文字不动。 */
.videos-page .daily-builder > .card-head > .btn.link { white-space: nowrap; }
.videos-page .daily-builder > .card-head > h2 { text-wrap: balance; }
.videos-page .card-head .help-tip-body li { text-wrap: pretty; }
.videos-page .daily-builder .notice .st-row { text-wrap: balance; }

/* camera-anomaly-v1 */
/* 相机异常检测 v1：管理 → 项目里的开关，和管理 → 异常检测页。本需求只有这一段，两部分的规则都接在
   这里，用缩进的小标题分开；只用设计变量，没有新颜色、新字号。 */

  /* 管理 → 项目的新建 / 编辑弹窗（pages/admin_projects.js openProjectForm）：「开启异常检测」勾选框、
     下面一句短说明（.admin-hint）、取消勾选时的警告（.notice.warning）。三样是一块（.admin-anomaly-field），
     不是 .admin-form-field：勾选行自己带 label，不需要字段标签那一层。
     - 块内间距 --sp-1，与别的字段「输入框 + 提示」同一档；块与块之间仍是 .admin-form 的 gap。
     - 短说明与勾选行的文字对齐：缩进 = 16px 勾选框 + --sp-2 间距（与相机替换确认框里「注销旧相机」那句同一个做法）；
       text-wrap: pretty 让折行时不把最后一个字单独甩到下一行（弹窗 460px 宽时「查看」会掉下来一个「看」字）。
     - 警告在一个常驻的 role=status 容器（.admin-anomaly-warn-slot）里，读屏才会播报它出现；没有警告时容器是空的、零高度，
       所以块内不用 grid 的 gap（空容器也会占一格间距），改成短说明和警告条各自带 --sp-1 的上边距。
       警告占满整行、不缩进；.notice 自带 margin-bottom，这里收掉，免得弹窗按钮行上面多出一截空白。
     - .admin-anomaly-nowrap：短说明里的「管理 → 异常检测」查看、警告里的「不再检测」各包在一个 span 里不折行（390 宽时
       前者会在「管」「理」之间断开、后者在「不」「再」之间断开；1440 宽时「查看」两个字会单独掉到第二行，所以「查看」也包进去）。
       两段都很短（11 个字、4 个字），在 343px 的窄弹窗里放得下。
     触屏 / ≤720px 的整行 44px 点击区由全站 .check 给（touch targets 一节），这里不重复。 */
.admin-anomaly-field { display: grid; min-width: 0; }
.admin-anomaly-field > .admin-hint { margin: var(--sp-1) 0 0; padding-left: calc(16px + var(--sp-2)); text-wrap: pretty; }
.admin-anomaly-warn-slot { min-width: 0; }
.admin-anomaly-warn-slot > .admin-anomaly-warn { margin: var(--sp-1) 0 0; min-width: 0; }
.admin-anomaly-nowrap { white-space: nowrap; }

  /* 管理 → 异常检测页（pages/admin_anomalies.js）。卡片、分段按钮、筛选行、状态圆点、文字链、折叠、表格都沿用全站的类
     （.card / .card-head / .seg / .form-inline / .field / .status / .btn.link / .disclosure / .table-scroll / .cm4-table.admin-table），
     这里只补本页的排法；选择器都以 .anomaly- 开头，前面的规则一条不改。
     - 筛选行：项目下拉在左，七个类别按钮（全站的 .seg，「电量 3」）在右；≤720px 下拉占满一行，七个按钮排成网格（每行 4 个）。
       按钮的尺寸（字号、内边距、最小高度）只由全站的 .seg button 规则定（test_console_css 的 .seg 只有一种规格守卫，CONS-B-10），
       这里不另写：窄屏按钮是全站 .seg 的 42px，整排外框算上 44px，分成两行时每行 42px。
     - 当前异常：一台相机一张小卡，宽屏网格排列（每列至少 24em，放不下就少一列，窄屏自然是一列；小卡各按自己的内容高，
       不拉成同高——一台五条、旁边一台一条时，同高的话那一台下面是一大块空白）。小卡左边 3px 色条是这台相机最严重的
       一条的级别（--warning-bar / --serious-bar / --critical，与相机参数页的提示条同一组令牌），色条只是辅助：每条异常都有
       类别文字标签和级别文字，不靠颜色区分。每条异常两行：类别标签 + 级别 + 「已持续」靠右，message 在下一行。
     - 相机名链接就是全站的 .btn.link（--ink-2、带下划线、悬停变 --ink），这里只补 min-width / 对齐 / 折行，颜色与字重不改。
     - 最近恢复：<details> 用全站的 .disclosure（summary 整行可点，窄屏 44px）。展开是五列表格；≤960px（侧栏与内容挤在一起、
       表格放不下五列）改成每条记录一张小卡，列名写在单元格的 data-label 里、用伪元素显示（写法同管理三页的 .admin-card-table，
       表头对读屏仍在、只是看不见）。
     - 只用设计变量，没有新颜色、新字号。 */
  .anomaly-filters { margin-bottom: var(--gap); }
  .anomaly-project-field { min-width: 0; max-width: 100%; }
  .form-inline > .anomaly-project-field > .anomaly-project { min-width: 12em; max-width: 100%; }
  .anomaly-seg { max-width: 100%; }
  @media (max-width: 720px) {
    .anomaly-filters > .anomaly-project-field { flex: 1 1 100%; }
    .anomaly-project-field > .anomaly-project { flex: 1 1 auto; }
    .anomaly-filters > .anomaly-seg { display: grid; grid-template-columns: repeat(auto-fit, minmax(5.5em, 1fr)); flex: 1 1 100%; }
  }

  .anomaly-summary { margin: 0 0 var(--gap); }
  .anomaly-summary-count { font-size: var(--fs-lg); font-weight: 600; }
  .anomaly-checked { overflow-wrap: anywhere; }

  .anomaly-disabled, .anomaly-active, .anomaly-recent { min-width: 0; }
  /* 刷新重画后原来的链接没有了（恢复了、被筛掉了），键盘焦点落到卡片标题（pages/admin_anomalies.js restoreFocus，h2 带 tabindex=-1）：
     鼠标点到标题不画框，键盘来的画全站的焦点环（与相机替换页的卡片标题同一个做法）。 */
  .anomaly-title:focus { outline: none; }
  .anomaly-title:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  .anomaly-disabled-list { list-style: none; margin: 0; padding: 0; }
  .anomaly-disabled-row {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0 var(--sp-3);
    padding: var(--sp-1) 0; border-top: 1px solid var(--border);
  }
  .anomaly-disabled-name { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
  .anomaly-disabled-note { min-width: 0; }
  .anomaly-disabled-link { flex: none; margin-left: auto; }

  .anomaly-active-body { min-width: 0; }
  .anomaly-empty { padding: var(--sp-4) 0; }
  .anomaly-groups {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 24em), 1fr)); align-items: start; gap: var(--sp-3);
  }
  .anomaly-camera {
    min-width: 0; padding: var(--sp-2) var(--sp-3) var(--sp-1);
    border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: inset 3px 0 0 var(--muted);
  }
  .anomaly-camera[data-level="warning"] { box-shadow: inset 3px 0 0 var(--warning-bar); }
  .anomaly-camera[data-level="serious"] { box-shadow: inset 3px 0 0 var(--serious-bar); }
  .anomaly-camera[data-level="critical"] { box-shadow: inset 3px 0 0 var(--critical); }
  .anomaly-camera-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 var(--sp-2); min-width: 0; }
  .btn.link.anomaly-camera-link { min-width: 0; justify-content: flex-start; text-align: left; overflow-wrap: anywhere; }
  .anomaly-camera-project { min-width: 0; overflow-wrap: anywhere; }
  .anomaly-items { list-style: none; margin: var(--sp-1) 0 0; padding: 0; }
  .anomaly-item { display: grid; gap: var(--sp-1); padding: var(--sp-2) 0; border-top: 1px solid var(--border); }
  .anomaly-item-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-2); }
  .anomaly-category {
    flex: none; padding: 0 var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-pill);
    font-size: var(--fs-xs); line-height: 1.7; color: var(--ink-2); white-space: nowrap;
  }
  .anomaly-level { flex: none; }
  .anomaly-duration { margin-left: auto; white-space: nowrap; }
  .anomaly-message { margin: 0; font-size: var(--fs-md); line-height: 1.6; overflow-wrap: anywhere; text-wrap: pretty; }
  /* message 里的时刻（10-07 20:15）与「数字 + 单位」（11.0 V、37.2 GB、2 小时 15 分）：折行时不从中间拆开
     （Chrome 在 zh-CN 文字里允许在「-」之后断行）。页面在 lib/admin_anomalies.js noWrapParts 切出来包进 span。 */
  .anomaly-nowrap { white-space: nowrap; }

  .anomaly-recent-details { min-width: 0; }
  .anomaly-recent-title { min-width: 0; font-size: var(--fs-lg); font-weight: 600; }
  .anomaly-recent-body { margin-top: var(--sp-3); }
  .anomaly-recent-body > .muted { margin: 0; }
  .anomaly-recent-table th, .anomaly-recent-table td {
    text-align: left; white-space: normal; overflow-wrap: anywhere; vertical-align: top;
  }
  .anomaly-recent-table thead th { white-space: nowrap; }
  .anomaly-recent-row { vertical-align: top; }
  .anomaly-recent-table .anomaly-camera-project { display: block; font-weight: 400; }
  .anomaly-recent-table .anomaly-camera-link { font-weight: 400; }
  .anomaly-recent-table .anomaly-range, .anomaly-recent-table .anomaly-cell-text { min-width: 0; }
  .anomaly-recent-table .anomaly-cell-category, .anomaly-recent-table .anomaly-cell-duration { white-space: nowrap; }
  .anomaly-recent-table .anomaly-range-from, .anomaly-recent-table .anomaly-range-to { white-space: nowrap; }
  @media (min-width: 961px) {
    .anomaly-recent-table .anomaly-cell-camera { min-width: 9em; }
    .anomaly-recent-table .anomaly-cell-message { min-width: 12em; }
    .anomaly-recent-table :is(.anomaly-cell-category, .anomaly-cell-range, .anomaly-cell-duration) { width: 1%; }
    .anomaly-recent-table .anomaly-range-from, .anomaly-recent-table .anomaly-range-to { display: block; }
  }
  /* 表格里相机名是 .btn.link（最小高 36px、文字居中），第一行文字比同行别的列低 5–6px：鼠标设备上收成一行字的高度，
     与别的列顶对齐；触屏设备（pointer: coarse）保留全站的触控高度。 */
  @media (min-width: 961px) and (pointer: fine) {
    .anomaly-recent-table .anomaly-camera-link { min-height: 0; padding-top: 0; padding-bottom: 0; }
  }
  @media (max-width: 960px) {
    .anomaly-recent-table { display: block; min-width: 0; }
    .anomaly-recent-table > thead {
      display: block; position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
      border: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
    }
    .anomaly-recent-table > tbody { display: grid; gap: var(--sp-2); }
    .anomaly-recent-table > tbody > tr {
      display: grid; gap: var(--sp-1); min-width: 0; padding: var(--sp-2) var(--sp-3);
      border: 1px solid var(--border); border-radius: var(--radius-sm);
    }
    .anomaly-recent-table > tbody > tr > :is(th, td) {
      display: grid; grid-template-columns: 7.5em minmax(0, 1fr); column-gap: var(--sp-3); align-items: baseline;
      min-width: 0; padding: 0; border: 0;
    }
    .anomaly-recent-table > tbody > tr > td::before { content: attr(data-label); color: var(--muted); }
    .anomaly-recent-table > tbody > tr > th {
      display: flex; flex-wrap: wrap; align-items: center; gap: 0 var(--sp-2); font-size: var(--fs-md);
    }
    .anomaly-recent-table .anomaly-camera-project { display: inline; }
    .anomaly-recent-table td > .anomaly-category { justify-self: start; }
  }
  /* 320 宽的小卡里值列只剩 119px，一整段「2026/10/07 19:57:13」放不下：开始、恢复各占一行，放不下时在日期与时间之间折行。 */
  @media (max-width: 360px) {
    .anomaly-recent-table .anomaly-range-from, .anomaly-recent-table .anomaly-range-to { display: block; white-space: normal; }
  }

/* ux-simplify-camera-v1 */
/* 相机参数页（pages/camera.js、pages/camera_config_card.js）整页收起解释性文字。本段只给 .camera-page 里的新结构写规则，
   不改前面任何已有规则；和拍摄计划页共用的选择器（.settings-status、.camera-delivery-detail）一律带 .camera-page 限定。
   T01 顶部提示条与状态条：
   - 「下发说明」的「?」放在状态条右上角：绝对定位，不占状态条网格里的一行。DOM 里它是状态条的第一个孩子（Tab 先到它）。
     第一行（相机参数 vN …）右边留出它的位置，窄屏折行时也压不到字。写法同拍摄计划页（ux-simplify-schedule-v1 的状态条一段）。
   - 「查看逐项结果」是 .disclosure（公共折叠样式）：summary 整行可点；chevron 紧跟在文字右边，不推到整行最右端，
     和拍摄计划页「查看相机读回的设置」一致。.camera-delivery-detail 在拍摄计划页的读回折叠上也用，所以限定在 .camera-page 里。
   - 从未读取时顶部提示条整块隐藏（hidden 属性，全站 [hidden] 是 display: none !important），不需要自己的规则。 */
.camera-page .settings-status { position: relative; }
.camera-page .settings-status > .help-tip { position: absolute; top: var(--sp-4); right: var(--sp-4); }
.camera-page .settings-status > .help-tip + .st-row { padding-right: calc(22px + var(--sp-3)); }
.camera-page .settings-status .camera-delivery-detail > summary > .disclosure-chevron { margin-left: 0; }
/* 「相机当前参数」卡（pages/camera_config_card.js）：
   - 标题行是公共的 .card-head（h2 在左，「?」在右，垂直居中，间距 --sp-2），不用再写规则。导语和"从未读取"提示条收进「?」，
     卡里不再有 .camera-config-intro；标题行下面直接是机型、读取时间两行（.camera-config-info，自带 --sp-3 下边距）。
   - 「其它相机设置（未映射）」是 .disclosure：summary 整行可点，chevron 紧跟在文字右边（和「查看逐项结果」一致），
     展开转 180° 由公共规则管。前面 CAM-ux-11 ② 那条窄屏 / 触屏规则（.camera-unmapped summary 的 min-height 和 padding-block，
     写在 summary 还是 list-item 的年代）不改：summary 现在是 flex，min-height 与 .disclosure 的一样，padding-block 把单行文字
     放在 44px 中间，两条加起来仍是 44px。 */
.camera-page .camera-unmapped > summary > .disclosure-chevron { margin-left: 0; }
/* 本页所有「?」气泡（状态条的「下发说明」、卡片标题右边的「相机当前参数说明」、以后参数行的「?」）：条目折行用 text-wrap: pretty，
   避免末行只剩一个字还把词拆开（卡片「?」第 1 条原来折成「…允许远程修 / 改。」，美观测试 F1；拍摄计划页同一问题的修法见
   ux-simplify-schedule-v1 T04 段的 .schedule-page .card-head .help-tip-body li）。text-wrap 是继承属性，写在 .help-tip-body 上，
   里面的 li、p 和以后参数行气泡里自己搭的节点都跟着，不用再逐个写选择器。限定在 .camera-page 下，别的页面的气泡不受影响。 */
.camera-page .help-tip-body { text-wrap: pretty; }
/* 参数表每行的「?」（pages/camera.js buildForm）：参数名右边一个「?」，「作用」「常见设置与效果」和下拉框的候选值说明
   都收在里面，行里不再常驻说明（.camera-help / .camera-choice-hint 的旧规则现在没有元素用了，不删）。
   - 行头容器 .camera-param-head 是行的第一个孩子，里面 label.param-name（点参数名仍聚焦控件）和「?」并排。「?」不能放进 label：
     button 放在别的控件的 label 里不合法，气泡里还有「完成」按钮。前面 T17 段的两条规则只认「label 是 .field 的直接孩子」
     （.form-stacked.is-columned .field > label 放回第 1 列；.camera-param.field > label.param-name 的 margin-top 把参数名对到控件中线），
     包起来以后都不生效，容器还会被 .field > :not(label) 当成第二列的内容，所以这里给容器补等价的规则，选择器比它们都具体。
   - ≥721px：容器在第 1 列（--form-col），顶端对齐，上面留出列头一行（--fs-xs × 1.6）加 2px，和 T17 那条一样；高度就是控件的高度
     （--control-h，触屏 --control-h-touch），里面 align-items: center，参数名和「?」都落在控件的垂直中线上。T17 那条公式（margin-top
     再加控件与行高之差的一半）只对一行字成立；参数名因为「?」占了位折成两行时（曝光补偿（EV））会偏下 11px。固定高度加居中，
     一行、两行都居中（两行时上下各超出约 4px，行上下有 --sp-3 的内边距，不碰别的东西），「?」在右侧、不压字。
     ≤720px：容器在控件上方，一行「参数名 ?」，下距 --sp-1（原来在 label 上）。
   - 参数名在 --form-col 里可以折行（min-width: 0、overflow-wrap: anywhere，和 console-batch01-p2 T01 段给 .is-columned 里 label 的写法一样），「?」不缩（help-tip 自带
     flex-shrink: 0），所以不会单独掉到下一行。
   - 气泡里是 .camera-param-help：两段（p）加一条候选值说明（p，没有时 hidden），段与段之间 --sp-2（和 .help-tip-list 的条间距一致）；
     小标题 .camera-help-k 沿用前面的 font-weight: 600，正文是气泡的 --ink-2。折行用上面的 text-wrap: pretty（继承自 .help-tip-body）。 */
.camera-page .camera-param.field > .camera-param-head {
  grid-column: 1; justify-self: stretch; align-self: start; min-width: 0;
  display: flex; align-items: center; gap: var(--sp-2);
}
.camera-page .camera-param-head > label.param-name { margin: 0; min-width: 0; overflow-wrap: anywhere; }
@media (min-width: 721px) {
  .camera-page .camera-param.field > .camera-param-head {
    margin-top: calc(var(--fs-xs) * 1.6 + 2px); height: var(--control-h);
  }
}
@media (min-width: 721px) and (pointer: coarse) {
  .camera-page .camera-param.field > .camera-param-head { height: var(--control-h-touch); }
}
@media (max-width: 720px) {
  .camera-page .camera-param.field > .camera-param-head { margin: 0 0 var(--sp-1); }
}
.camera-page .camera-param-help > * { margin: 0; overflow-wrap: anywhere; }
.camera-page .camera-param-help > * + * { margin-top: var(--sp-2); }
/* 参数表的列头（pages/camera.js columnHeadRow、COLUMN_LABELS；出处 2026-09-23-console 的 camera-ui.md CAM-ui-09：13 行把
   「云端目标值 / 相机当前值 / 状态」重复 39 遍，建议每张卡片只在顶部放一行表头，窄屏继续用行内列头）：
   - 三列真正并排的断点是 1024px：前面 console-batch01-p2 T17 段在 721–1023px 把三块上下叠放、列头写在值的前面，所以一行列头只在
     ≥1024px 显示，≤1023px 列头行 display: none，每行保留自己的三个列头，和改前一样。
   - ≥1024px：每张分组卡 h2 下面是一行 .camera-col-heads（aria-hidden，读屏已有每行的列头和 SR_PREFIX），每一行里的 .camera-col-head 视觉隐藏，
     写法和 .camera-sr-prefix 一样（绝对定位、1px、裁掉；不是 display: none，读屏仍读得到，也不再占三列网格里的一行）。
   - 列头行和参数行是同一种网格：第 1 列 --form-col（空，参数名的那一列），列距 --sp-3；第 2 列是 .camera-cols（同一条规则，三列比例和列距和行里的
     一样），所以三格的左缘和每行 .camera-col-target / -device / -status 的左缘是同一个 x。左边的 padding-left 加负 margin-left 照抄
     .camera-param-row，max-width: 100% 也照抄（参数行是 .form-stacked .field，被它封顶：不写的话列头行因负 margin 比参数行宽 12px，
     第 2 列宽 12px，三格的左缘向右错开最多 9px），下边线和行与行之间的 border-top 一样长。字号、颜色同原来的行内列头（--fs-xs、--ink-2），
     下面一条 --border 细线把它和第一行隔开。
   - 参数行头（T03 的 .camera-param-head）原来在上面留出列头一行加 2px（≥721px）；≥1024px 行内列头不再占高度，这里改成 0，
     高度（控件高）和居中不变，参数名仍落在控件的垂直中线上。
   - 第一行：h2 不再紧挨它，前面 .camera-group h2 + .camera-param（无上边框、上内边距 2px）不再命中，补一条挨着列头行的。任何宽度都没有上边框；
     ≤1023px（列头行不显示）上内边距 2px，和改前一样；≥1024px 上面有列头行的细线，第一行和别的行一样留 --sp-3。 */
.camera-page .camera-col-heads { display: none; }
.camera-page .camera-col-heads + .camera-param { border-top: none; padding-top: 2px; }
@media (min-width: 1024px) {
  .camera-page .camera-col-heads {
    display: grid; grid-template-columns: var(--form-col) minmax(0, 1fr); gap: 0 var(--sp-3);
    padding: 0 0 var(--sp-2) var(--sp-3); margin-left: calc(var(--sp-3) * -1); max-width: 100%;
    border-bottom: 1px solid var(--border);
  }
  .camera-page .camera-col-heads > .camera-cols { grid-column: 2; }
  .camera-page .camera-col-heads-item { min-width: 0; font-size: var(--fs-xs); color: var(--ink-2); overflow-wrap: anywhere; }
  .camera-page .camera-col-heads + .camera-param { padding-top: var(--sp-3); }
  .camera-page .camera-col-head {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
    overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
  }
  .camera-page .camera-param.field > .camera-param-head { margin-top: 0; }
}
/* 状态条里以图标开头的行（下发状态 ✓、相机当前值 ⓘ）：图标一格、文字一格，文字只在自己那一格里折行。原来是 .st-row 的 flex 折行，
   文字一长整块掉到下一行，375 / 390 宽下 ✓ ⓘ 自己占一行（768 下下发失败、有未下发项时也一样）。写法照拍摄计划页（console.css 的
   T21 拍摄计划复测段与 ux-simplify-schedule-v1 状态条段）：图标那一格固定 --sp-4（16px，与 ⓘ 15px、线条图标同宽），✓ 10px、ⓘ 15px 的
   两行文字左缘落在同一条线上；用子代组合符限定在状态条的直接子行，限定 .camera-page，不动拍摄计划页和存储页的同名规则。
   第一行（相机参数 vN …）右边给「?」留的 padding-right 那条（上面）不受影响：它没有图标。 */
.camera-page .settings-status > .st-row:has(> .notice-icon:first-child) {
  display: grid; grid-template-columns: var(--sp-4) minmax(0, 1fr); align-items: baseline;
}
.camera-page .settings-status > .st-row:has(> .notice-icon:first-child) > :not(.notice-icon) { grid-column: 2; min-width: 0; }
/* 参数名那一列按最长的参数名放宽：「曝光补偿（EV）」在 14px / 600 下一行 102.08px（实测，Chrome / Windows），加「?」前的间距 --sp-2
   和「?」本身 22px，共 132.08px；全站的 --form-col（8.5em，15px 下 127.5px）差 4.6px，名字折成两行、label 框被撑满，「?」被推到
   列的最右边，离「（EV）」近 50px。这里取 7.7 个 --fs-md（比实测宽约 5.7px，字体换一套、字形宽一点也放得下）加 --sp-2 加 22px，
   即 137.8px：13 个参数名在 ≥721px（含 721–1023 叠放区间、触屏）都是一行，「?」紧跟文字（间距 --sp-2 不变）。
   不去重新定义 --form-col：它是全站 .is-columned 表单（拍摄计划、存储）的标签列宽，只在 :root 写一次（存储页的测试就盯着这一条），
   所以本页用自己的变量 --camera-name-col，写在 .camera-group 上，参数行（.form-stacked.is-columned .field 的网格，选择器同样三个
   类、写在后面所以后者赢）和列头行（.camera-col-heads）都改读它，三列左缘自然仍然对齐。≤720px 参数行是纵向 flex，网格模板不起
   作用，列头行不显示。 */
.camera-page .camera-group { --camera-name-col: calc(var(--fs-md) * 7.7 + var(--sp-2) + 22px); }
.camera-page .camera-param.field,
.camera-page .camera-col-heads { grid-template-columns: var(--camera-name-col) minmax(0, 1fr); }
/* 折行不留单字（美观测试 T07 第 2 轮）：和本页气泡（上面 .camera-page .help-tip-body）同一个写法。
   - 相机自己报的备注（.camera-device-note）：名字列加宽以后，721–1023px 叠放区间值列窄了约 10px，768 下 ISO 那行的备注
     「25600 是这台机身的扩展档，云端的固定可选值里没有」从一行变成两行，第二行只剩一个“有”（拍摄模式的备注剩一个“效”）；
     pretty 让浏览器把断点往前挪，两行匀一点。行数不变，不影响整页高度。
   - 顶部提示条（.camera-delivery-notice）：375 宽下「已下发的项已确认，但仍有项没有下发到相 / 机」末行单字，改前就有。
     标题是 .camera-notice-body 里一个没有类名的 <strong>，下面还有一行说明（.camera-model-note），text-wrap 是继承属性，
     写在容器上两行都管；限定在本页的提示条里，不动公共的 .notice。 */
.camera-page .camera-device-note { text-wrap: pretty; }
.camera-page .camera-delivery-notice .camera-notice-body { text-wrap: pretty; }
/* ux-simplify-camera-v1 end */

/* ux-simplify-storage-v1 */
/* 存储与上传页（pages/storage.js、storage_cm4*.js）：解释性的说明收进卡片标题右边的「?」（lib/help_tip.js，外观在上面
   ux-simplify-schedule-v1 T01 段）。标题行就是公共的 .card-head，本页不再另写规则；这一段只放本页自己的部分，选择器都以
   .storage-page 开头，颜色、字号、间距只用 :root 令牌。
   - 本页每个「?」气泡里的每一条都用 text-wrap: pretty，写法与拍摄计划页（.schedule-page .card-head .help-tip-body li）一致：
     保存路径、上传策略、SSD、拍照测试、手动上传五个气泡里的条目都是整句中文，末行不要只剩一个字。 */
.storage-page .help-tip-body li { text-wrap: pretty; }
/* 单句的气泡（手动上传说明：传一个字符串，helpTip 画成一段 <p> 而不是列表）也一样：整句中文，末行不要只剩一个字。
   上面那条只管列表里的 li，这条管直接放在气泡里的 p。 */
.storage-page .help-tip-body > p { text-wrap: pretty; }

/* SSD 卡的标题行（.cm4-card > .cm4-head：h2、「?」、「更新于 …」）。.cm4-head 的原规则在前面「storage page: CM4 SSD status」
   一段里，不改；它也用在拍照测试、指令列表的小标题行，所以这里的选择器限定到卡片的直接子级。
   - 「?」在 DOM 里紧跟 h2（和别的卡的 .card-head 一样），宽屏排在最右：h2 … 更新于 …「?」，与别的卡的「?」同在卡片右缘；
     自己垂直居中，h2 和「更新于」仍按原来的基线对齐。
   - ≤480px：「更新于」本来就独占下一行（前面的规则），「?」留在 h2 同一行，靠右。 */
.storage-page .cm4-card > .cm4-head > .help-tip { order: 1; align-self: center; }
@media (max-width: 480px) {
  .storage-page .cm4-card > .cm4-head > .help-tip { order: 0; margin-left: auto; }
}
/* 「SSD 说明」气泡（pages/storage_cm4.js ssdHelpContent）：整块是一个 div，里面是状态那组列表、小标题、限制区那组列表。
   helpTip 只给 .help-tip-body 的直接孩子之间留间距，这一块里的各段要自己留：段与段之间 --sp-2，和列表项之间一致；
   小标题比正文重一点（--ink、600，气泡正文是 --ink-2），挨着它下面的列表收到 --sp-1，一眼看出列表属于谁。
   写法与拍摄计划页「拍摄时段说明」气泡里的 .schedule-window-help、.schedule-help-subhead 一致。 */
.storage-page .storage-ssd-help > * { margin: 0; }
.storage-page .storage-ssd-help > * + * { margin-top: var(--sp-2); }
.storage-page .storage-help-subhead { color: var(--ink); font-weight: 600; }
.storage-page .storage-help-subhead + .help-tip-list { margin-top: var(--sp-1); }
/* 「SSD 说明」里的组多了：状态、项目目录里的其他文件、其他数据、限制区，最多四组，每组一个小标题。组与组之间只隔
   --sp-2（和列表项之间一样）分不出哪句属于哪组，所以除了排第一的小标题，每个小标题上面多留一点（--sp-3）。
   选择器比上面的「> * + *」具体，会盖过它的 margin-top；小标题到它下面列表的 --sp-1 不变。 */
.storage-page .storage-ssd-help > .storage-help-subhead:not(:first-child) { margin-top: var(--sp-3); }

/* timezone-full-list-v1 */
/* 拍摄计划页相机时钟卡的「相机时区」下拉（pages/schedule.js createClockCard、lib/zone_picker.js）：原来是输入框加 datalist（只有常用几项），
   现在输入框外面包一个 .zone-picker，点开、敲字或按方向键弹出可搜索的分组列表（419 个时区，常用在最上面）。选中项的中文名和偏移写在
   输入框下面一行（.schedule-clock-zone-choice）。组件只用 hidden 属性显示和收起弹出层，所有外观都在这一段；颜色、字号、间距只用 :root
   令牌，不新建变量，每条规则的每个选择器都以 .schedule-clock 开头（只管相机时钟卡）。
   - 一格 .schedule-clock-zone-control（输入框外框 .zone-picker 加它下面一行说明 .schedule-clock-zone-choice）完全接替原来输入框在
     .schedule-clock-zone-row（.form-row，flex 换行）里的尺寸：基础 14em、最窄 10em、最宽 22em；>480px 基础宽度提到 22em（与
     ux-simplify-schedule-v1 T06 段的修复一致，括号里的「UTC+8」不被裁掉）；≤480px 占满一行。.zone-picker 只管定位（relative，弹出层以它为准），
     里面的输入框撑满它，并改成块级，免得行内元素的基线空隙把外框撑得比输入框高。收起时输入框和「保存时区」的位置、宽度与改版前一样。
     说明在 DOM 里紧跟输入框、排在按钮前面：窄屏一格占满一行，说明就在输入框下面，读屏顺序和视觉顺序一致（美观建议 S1）。这一格比输入框高，
     所以这一行的对齐从居中改成顶对齐：按钮和输入框顶边对齐；「保存时区」和「未修改」是同一个子行（.schedule-clock-zone-actions），在子行里居中，
     二者的中线一致，不靠给「未修改」加高度。
   - 弹出层是 .zone-picker-popup：紧贴输入框下方（--sp-1），作为滚动容器（最高 60vh、不超过 26em，滚到头不带动页面）。宽屏 30em，
     不窄于输入框，也不超出屏幕（100vw 减去两侧页面边距和卡片内边距，共 4 个 --sp-4：滚动条占位的窗口里也放得下）；≤480px 与外框同宽
     （left 与 right 都是 0）。721–899px 之间页面左边有 200px 的侧栏，输入框的左缘在 376px 左右，30em 的弹出层右缘会越过屏幕右边
     （越界的部分被 body 的 overflow-x: clip 裁掉），所以这一段弹出层与输入框同宽。外观与同页的「?」气泡（.help-tip-panel）一致：
     同一个底色 --surface-raised、1px --border 描边、--shadow-pop 阴影；圆角用 --radius。z-index 30，高于粘性保存条的 5，低于提示条的 40。
   - 组标题 .zone-picker-group-label 在弹出层里吸顶（sticky，底色同弹出层，滚动时选项从它下面过）；弹出层的 scroll-padding-top 等于它的
     高度，键盘把选项移上来时不被它盖住。选项主行（城市 · 国家）--fs-md、--ink，副行（IANA 名 · 偏移 · 夏令时）--fs-sm、--muted；
     键盘移到的当前项（.is-active，脚本同时给它 aria-selected="true"）用与侧栏当前项同一种强调色底，悬停（只在真有悬停的设备上）用与侧栏
     悬停同一种淡灰底，输入框里已有的那一项（.is-saved；aria-selected 跟着键盘走，不再标它，读屏名里另有「当前值」）主行加粗、用
     --accent-text，左边一道 3px 的 --accent 竖条（侧栏当前项的写法）。窄屏（≤720px）选项至少
     --control-h-touch 高；长名字折行，不撑宽弹出层。
   - 美观建议 S2、S5、S6 和提示行折行：不是第一组的那一组上面一条 --border 细线，所有组标题下面一条（和「?」气泡的 .help-tip-head 一样），
     吸顶时滚过去的选项文字不贴着标题（上面那条线画在「组」上而不是组标题上：吸顶的标题贴着弹出层的上边，画在标题上会和弹出层自己的
     1px 边框叠成 2px）；已选项的主行是 600，里面的间隔号单独一个 span（.zone-picker-option-dot）保持常规字重，不变成实心圆点；
     当前项除了底色再加一圈 1px --accent 内描边：深色下 14% 的蓝底和 6% 的灰底亮度相同，只靠色相分不开；提示行 text-wrap: pretty，
     折行时末行不剩一个字（浏览器不认也只是照常折）。
   - 对比度：--muted 和 --accent-text 只保证压在 --surface / --surface-raised 上够 4.5:1（见 :root 里的注释）；当前项、悬停行的底色是
     半透明色调，叠上去以后副行 --muted、已选主行 --accent-text 在浅色 4.22–4.43、深色 3.8–3.9，不够。所以带底色的行里副行改用
     --ink-2，已选的主行改用 --ink（仍然 600，竖条标出「已选」），实测各状态的文字都 ≥ 4.5:1，浅深两色都是。
   - 提示行 .zone-picker-note（加载中、加载失败、没有匹配）是弹出层的第一屏：弹出层是 flex 列，提示行用 order 排到列表前面并吸顶，
     打开就看得见（失败时列表有「常用」10 项，原来提示行排在它们后面，要滚到底才看到）。弹出层写了 display，靠全站的
     [hidden] { display: none !important } 收起；提示行可见时 scroll-padding-top 再加上它的高度（两行），键盘上移不被它盖住。
   - 读屏状态 .zone-picker-status 视觉隐藏（绝对定位、1px、裁掉；不是 display: none，读屏照读），写法与 .camera-sr-prefix 一致。 */
.schedule-clock .schedule-clock-zone-control { flex: 1 1 14em; min-width: 10em; max-width: 22em; }
.schedule-clock .zone-picker { position: relative; }
.schedule-clock .zone-picker > .schedule-clock-zone-input { display: block; width: 100%; max-width: none; }
@media (min-width: 481px) {
  .schedule-clock .schedule-clock-zone-control { flex-basis: 22em; }
}
@media (max-width: 480px) {
  .schedule-clock .schedule-clock-zone-control { flex: 1 1 100%; max-width: none; }
}
/* 一格（输入框 + 说明）比输入框高：这一行顶对齐，按钮和输入框顶边对齐。「保存时区」和「未修改」是同一个子行（.schedule-clock-zone-actions，
   居中对齐、一起换行）：「未修改」的中线就是按钮的中线，不管按钮是 36px 还是 44px（全站触控规则按宽度和指针给），也不需要给「未修改」
   另加高度——折到下一行时它只占一行字高。子行的 flex-basis 是 0、不窄于里面最宽的一项（按钮）：输入框那一格旁边放得下按钮就仍放在旁边
   （放不下「未修改」就在按钮下面换行），按钮也放不下才整个子行换到那一格下面；≤480px 子行占满一行，按钮占满子行（全站 ≤480px 的规则）。 */
.schedule-clock .schedule-clock-zone-row { align-items: flex-start; }
.schedule-clock .schedule-clock-zone-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); flex: 1 1 0; }
@media (max-width: 480px) {
  .schedule-clock .schedule-clock-zone-actions { flex-basis: 100%; }
}
/* 按钮的字不折行：「保存时区」四个字都能在字间断行，子行的最小宽度就只剩一个字宽，481px 左右会被挤进输入框那一格旁边的 77px 里、折成两行（58px 高）。
   不折行以后子行的最小宽度是整个按钮，放不下就整个子行换到下一行，和改版前的断点一样。 */
.schedule-clock .schedule-clock-zone-actions > .btn { white-space: nowrap; }

.schedule-clock .zone-picker-popup {
  display: flex; flex-direction: column;
  position: absolute; top: calc(100% + var(--sp-1)); left: 0; z-index: 30;
  width: min(30em, calc(100vw - 4 * var(--sp-4))); min-width: 100%;
  max-height: min(60vh, 26em); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain;
  scroll-padding-top: calc(var(--fs-xs) * 1.6 + 2 * var(--sp-1) + 1px);
  background: var(--surface-raised); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow-pop); text-align: left;
}
@media (min-width: 721px) and (max-width: 899px) {
  .schedule-clock .zone-picker-popup { width: 100%; }
}
@media (max-width: 480px) {
  .schedule-clock .zone-picker-popup { left: 0; right: 0; width: auto; min-width: 0; }
}

.schedule-clock .zone-picker-group-label {
  position: sticky; top: 0; z-index: 1; padding: var(--sp-1) var(--sp-3);
  background: var(--surface-raised); color: var(--ink-2); font-size: var(--fs-xs); font-weight: 600;
  border-bottom: 1px solid var(--border);
}
.schedule-clock .zone-picker-group + .zone-picker-group { border-top: 1px solid var(--border); }
.schedule-clock .zone-picker-option { display: block; padding: var(--sp-2) var(--sp-3); overflow-wrap: anywhere; cursor: pointer; }
.schedule-clock .zone-picker-option-main { display: block; color: var(--ink); font-size: var(--fs-md); }
.schedule-clock .zone-picker-option-dot { font-weight: 400; }
.schedule-clock .zone-picker-option-sub { display: block; color: var(--muted); font-size: var(--fs-sm); }
@media (hover: hover) {
  .schedule-clock .zone-picker-option:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
  .schedule-clock .zone-picker-option:hover > .zone-picker-option-sub { color: var(--ink-2); }
  .schedule-clock .zone-picker-option.is-saved:hover > .zone-picker-option-main { color: var(--ink); }
}
.schedule-clock .zone-picker-option.is-active {
  background: color-mix(in srgb, var(--accent) 14%, transparent); outline: 1px solid var(--accent); outline-offset: -1px;
}
.schedule-clock .zone-picker-option.is-active > .zone-picker-option-sub { color: var(--ink-2); }
.schedule-clock .zone-picker-option.is-saved { box-shadow: inset 3px 0 0 var(--accent); }
.schedule-clock .zone-picker-option.is-saved > .zone-picker-option-main { color: var(--accent-text); font-weight: 600; }
.schedule-clock .zone-picker-option.is-active.is-saved > .zone-picker-option-main { color: var(--ink); }
@media (max-width: 720px) {
  .schedule-clock .zone-picker-option { min-height: var(--control-h-touch); }
}

.schedule-clock .zone-picker-list { flex: none; }
.schedule-clock .zone-picker-note {
  margin: 0; padding: var(--sp-3); color: var(--muted); font-size: var(--fs-md); overflow-wrap: anywhere;
  flex: none; order: -1; position: sticky; top: 0; z-index: 2; background: var(--surface-raised);
  text-wrap: pretty;
}
.schedule-clock .zone-picker-popup:has(.zone-picker-option) > .zone-picker-note { border-bottom: 1px solid var(--border); }
.schedule-clock .zone-picker-popup:has(.zone-picker-note:not([hidden])) {
  scroll-padding-top: calc(var(--fs-md) * 3.2 + 2 * var(--sp-3) + var(--fs-xs) * 1.6 + 2 * var(--sp-1) + 2px);
}
.schedule-clock .zone-picker-status {
  position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; border: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* 输入框下面选中项的中文名和偏移（.meta 灰字，字号颜色就是 .meta 的）：在一格（.schedule-clock-zone-control）里紧跟输入框，宽度就是这一格的，
   长了在自己里面折行；框里是空的或认不出时是空串，:empty 隐藏。状态 / 原因那一格（.schedule-clock-zone-hint）在整行下面。 */
.schedule-clock-zone-choice { display: block; margin: var(--sp-1) 0 0; overflow-wrap: anywhere; }
.schedule-clock-zone-choice:empty { display: none; }

/* console-i18n-v1 */
/* 语言切换入口（lib/lang_switch.js）：登录表单底部和顶栏账号区各一组「English | 中文」（英文在前，用户 2026-10-21 定；≤720px 的菜单里是同一组节点）。
   节点只在上线开关打开或地址带 ?i18n=preview 时才建；没有节点时这一段对页面零影响，所以每条规则都挂在
   .lang-switch 上（或写成 :has(.lang-switch)）。只用上面已有的变量，没有新颜色、新字号。
   当前语言除了字色，还有底色和底边的一道线，不只靠颜色区分（aria-pressed 同时说给读屏听）。
   边框画在每个按钮自己身上、组本身没有框：这样按钮自己就是 --control-h（触屏 --control-h-touch）高，
   和同一行的「修改密码」「退出」一样高，三者顶边对齐。字号和 .seg、.btn.link 一样是 --fs-base。 */
.lang-switch { display: inline-flex; flex: none; align-items: stretch; }
.lang-switch-button {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: var(--control-h); margin: 0; padding: var(--sp-1) var(--sp-2);
  border: 1px solid var(--control-border); border-radius: 0; background: var(--surface); cursor: pointer;
  font-size: var(--fs-base); color: var(--ink-2); white-space: nowrap;
}
.lang-switch-button:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
.lang-switch-button:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; border-left: none; }
.lang-switch-button:not([aria-pressed="true"]):hover { background: color-mix(in srgb, var(--ink) 6%, var(--surface)); color: var(--ink); }
.lang-switch-button[aria-pressed="true"] {
  background: color-mix(in srgb, var(--accent) 14%, var(--surface)); color: var(--ink);
  box-shadow: inset 0 -3px 0 var(--accent-text);
}
.lang-switch-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; position: relative; }
.login .lang-switch { display: flex; width: max-content; max-width: 100%; margin: var(--sp-5) auto 0; }
/* 顶栏：账号区（切换入口、用户名、修改密码、退出）是一整块；放不下时整块换到下一行，仍然贴右（720 以下
   原来就这样，720 到 1023 之间原来一行放得下、没有这条）。 */
.user-actions:has(.lang-switch) { margin-left: auto; }
/* 手机宽度：账号区自己一行放得下就一行（切换入口、修改密码、退出三者同一行、同一个顶边）；英文的
   “Change password” 比中文宽，留一点余量：项之间的空隙收窄，真放不下才在块里换行，也不横向溢出。 */
@media (max-width: 640px) {
  .user-actions:has(.lang-switch) { gap: var(--sp-2) var(--sp-3); flex-wrap: wrap; justify-content: flex-end; }
}
@media (max-width: 720px), (pointer: coarse) {
  .lang-switch-button { min-height: var(--control-h-touch); min-width: var(--control-h-touch); }
}
/* 侧边导航：英文的页名比中文宽，当前页还加粗（600）。最长的 “Camera Replacement” 在 200px 宽的导航里（扣掉 3px 左边线
   和左右各 20px 内距，内容区 156px）不加粗约 152px、加粗约 159px，成为当前页时会折成两行，下面的项跟着往下跳一格。
   有切换入口（开关打开或带 ?i18n=preview）时导航项不折行：多出的几像素落在右内距里，离右边线还有十几像素，
   不会盖住别的东西。没有入口时（开关关着）这条不生效，导航与以前逐字相同。 */
.shell:has(.lang-switch) .sidenav a { white-space: nowrap; }
/* 数据表的表头（lib/chart.js headingContent）：英文把单位放在名称下面（“Battery voltage” 换行 “(V)”），带单位的表头成了
   两行，Time、Readings、Note 还是一行。浏览器默认让表头在格子里居中，一行的表头就浮在两行表头的中间；改成贴下沿，
   一行的表头和两行表头的第二行、下面的数据落在同一条线上。只在英文下生效（:lang(en)）：中文表头没有换行，
   一个字节都不变；没有切换入口时这条不生效。 */
:lang(en):has(.lang-switch) .chart thead th { vertical-align: bottom; }
/* 备注格（“Counter wrap or reset”）：英文比中文长，带这条备注的那一页在 1280–1440 宽的桌面上会比卡片宽一点。
   这一格允许折行：放得下时是一行；放不下时由备注这一列让出宽度（别的格都不折行，所以只有它能缩），只有带备注的
   那几行变高，整个表不用横滑。只在英文下生效；中文的备注格不变。 */
:lang(en):has(.lang-switch) .chart td.note-event { white-space: normal; }
/* 相机参数页顶部卡片（pages/camera_config_card.js）的两行：标签 .st-label 的 min-width 是 5.5em（82.5px），本来就是让“相机型号”
   “读取于”两行的值从同一处起头。英文的 “Camera model” 比它宽（本机字体约 105px，Segoe UI 约 97px），型号那一行的值被顶到右边，
   比“读取于”那一行的值靠右 14–23px，两行不齐。有切换入口时把最小宽度放宽到 7.5em（112.5px，比 105px 还多 7px 余量），两行的值
   又落在同一条线上。只在英文下生效（:lang(en)）、没有切换入口时不生效：中文的标签盒子仍是 82.5px，一个字节都不变。 */
:lang(en):has(.lang-switch) .camera-config-info .st-label { min-width: 7.5em; }
/* 设备状态页的七张读数瓦片（pages/status.js tile()）：名称 .k 在上、读数 .v 在下。英文名称比中文长，在 1100–1440 宽的桌面和
   721–860 宽的平板上 “Internal temperature”“CPU temperature” 折成两行，这一张的读数就比同一行其余几张低一行；累计拍照的读数加
   单位 “65,535photos” 中间没有可以断行的地方，五位数时伸出瓦片。两处一起改，都只在英文下生效（:lang(en)）：
   - 瓦片改成子网格（grid-row: span 2，两行 .k / .v 由父网格 .status-reading-tiles 的行共用）：同一行的瓦片共用名称行的高度和读数
     行的高度，名称折几行读数都在同一条线上；名称都是一行时每个瓦片和中文一样高（读数行的最小高度 38px 补回子网格不认的
     min-height: 86px）。瓦片之间的行距仍是父网格的 gap，瓦片里 .k 和 .v 之间不另加间隙（row-gap: 0，中间的 4px 是 .v 自己的 margin-top）。
   - 单位 <small> 改成 inline-block：数字和单位之间有了换行点，放不下时整个单位换到下一行，不再从词中间折断。总览卡片和
     地图气泡的读数格（.reading dd small）是同一个问题，一并带上；总览页本身还没迁移，只是同一个词带来的。
   中文不受影响：没有 :lang(en) 的页面一个字节都不变；浏览器不认子网格时只是瓦片各自成块，不会错位。 */
:lang(en):has(.lang-switch) .status-reading-tiles > .tile { display: grid; grid-row: span 2; grid-template-rows: subgrid; row-gap: 0; }
:lang(en):has(.lang-switch) .status-reading-tiles .tile .v { min-height: 38px; }
:lang(en):has(.lang-switch) .status-reading-tiles .tile .v small,
:lang(en):has(.lang-switch) .reading dd small { display: inline-block; }
/* 第一行三张卡片底部的版本 / 型号三行（pages/status.js tileFoot，样式在上面 status-versions 一段）：标签在左、值在右，放不下时值换到下一行，
   ≤640px 一律上下排。中文的标签短，到 641px 以上一直放得下；英文的 “STM32 firmware version” 比 “Camera model” 宽得多，在 641–1100px
   宽（三张卡片只有 210–245px 宽）电压卡的这一行折成两行，另外两张还是一行，三条分隔线一高一低（1024 宽差 20px）。这个宽度范围内英文也
   一律上下排：三张卡片的这一行都是两行，分隔线在同一高度。1101px 以上卡片够宽，仍是一行。只在英文下生效；中文一个字节不变。 */
@media (max-width: 1100px) {
  :lang(en):has(.lang-switch) .status-foot-line { flex-direction: column; align-items: flex-start; }
}
/* 备注格的折行只在表放得下的宽度才有用（上面那条 white-space: normal）：数据表比卡片宽、在 .table-scroll 里横滑的时候，
   自动表格布局把唯一允许折行的备注列压到最小内容宽度（只剩 “Counter” 一个词），“Counter wrap or reset” 折成三行、这一行
   79px（普通行 38px，中文 38px），表并没有因此不用横滑。这个宽度范围内备注格不折行：带备注的行和普通行一样高，备注在横滑区里。
   上限 1220px：Segoe UI 下带备注那一页的表折行后约 971px，在 1101–1220px 宽的卡片里（827–946px）仍然横滑，折行换不来任何东西；
   1221px 起折行才有用，但不是马上就不横滑：1221–1244px 是三行且还横滑（24px 缩到 1px），1245px 起不横滑；三行是 1221–1274px、
   两行 1275–1324px、一行 1325px 起（1320px 仍是两行），所以那里不动。Microsoft YaHei UI 下表更宽：1221px 以上一直是三行，
   1221–1326px 横滑（106px 缩到 1px），1327px 起不横滑但仍是三行（1341px 以上内容区封顶 1066px，表最小 1052.6px、一行要
   1138.5px，放宽多少都是三行；只在 YaHei，不再往上加）。断点保持 1220px（数字是 B2 版面检查的实测）。≤1220px 以内也包括原来的 ≤1100px。 */
@media (max-width: 1220px) {
  :lang(en):has(.lang-switch) .chart td.note-event { white-space: nowrap; }
}
/* 设备状态页第一行三张卡片（电压、相机电源、CM4 电源）：641px 起三张并排，721px 起左边还有侧栏，英文在 641–900px 宽每张卡片的内容区
   只有一百多像素（768 宽时 135px）：电压短结论 “Voltage very low < 11.0 V” 自己就比卡片内容区宽，在一段之内折行（最坏 4 行、末行孤词 “report”），
   卡片名 “Battery voltage” 折两行，“Powered off” 折两行，底部 “STM32 firmware version” 折两行、三条分隔线差 18px。这个宽度范围内
   英文沿用 ≤640px 的排法：电压卡独占一行，两张电源卡并排（上限 900 是 Microsoft YaHei UI 下短结论全部不再段内折行的最小值）。
   901px 以上仍是三张一排。只在英文下生效；中文一个字节不变。
   前导写成区间 (width > 640px)，不写成 641px 起的 min-width：和原有的 (max-width: 640px) 首尾相接，小数宽度（640.5px 这类缩放下的视口）
   不会落进两边都不管的 1px 缝里。区间写法要 Chrome 104+ / Safari 16.4+ / Firefox 63+；更老的浏览器整块不认，只是英文预览下这一组排法不生效。 */
@media (width > 640px) and (max-width: 900px) {
  :lang(en):has(.lang-switch) .status-summary-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  :lang(en):has(.lang-switch) .status-summary-tiles > .voltage-tile { grid-column: 1 / -1; }
}
/* 七张读数瓦片的列数（上面子网格那几条管对齐和单位换行，这里管“放不下单位”）：英文名称和单位比中文长，累计拍照到一万以上
   （16 位计数器的常态）时 “65,535 photos” 要 109.6px（Segoe）/ 121.2px（YaHei），而 1101–1340px 的七列瓦片、721–860px 的四列瓦片
   内宽只有 88–114px：单位整个换到下一行，名称也折行，整行瓦片 143px（中文 86px）。这两段宽度里列数少一档：1101–1340px 四列，
   721–860px 三列，瓦片变宽，名称和单位都不折，整行回到 86px。1341px 以上七列、861–1100px 四列、≤720px 原样。只在英文下生效。
   第一块的前导同样写成 (width > 1100px)，和原有的 (max-width: 1100px) 首尾相接，没有 1px 的缝（浏览器要求同上）。 */
@media (width > 1100px) and (max-width: 1340px) {
  :lang(en):has(.lang-switch) .status-reading-tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 721px) and (max-width: 860px) {
  :lang(en):has(.lang-switch) .status-reading-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* 功耗页读数行（.energy-readout，console-i18n-v2 第 2 批 P02）：没选中柱子时是空的，选中（悬停、键盘、手指点按）时是一条详情，页面不能因此跳高，
   所以 min-height 定成这个宽度下最长一条详情占的行数（ux-simplify-energy-v1 段，按中文量的：861px 起 1 行、721–859px 和 ≤640px 2 行、≤340px 3 行）。
   英文详情更长：「30 Sep 2026, 23:00–00:00 (in progress) · Measured 12.3 Wh · Estimated 15.6 Wh · Average 12.34 W · Coverage 100%」13px 下不折行要
   718px（Microsoft YaHei UI；Segoe UI 661px；按天的写法 663 / 610px），中文最长一条约 570px，中文那几档在英文下不够：≤430px 要 3 行（原来 341–430px 只留 2 行，375px 宽悬停一根柱子页面下跳 21px）、641–720px 要 2 行
   （原来 1 行）、860–1000px 要 2 行（原来 1 行）；721–859px 和 431–640px 原来的 2 行够，1001px 起原来的 1 行够。读数行内容宽度同中文：
   视口宽大于 720px 时是视口宽减 274px（侧栏在），720px 及以下是视口宽减 58px。断点在 Microsoft YaHei UI 和 Segoe UI 两种字体下每 4px 扫过
   （default 字体会跳高的最宽范围：344–424px、644–720px、860–992px；Segoe UI 的更窄一点），上沿各留一点余量；假定一天的进行中读数不到 1000 Wh
   （超过了会写成 kWh，再长约 20px，只影响 993–1012px 这一小段）。三块都只在英文下生效，中文一个字节不变；前导写成区间 (width > …)，
   和原有的 (max-width: …) 首尾相接，没有 1px 的缝（浏览器要求同上）。 */
@media (max-width: 430px) {
  :lang(en):has(.lang-switch) .energy-page .energy-readout { min-height: calc(1.6em * 3); }
}
@media (width > 640px) and (max-width: 720px) {
  :lang(en):has(.lang-switch) .energy-page .energy-readout { min-height: calc(1.6em * 2); }
}
@media (width > 859px) and (max-width: 1000px) {
  :lang(en):has(.lang-switch) .energy-page .energy-readout { min-height: calc(1.6em * 2); }
}
/* console-i18n-v2-b3 schedule：拍摄 / 上传时段表每天一行（.win-row）。≤480px 时一行排成「星期 | 勾选 | 开始 | 结束」，两个下拉各分一半余宽
   （上面 T20 段）；中文的勾选字和「开始」「结束」各只有两个字，英文是 Capture / Upload、Start、End：375px 宽时每个下拉只剩约 55px，
   “12:00” 被箭头盖住最后一个 0（390px 宽 70px 刚好放下，Microsoft YaHei UI 和 Segoe UI 下都量过）。≤400px 的英文沿用 ≤370px 中文那一档的办法：
   “开始 / 结束” 两个字只留给读屏（<label for> 仍然关联下拉），两个下拉之间画一个 “–”。401px 起下拉不小于 72px，字和箭头不挤，原样。
   只在英文下生效；中文和没有切换入口时一条都不命中。 */
@media (max-width: 400px) {
  :lang(en):has(.lang-switch) .win-row > .field label {
    position: absolute; width: 1px; height: 1px; padding: 0; border: 0;
    overflow: hidden; white-space: nowrap; clip-path: inset(50%);
  }
  :lang(en):has(.lang-switch) .win-row > .field + .field::before { content: "–"; flex: none; color: var(--ink-2); }
}
/* console-i18n-v2-b3 schedule：保存条（.save-row）的按钮在 768px 宽的英文下一行放不下——改了值时「● Unsaved changes」「History」「Resend current」
   「Save & send」比中文宽出十几个像素——换行后最后那个主按钮掉到第二行最左，和上面的按钮对不齐。英文下一行放不下时各行靠右：
   有 .spacer 的那一行余宽归 .spacer，不受影响；主按钮仍在右边，和中文换行时一样占第二行，只是靠右。≤480px 的紧凑条（.is-compact）有自己的
   排法，不动；保存条的高度来源（--save-bar-h、trackStickyBar）一行没动。只在英文下生效。 */
:lang(en):has(.lang-switch) .schedule-page .save-bar:not(.is-compact) .save-row { justify-content: flex-end; }
/* console-i18n-v2-b6 photos-videos */
/* 延时视频页新建卡的「相机目录」下拉框（pages/videos.js dailyBuilder，console-i18n-v2 第 6 批 R02）。≤720px 时字段是「标签在左、下拉框吃剩下的宽度」
   （前面 .daily-builder .form-inline > .field > select 的 flex: 1 1 0）：中文标签「相机目录」约 52px，英文 Camera folder 88px，所以英文下拉框比中文窄 36px，
   选项又更长（「（975 张）」对 "(975 photos)"，约 20px）。线上的目录是整机编号，选中 FixCamera-A00009 (975 photos) 要 270px、FixCamera-B00012 (12873 photos)
   要 286px，375px 宽的下拉框只有 221px、390px 宽 236px，英文被截成「FixCamera-A00009 (975 p」，中文同宽完整。只改措辞不够（标签写 Folder 还差约 5px），
   所以英文下这一个字段 ≤720px 时标签在上、下拉框占满整行：下拉框 flex: 1 1 100% 排不进标签那一行，换到下一行（.field 本来就是 flex-wrap: wrap，
   行距 --sp-1），375px 宽 317px、390px 宽 332px。其余字段（开始日期、结束日期、每天的时间、允许偏差）的选项都放得下，不动；>720px 下拉框是内容宽，不动。
   只在英文下生效：中文一个字节不变；前导是 (max-width: 720px)，和 .daily-builder 原有的两条 ≤720px 规则同一个断点。 */
@media (max-width: 720px) {
  :lang(en):has(.lang-switch) .daily-builder .form-inline > .field:has(> select[id^="daily-camera-"]) > select { flex: 1 1 100%; }
}

/* console-i18n-v2-b7 admin: English-only rules for the Admin -> Users page (U01).
   1. At 720px and under the user table is a list of cards, each line "label value"; the label column is --admin-card-label = 5em (users: sized
      for the widest Chinese column name, 最近活动 = 4 characters). The English column names are longer: "Capabilities" is 71.6px wide at 13px (system-ui;
      65.9px with Segoe UI named explicitly; both are wider than 5em = 65px) and was broken in the middle of the word ("Capabiliti / es"). The label column is 6.5em here (84.5px, a full em of room for
      another font); the value column simply gets 19px less. The base rule writes grid-template-columns on .admin-card-table > tbody > tr > td, so
      this one writes the same property on the same cells (the segment may not set a custom property: tokens_only refuses a "--" declaration).
   2. The 待改密 flag next to an email is "Password change pending" in English: an inline-block pill that wrapped to two lines inside a narrow
      email column (768px, where the table scrolls sideways). One line; the email column gets a little wider, only in English. */
@media (max-width: 720px) {
  :lang(en):has(.lang-switch) .admin-users-table > tbody > tr > td { grid-template-columns: 6.5em minmax(0, 1fr); }
}
:lang(en):has(.lang-switch) .admin-users-table .admin-flag { white-space: nowrap; }

/* console-i18n-v2-b7 admin: English-only rules for the Admin -> Audit page (U02).
   1. At 1023px and under each audit record is a card, each line "label value"; the label column is --audit-card-label = 3em (sized for the two-character
      Chinese names 用户 / 项目 / 详情). The English "Project" and "Details" are about 3.7em wide and were broken in the middle of the word
      ("Projec / t", "Detail / s"). The label column is 4.5em here; the value column gets a little less. The base rule writes grid-template-columns on
      these cells, so this one writes the same property on the same cells (the segment may not set a custom property: tokens_only refuses a "--" declaration).
   2. At 720px and under the filters are one line per control, the label column is --audit-label-w = 4.5 x --fs-sm = 58.5px (sized for the four-character
      Chinese 动作类型 / 动作前缀). "Action type" and "Action prefix" are about 66px and 80px wide at 13px and were cut off ("Action pret"); the label column is
      88px in English (written as a sum of --sp-* steps: this segment's spacing properties may only use them, and the Reset button and the date-range
      message, which are indented to the control column, move with it: 88px + the 8px gap = 96px). */
@media (max-width: 1023px) {
  :lang(en):has(.lang-switch) .admin-audit-table.cm4-table > tbody > tr.admin-audit-row > td:not(.admin-cell-action) { grid-template-columns: 4.5em minmax(0, 1fr); }
}
@media (max-width: 720px) {
  :lang(en):has(.lang-switch) .admin-audit-filters .admin-filter-field { grid-template-columns: calc(var(--sp-6) + var(--sp-6) + var(--sp-6) + var(--sp-4)) minmax(0, 1fr); }
  :lang(en):has(.lang-switch) .admin-audit-filters > [data-action="reset"] {
    flex: 0 1 calc(100% - var(--sp-6) - var(--sp-6) - var(--sp-6) - var(--sp-4) - var(--sp-2));
    margin-left: calc(var(--sp-6) + var(--sp-6) + var(--sp-6) + var(--sp-4) + var(--sp-2));
  }
  :lang(en):has(.lang-switch) .admin-audit-filters > .admin-audit-range-error {
    flex: 0 1 calc(100% - var(--sp-6) - var(--sp-6) - var(--sp-6) - var(--sp-4) - var(--sp-2));
    margin-left: calc(var(--sp-6) + var(--sp-6) + var(--sp-6) + var(--sp-4) + var(--sp-2));
  }
}

/* console-i18n-v2-b7 admin: English-only rules for the Admin -> Cameras page (U03).
   The ten-column camera table is 1268px wide in English with the Chinese rules (the Chinese one: 990px at 1280, 1066px at 1440, where it fits): the
   column headings and the three status columns (Provisioning / Registration packet / HMAC, nowrap by the base rules because the Chinese words are three
   characters) cannot break, so it scrolled sideways 278px at 1280 and 202px at 1440. Table layout only (721px and up; the cards below keep their rules):
   1. the column headings may break between words ("Hardware generation", "Registration packet", "Last STM32 report");
   2. the Provisioning / Registration packet / HMAC cells may break between words ("Not configured", "Not registered", "Not applicable"; every row is
      already two lines high because of the issue time and the last-report times). Their .status-text carries overflow-wrap: anywhere (base rule), which
      squeezes the column down to one letter and breaks the words in the middle ("Provisione / d"): back to the normal rule, the way .admin-when does it;
   3. the cell padding is 8px on each side instead of 12px (--sp-2 instead of --sp-3, the value .cm4-table already uses at 480px and under): the longest
      unbreakable words ("Provisioned", "applicable", "registered") need the room;
   4. the name column's minimum is 6em, not 7em (the Chinese words are shorter; the project column keeps 7em).
   The table is then 966px wide from 721px up, so it fits the content area from 1280px (990px) without a sideways scroll, as the Chinese one does; below that
   it scrolls, as the Chinese one does (the base rule: min-width 940px). */
@media (min-width: 721px) {
  :lang(en):has(.lang-switch) .admin-cameras-table th,
  :lang(en):has(.lang-switch) .admin-cameras-table td { padding-left: var(--sp-2); padding-right: var(--sp-2); }
  :lang(en):has(.lang-switch) .admin-cameras-table thead th { white-space: normal; }
  :lang(en):has(.lang-switch) .admin-cameras-table .admin-cell-prov,
  :lang(en):has(.lang-switch) .admin-cameras-table .admin-cell-reg,
  :lang(en):has(.lang-switch) .admin-cameras-table .admin-cell-hmac { white-space: normal; }
  :lang(en):has(.lang-switch) .admin-cameras-table .admin-cell-prov .status-text,
  :lang(en):has(.lang-switch) .admin-cameras-table .admin-cell-reg .status-text,
  :lang(en):has(.lang-switch) .admin-cameras-table .admin-cell-hmac .status-text { overflow-wrap: normal; }
  :lang(en):has(.lang-switch) .admin-cameras-table .admin-cell-name { min-width: 6em; }
}

/* console-i18n-v2-b7 admin: English-only rules for the New camera form of the Admin -> Cameras page (U03, layout fix).
   The unit number is one line, a flex row (.admin-unit-id): the "FixCamera-" prefix, the generation select and the number box. lib/dom.js createFieldErrors
   puts a field's error right after that field, so the error ("Enter the 5-digit number", "Unit number 00003 is already used by camera FixCamera-A00003")
   became a fourth item of the row, squeezed into 43-47px at 360-390px, where .admin-field-error p's overflow-wrap: anywhere broke "number" in the middle
   ("numb / er"; the second sentence took eight lines). The Chinese row is squeezed the same way, but its characters may break anywhere and it stays as it
   is. In English, while the row holds an error it may wrap and the error takes a line of its own under the three boxes, where every other field's error
   is (the row without an error is not touched, at any width). The number box's flex-basis is 0 meanwhile (the base rule: auto, which is its 10em maximum):
   otherwise the wrap would drop the box to a line of its own at 375px, where the three boxes fit only because the box shrinks; with a basis of 0 it takes
   what is left of the first line, up to the same 10em, exactly as before. */
:lang(en):has(.lang-switch) .admin-unit-id:has(> .admin-field-error) { flex-wrap: wrap; }
:lang(en):has(.lang-switch) .admin-unit-id:has(> .admin-field-error) > .text { flex-basis: 0; }
:lang(en):has(.lang-switch) .admin-unit-id > .admin-field-error { flex: 1 1 100%; min-width: 0; }

/* console-i18n-v2-b7 admin: English-only rule for the camera drawer's fact rows at 480px and under (U03, layout suggestion taken).
   The label column is 8.5em from 481px up and 7em (91px) at 480px and under, sized for the Chinese labels. The English labels are longer ("STM32 hardware
   version" is 153px): at 91px the longest broke into three lines ("STM32 / hardware / version", "CM4 / software / version"). At 8.5em (110.5px) no label needs
   more than two lines and the value column gives up 19.5px: measured at 375-480px on three cameras the values take as many lines as before (one more in
   one case, 28 to 29) and the list is 60-85px shorter. It is the same value the base rule writes above 480px. */
@media (max-width: 480px) {
  :lang(en):has(.lang-switch) .admin-camera-drawer .meta-list.admin-facts { grid-template-columns: 8.5em minmax(0, 1fr); }
}

/* console-i18n-v2-b7 admin: English-only rule for the Admin -> Anomaly Detection page (U04).
   At 720px and under the seven category buttons are a grid (the base rule: columns of at least 5.5em, four to a row at 375-390px) and each button is the
   site-wide .seg button (one size: the T01 guard forbids a second padding or font size). The English names are longer ("Temperature 1" is 103.7px wide at 15px
   with system-ui, 95.5px with Segoe UI, plus the button's 12px of padding on each side): in a column of 5.5em "Battery", "Temperature", "Humidity" and "Camera"
   broke before the number and left it alone on a second line while "Photos 1", "CM4 1" and "Offline 1" did not, so the row looked uneven. Columns of at least
   10em (150px at 15px, room for a three-digit count) keep every button on one line: two to a row on a phone, more as the screen gets wider. The base rule
   writes the same property on the same element; this one carries :lang(en):has(.lang-switch), so it is the more specific one and only the English page matches it. */
@media (max-width: 720px) {
  :lang(en):has(.lang-switch) .anomaly-filters > .anomaly-seg { grid-template-columns: repeat(auto-fit, minmax(10em, 1fr)); }
}

/* console-i18n-v2-b5 storage：W03 存储页「手动上传指定月份」卡的指令列表（.cm4-task-table，九列，页面 pages/storage_cm4_tasks.js taskRow）。每格原来是
   white-space: nowrap（上面 .cm4-task-table th, td），只有「说明」「结果摘要」「时间」几列可折行（2026-10-27 起中文第 3 列也折行，是上面基础规则旁的
   :lang(zh) 一条，不在这一段）。英文的「状态」和第 3 列「方式 / 指令种类」比中文长很多：
   第 4 批 lib/cm4.js 迁完以后，「Waiting for the CM4 to come online」约 215px、「Re-upload the whole month (including failed retries)」约 330px（2026-10-27 改名为「Check the whole month, re-upload only missing photos」，更长，照样在 14em 内折行）（中文
   「等待 CM4 领取」约 100px、「强制重新上传整月（含失败重试）」约 190px），九列加起来 1357px，1280px 宽（内容区 1006px）横滑 351px，「操作」列的「Cancel」
   在最右边看不到（中文同宽只横滑 76px）。所以英文下这两列允许折行：状态至少 9em、方式至少 11em 至多 14em（折成两行，「Upload photos not yet uploaded」
   两行、最长的一条三行），1280px 宽九列刚好 1006px 放下、不横滑，1440px 宽行高回到 59px 的多数；1024px 以下照旧在 .table-scroll 里横滑（中文也是）。
   折行只发生在空格处（没有 overflow-wrap），状态列的图标和文字照旧在同一个 .status 里。第 3 列没有 class（页面源码 h("td", null, valueOf(modeLabel(row)))），
   所以按位置选；tests_js/storage_page_order.test.mjs 的 W03 用例钉住「第 3 个 td 就是 modeLabel(row)」。只在英文下生效，中文和没有切换入口时一条都不命中。 */
:lang(en):has(.lang-switch) .cm4-task-table td.cm4-task-state,
:lang(en):has(.lang-switch) .cm4-task-table td:nth-child(3) { white-space: normal; }
:lang(en):has(.lang-switch) .cm4-task-table td.cm4-task-state { min-width: 9em; }
:lang(en):has(.lang-switch) .cm4-task-table td:nth-child(3) { min-width: 11em; max-width: 14em; }
/* console-i18n-v2-b5 storage：W03 存储页键值对的标签列（拍照测试卡信息行、SSD 状态卡状态行、拍照结果的 dl）。三处都是“标签列 + 值列”的两列网格，标签列固定
   var(--kv-col) = 7 个 --fs-sm（91px），几张卡的值才落在同一条线上。英文里最长的标签 “Last connected”（拍照卡信息行，W03 起和 SSD 卡「从未上报但连过」那一行是同一个
   说法）在本机字体（Microsoft YaHei UI）下 93.7px，差 2.7px 放不下，永远折成两行，这一行比别的行高一倍（W02 版面测试 S1；改措辞前的 “CM4 last connected”
   要 123px，更宽）；Segoe UI 下 86.2px 放得下。SSD 卡的 “Agent version” 86.5px、“Last reported” 83.1px 放得下。有切换入口时三个网格的标签列一起放宽到
   7.5 个 --fs-sm（97.5px，两种字体下余量 3.8px / 11.3px），几张卡仍是同一条线；不能改 --kv-col 本身（本段不许声明新的自定义属性，见 test_console_css.py 的
   check_i18nsw_tokens_only），所以直接盖三个网格的 grid-template-columns。只在英文下生效，中文和没有切换入口时一条都不命中。 */
:lang(en):has(.lang-switch) .storage-page .cm4-capture-card .st-row:has(> .st-label),
:lang(en):has(.lang-switch) .storage-page .cm4-status .st-row,
:lang(en):has(.lang-switch) .storage-page .cm4-capture-rows { grid-template-columns: calc(var(--fs-sm) * 7.5) minmax(0, 1fr); }

/* console-i18n-v2-b8 english-pass：— E02 shell, readings。读数格：总览相机卡（pages/overview.js readingRow）和地图单台气泡（lib/map_markers.js readingRow）的三格读数
   「Battery voltage / Internal temperature / Total photos」是同一种结构：dl.readings 三列网格，每格 div.reading 里 dt 名称、dd 数字加单位。英文名称长短不一，
   “Battery voltage”“Internal temperature” 在窄的列里折两行、“Total photos” 一行，数字就一个高一个低，对不齐（用户 2026-10-21「还要将内容对齐」）。
   英文下每格改成子网格（grid-row: span 2，名称和数字两行由父网格 .readings 的两行共用，同设备状态页读数瓦片 B2 的做法）：同一排三格共用名称行的高度，
   名称折几行，三格的数字都落在同一条线上；名称都是一行时每格和以前一样高。父网格和子网格的行距都归零（原来 .readings 的 gap 只有一排，行距不起作用，
   现在有两行，不归零数字会离名称远 8px）。dd 不折行（white-space: nowrap）：数字和 V、°C 之间的单位（上面 B2 把单位改成了 inline-block，有换行点）不会掉到下一行；
   “Total photos” 一格在英文下根本没有单位节点（lib/chart.js hidesReadingUnit），这里管的是另外两格的 V 和 °C。
   选择器限在 .cam-card 和 .map-bubble-readings 里面，不碰 .readings / .reading 本身：存储页 SSD 状态卡（pages/storage_cm4.js，dl.readings.cm4-figures）也用这两个类，
   那里没有单位节点，英文和中文都一个像素不动。中文和没有切换入口时一条都不命中。 */
:lang(en):has(.lang-switch) .cam-card .readings,
:lang(en):has(.lang-switch) .map-bubble-readings { row-gap: 0; }
:lang(en):has(.lang-switch) .cam-card .readings > .reading,
:lang(en):has(.lang-switch) .map-bubble-readings > .reading { display: grid; grid-row: span 2; grid-template-rows: subgrid; row-gap: 0; }
:lang(en):has(.lang-switch) .cam-card .reading dd,
:lang(en):has(.lang-switch) .map-bubble-readings .reading dd { white-space: nowrap; }
/* 刷新控件（lib/ui.js refreshControl）：「更新于 …」和「· 每 N 秒自动刷新」各在一个 white-space: nowrap 的 <span> 里，只有它们中间的空格能折行，英文一行放不下时第二行就以「·」开头
   （第 5 批 W03 S3 记下的，360 / 375 宽实测）。英文词典值 ui.refresh.autoEvery 现在是 U+2060（把「·」粘在前一段上）加「· 」加一整段不可拆的 “Auto‑refresh every 60 s”
   （连字符 U+2011、数字前后 U+00A0），整个周期里只剩「·」后面一个空格能折行；这一条放开周期 span 的 nowrap，让浏览器能在那个空格处折：放不下时第一行以「·」结尾、
   第二行是完整的 “Auto-refresh every 60 s”，放得下时和以前一样一行。只在英文下生效；中文的周期 span 仍是 nowrap，DOM 和样子一个字节不变。 */
:lang(en):has(.lang-switch) .refresh-ctl-period { white-space: normal; }

/* console-i18n-v2-b8 english-pass：— E03 settings, schedule, camera, storage。「留给第 8 批」的版面小修，只在英文下生效（每条挂 :lang(en):has(.lang-switch)），中文和没有切换入口时一条都不命中。
   L03 保存条灰按钮说明（deliver.idle.waiting，相机参数、存储两页，拍摄计划页第 3 批已有同一条）：768 宽时英文说明自己占满一行，按钮换到第二行，.spacer 留在第一行，
   第二行的按钮就靠左；让换行后的按钮仍靠右，和中文换行时、拍摄计划页一样。一行放得下时 .spacer 吃掉余宽，这一条不起作用；≤480px 的紧凑条（.is-compact）有自己的排法，不动。
   L10 拍摄 / 上传周表星期列（≤480px 的网格第一列 2.2em）：375 / 390 宽的英文粗体 “Mon”“Wed” 比列宽 2px，放宽到 2.4em。
   L11 拍照间隔行：481–860px 之间英文的 “Seconds” 单独折到第二行最左、两个数字框上下不齐；让“关闭定时拍照”先独占一行（≤480 已经这样，上面 T 段），两个数字框并排。
   L13 历史抽屉状态胶囊（.hist-tag）、L19 相机参数页锁定药丸（.camera-lock）：英文折成两行时 999px 的圆角把框拉成椭圆、压到首字母；圆角改成字号的 0.9 倍（比一行高度的一半大，
   一行时仍是药丸、一个像素不变），两行时只是普通圆角。
   L07 存储页指令列表的状态列：「Waiting for the CM4 to pick it up」在 1280 / 1440 宽折三行（W03 的 9em 下限不够）；≥1340px 时放宽到 12em，折两行，行高回到别的行一样的 59px；
   1340px 以下不加宽：九列加起来 1280px 宽正好放进内容区（W03），再宽一点 1280 就要横滑（实测 12em 在 1280 横滑 39px、1320 起不横滑）。 */
:lang(en):has(.lang-switch) .camera-page .save-bar:not(.is-compact) .save-row,
:lang(en):has(.lang-switch) .storage-page .save-bar:not(.is-compact) .save-row { justify-content: flex-end; }
@media (max-width: 480px) {
  :lang(en):has(.lang-switch) .win-row { grid-template-columns: 2.4em auto minmax(0, 1fr) minmax(0, 1fr); }
}
@media (min-width: 481px) and (max-width: 860px) {
  :lang(en):has(.lang-switch) .schedule-interval-row > .check { flex-basis: 100%; }
}
:lang(en):has(.lang-switch) .hist-tag { border-radius: calc(var(--fs-xs) * 0.9); }
:lang(en):has(.lang-switch) .camera-lock { border-radius: calc(var(--fs-sm) * 0.9); }
@media (min-width: 1340px) {
  :lang(en):has(.lang-switch) .cm4-task-table td.cm4-task-state { min-width: 12em; }
}
/* 布局测试 F1：指令列表「说明」列（td.cm4-task-reason，上面旧规则 overflow-wrap: anywhere + min-width: 8em）。服务器的固定句子现在在英文页是英文，
   `Writing the CM4 configuration failed: …` 在 768–1280 宽被拆成 `configuratio` / `n`：anywhere 把这一列的最小内容宽度算成一个字符，表格把它压在 8em 下限，
   放不下 `configuration`（约 85px）就拆词。英文下改成 break-word（按整词算最小宽度，只有一个词本身比格子宽时才拆），左右内距从 12px 收到 8px，
   整词刚好放进 104px 的下限，1280 宽九列仍然正好放进内容区、不横滑（实测）。只在英文下生效，中文像素不变。 */
:lang(en):has(.lang-switch) .cm4-task-table td.cm4-task-reason { overflow-wrap: break-word; padding-left: var(--sp-2); padding-right: var(--sp-2); }

/* console-i18n-v2-b8 english-pass：— E04 photos, videos, admin. English-only rules for the Photos page and the six admin pages (the "left for batch 8" items L23-L33; L24, the quick ranges, is not done: see the E04 section of docs/console-i18n.md section 9.16).
   1. Photos selection bar, 480px and under (L23). The bar is one flex row in Chinese ("Selected 1 | More | Create video"). English does not fit on one row at 375px:
      "1 photo selected" (126px) + "More" (64px) + "Create time-lapse video" (197px) is 387px of a 351px bar, so the primary button dropped to a second row and the
      "More" menu (opens downward from its button, 161px wide) opened right over it. The count takes a row of its own, so the second row is "More" on the left and the
      primary button on the right (261px with the gap, it fits down to 320px) and the menu opens over the photos below, not over a button. At 451-480px the three
      would still fit on one row; the bar has two rows there too, a deliberate price for one rule that does not depend on how many digits the count has.
   2. Photos Date range mode, 720px and under (L25). "Start date" is 61px and "End date" 52px wide, so when the two fields are stacked the two date boxes started 9px apart.
      Both labels are at least 5 --fs-sm wide (65px, room for Segoe UI as well), so the boxes start on the same line. Above 720px the fields sit side by side and nothing changes.
   3. Admin -> Audit, the "Admin actions / Sign-ins" prefix buttons in the opened filter, 720px and under (L29). Both buttons had the same width (flex-basis 0), 126px each at
      375px, and "Admin actions" (122px with its padding) broke into two lines next to a one-line "Sign-ins". Each button is as wide as its text first and the rest is shared (flex-basis auto).
   4. Admin -> Projects card, 720px and under (L30). The label column is 8em, sized for "Dropbox 根目录"; "Dropbox root folder" is 125px wide at 13px and broke into two lines.
      A wider label column (10em) took 26px from the value and broke a folder path in the middle of a word at 360-375px ("/Fix-Camera/Demo/Riversid / e"). The root cell alone
      is one column instead (the label above the path, the path on the whole width, 276-306px at 360-390px): the label is one line and the path breaks, if it has to, only after a
      hyphen or a slash; the other rows of the card keep the 8em label column.
   5. The two admin dialogs of confirm / one-time-password kind, 480px and under, only inside the admin pages (the pages' root has the class page-admin-...; the Storage page's dialogs
      are not touched) (L30, L32). English is longer than Chinese in the same dialogs: the delete confirmation was 794px high and scrolled 28px in an 800px window (Chinese 747px), the
      one-time password's second step 836px and scrolled 70px (Chinese 696px). The gap between the blocks is 8px instead of 12px, the lines of the list of what is deleted 4px apart instead of 8px,
      the red / yellow notice one step smaller (--fs-md), the explanation under the password --fs-sm: the delete-user dialog 751px (Chinese 747px) and the password's second step 762px, no scrolling at 800px from 375px up. The delete-project dialog has one more row of text and still scrolls in an 800px window: 30px at 360px and 8px
      at 375-414px (74px / 50px before; Chinese scrolls 28px / 5px there), the buttons stay in view from 375px up.
   6. The same one-time password dialog at 374px and under (360px phones). The login-details block holds "One-time password: <16 characters>" in a monospace face, which
      cannot fit on one line at 360px, and the second step was 805px high in an 800px window (scrolling 39px, the Close button cut in half). The block is one step
      smaller (--fs-xs) and the line height of the password boxes 1.35: 758px, no scrolling (Chinese 722px). */
@media (max-width: 480px) {
  :lang(en):has(.lang-switch) .sel-bar.is-compact .sel-count { flex: 1 1 100%; }
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-danger,
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-confirm,
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-secret { gap: var(--sp-2); }
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-danger-items dd + dt { margin-top: var(--sp-1); }
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-danger-warn,
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-confirm-warn,
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-secret-warn { font-size: var(--fs-md); }
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-secret-note { font-size: var(--fs-sm); }
}
@media (max-width: 720px) {
  :lang(en):has(.lang-switch) .photo-date-row .photo-range > .field > label { min-width: calc(var(--fs-sm) * 5); }
  :lang(en):has(.lang-switch) .admin-audit-filters .admin-action-seg button { flex: 1 1 auto; }
  :lang(en):has(.lang-switch) .admin-projects-table > tbody > tr > td.admin-cell-root { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 374px) {
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-secret-command { font-size: var(--fs-xs); }
  :lang(en):has(.lang-switch) .page[class*="page-admin-"] .admin-secret-value { line-height: 1.35; }
}

/* mobile-topbar-menu-v1 */
/* 手机顶栏和浮动菜单（≤720px；lib/nav_menu.js 按 matchMedia("(max-width: 720px)") 挪节点，>720px 一条都不生效）。
   顶栏：第一行左 FixCamera、右「菜单」按钮，第二行两个下拉框（.pickers 沿用 ≤720px 那段的 order: 10 和整行宽度）；
   语言入口、用户名、修改密码、退出都不在顶栏里，在菜单里，所以中文、英文顶栏同高。
   菜单：铺满视口的遮罩（.menu-layer，--overlay）里靠右一个面板（.menu-panel），浮在页面上方，页面内容不动；面板里是原来的
   侧栏（#mainNav 仍带 .sidenav，链接、选中态、「管理」分组用的就是侧栏的规则）和账号区（.user-actions）。
   面板的上边距由脚本按菜单按钮的下沿量出来，写在遮罩的内联 padding-top 上；面板最高不超过遮罩的内容区，内容多了自己滚，
   页面不滚（滚动锁来自 lib/help_tip.js，规则在 ux-helptip-fix-v1 段）。
   本段只用上面已有的变量，没有新颜色、新字号、新自定义属性；每条规则要么在 ≤720px 的 @media 里，要么选择器带 .menu-layer /
   .menu-panel（这两个类只在手机路径上由脚本建出来，宽屏的 DOM 里没有）。旧的 ≤720px 顶栏规则一行没改，这里用靠后的规则盖掉。 */
@media (max-width: 720px) {
  /* DOM 顺序仍是 菜单按钮、品牌、下拉框（Tab 顺序不变），只是画出来品牌在左、按钮在右。 */
  .topbar > .brand { order: 1; }
  /* 旧规则给的是 inline-block，按钮里的字贴着上沿；inline-flex 让字落在 44px 高的正中。 */
  .topbar > .nav-toggle { order: 2; display: inline-flex; margin-left: auto; min-width: var(--control-h-touch); }
  .topbar > .spacer { display: none; }
  /* 刘海屏横放时左右各让出安全区（没有刘海时 env() 是 0，仍是 --sp-3）。 */
  .topbar {
    padding-left: max(var(--sp-3), env(safe-area-inset-left));
    padding-right: max(var(--sp-3), env(safe-area-inset-right));
  }
  /* 菜单开着时按钮抬到遮罩上面（遮罩 52，按钮 53），保持点得到；底色带一点强调色表示“按下”。 */
  .nav-toggle[aria-expanded="true"] {
    position: relative; z-index: 53;
    background: color-mix(in srgb, var(--accent) 14%, var(--surface)); border-color: var(--accent-text);
  }
}
/* 遮罩：z-index 52，高于吸底栏 5、弹出层 30、图表提示 40、轻提示 50（和弹窗、「?」底部面板一样，轻提示在遮罩下面，
   不盖住面板底部的「退出」），低于「?」底部面板 54 / 55 和弹窗 60（弹窗开在菜单上面时在它上面）。
   click 上关闭（不在 pointerdown），手指抬起后的 click 落在遮罩上被吃掉，不穿到下面的页面控件。
   遮罩上不写 touch-action: none（也不要写任何排除 pinch-zoom 的值）：面板在遮罩里面，touch-action 沿祖先链取交集，可滚动的面板能拿回
   平移、拿不回缩放，遮罩一写 none，面板里任何地方都捏不动，弱视用户放大看菜单就做不到。从遮罩上开始的手势由 lib/nav_menu.js 在
   touchstart 上 preventDefault 挡住（页面不会被带着滚），页面本身有滚动锁。 */
.menu-layer {
  position: fixed; inset: 0; z-index: 52;
  display: flex; justify-content: flex-end; align-items: flex-start;
  padding: var(--sp-2) max(var(--sp-3), env(safe-area-inset-right)) max(var(--sp-3), env(safe-area-inset-bottom)) max(var(--sp-3), env(safe-area-inset-left));
  background: var(--overlay);
}
/* 面板：宽 20rem（320px），窄到放不下就占满遮罩的内容区；最高不超过遮罩的内容区，内容多了面板自己滚、不把滚动传给页面。
   手机浏览器静止时不画滚动条，内容被切在两行之间的空白里时看上去就是“菜单到这里结束了”（管理员的「修改密码」「退出」整块藏在
   下面）。所以给面板加滚动阴影（纯 CSS）：background 的五层从上到下是——
   1、2 两层是“遮盖”（background-attachment: local，跟着内容滚）：内容顶端、底端各一块和面板同色的渐变（24px 高：靠边的 16px 是实色，
   正好盖住阴影的整 16px，再用剩下的 8px 渐隐），滚到头时盖住下面的阴影；
   3、4 两层是阴影（scroll，钉在面板的上下沿）：用 --ink 的 22% 画一道 16px 高渐隐的边，没滚到头的那一边就露出来，滚到头被遮盖盖住；
   5 是面板的底色。内容放得下时遮盖盖住两道阴影，上下两端没有残影（遮盖的实色部分要和阴影一样高：只盖住一部分时，离边 10–16px 处
   会透出一道淡灰条，深色下是淡亮条）。深色下 --ink 是白色，阴影变成一道浅色的光边。
   只用已有变量（--surface-raised、--ink、--sp-2、--sp-4、--sp-6）。 */
.menu-panel {
  width: min(20rem, 100%); max-height: 100%; overflow-y: auto; overscroll-behavior: contain; outline: none;
  padding: var(--sp-2) 0; border: 1px solid var(--border);
  background:
    linear-gradient(var(--surface-raised) var(--sp-4), transparent) top / 100% var(--sp-6) no-repeat local,
    linear-gradient(transparent, var(--surface-raised) var(--sp-2)) bottom / 100% var(--sp-6) no-repeat local,
    radial-gradient(farthest-side at 50% 0, color-mix(in srgb, var(--ink) 22%, transparent), transparent) top / 100% var(--sp-4) no-repeat scroll,
    radial-gradient(farthest-side at 50% 100%, color-mix(in srgb, var(--ink) 22%, transparent), transparent) bottom / 100% var(--sp-4) no-repeat scroll,
    var(--surface-raised);
  border-radius: var(--radius); box-shadow: var(--shadow-pop);
}
.menu-panel .sidenav { display: block; padding: 0; border: 0; background: none; }
/* 账号区：和导航之间一条分隔线；用户名在最上面（DOM 里语言入口在 #userName 前面，用 order 排，不换 DOM 顺序），
   然后是语言入口（仅 switchAvailable() 时有）、修改密码、退出。盖掉 console-i18n-v1 段里 .user-actions:has(.lang-switch)
   的靠右和 ≤640px 的换行（那是顶栏的排法）。 */
.menu-panel .user-actions {
  display: flex; flex-direction: column; align-items: stretch; flex-wrap: nowrap; justify-content: flex-start; gap: 0;
  margin: var(--sp-2) 0 0; padding: var(--sp-2) 0 0; border-top: 1px solid var(--border);
}
/* 用户名在菜单里任何宽度都显示、不截成 6em（盖掉 ≤1023px 的省略号规则；≤480px 藏用户名的规则选择器带 .topbar，到不了这里），长名字折行。 */
.menu-panel .user {
  order: -1; display: block; max-width: none; white-space: normal; overflow: visible; text-overflow: clip;
  padding: var(--sp-1) var(--sp-5) var(--sp-2) calc(var(--sp-5) + 3px);
}
.menu-panel .lang-switch { display: flex; align-self: stretch; margin: var(--sp-2) var(--sp-5) var(--sp-2) calc(var(--sp-5) + 3px); }
/* 语言按钮的焦点环（console-i18n-v1 段，outline-offset: 2px）画在按钮外面，面板是 overflow-y: auto，环伸出面板可见区的那一截会被裁掉。
   浏览器 Tab 过来时只把按钮的边框盒滚进可见区，所以给它留滚动余量（--sp-3 = 12px，比环外沿的 4px 多），环的样子和登录页、桌面顶栏一致。 */
.menu-panel .lang-switch-button { flex: 1 1 0; scroll-margin-block: var(--sp-3); }
/* 修改密码、退出：整行可点，高度不小于 --control-h-touch；字起点和导航链接对齐（3px 透明左边线加 --sp-5 的内距），不带下划线。
   选择器不是 .user-actions > .btn / .btn.link 本身，旧守卫只管那几个，这里的 flex / white-space 不受影响也不碰它们。 */
.menu-panel .user-actions > .btn {
  justify-content: flex-start; min-height: var(--control-h-touch); padding: var(--sp-3) var(--sp-5);
  border: 0; border-left: 3px solid transparent; border-radius: 0;
  text-align: left; text-decoration: none; color: var(--ink-2);
}
.menu-panel .user-actions > .btn:hover:where(:not(:disabled)) { color: var(--ink); background: color-mix(in srgb, var(--ink) 6%, transparent); }
/* 面板自己滚动（overflow），环画在行里面：offset 为负，否则第一行、最后一行和整行宽的链接的环被面板边缘裁掉。 */
.menu-panel a:focus-visible, .menu-panel .btn:focus-visible { outline-offset: -2px; }

/* mobile-sticky-banner-v1 */
/* 手机顶栏第一行吸顶 + 初始密码横幅的按钮。
   吸顶（≤720px）：顶栏第一行——左 FixCamera、右「菜单」按钮——钉在视口顶部，页面怎么滚都在；第二行（项目、相机两个下拉框）
   还在顶栏里，随页面滚走。做法是 position: fixed，不是 sticky：.pickers 在 <header class="topbar"> 里面，sticky 的顶栏
   会把第二行一起粘住，单给品牌和按钮写 sticky 又只在顶栏还在视口里时有效（约束框是父元素），所以三样各自 fixed：
   .topbar::before 是底条（--surface 底色、--border 下边线，高 --topbar-h），.brand、.nav-toggle 是画在底条上的两个字；
   .topbar::after 是文档流里的空位（整行宽、--control-h-touch 高），代替原来第一行占的地方，所以页面在顶部时顶栏的版面和
   改版前一样（普通页 113px，管理页 .pickers 被藏时 61px），.pickers 的 order: 10 和 width: 100% 照旧把它推到第二行。
   .topbar 自己不写 position / z-index（不建层叠上下文）：品牌、按钮、底条都直接参与根层叠，z-index 一律 35，同值时按
   DOM 顺序叠（::before 在最前，所以底条在品牌和按钮下面）。取值：高于页面里的弹出层 30（「?」气泡、时区弹出层），低于图表提示
   40、轻提示 50、菜单遮罩 52、「?」底部面板 54 / 55、弹窗 60。菜单开着时按钮仍是 53、仍在遮罩上面（这里把 fixed 写回给
   mobile-topbar-menu-v1 段里 [aria-expanded="true"] 的 position: relative），点得到，再点一次关闭。
   不挡内容：根滚动容器留 scroll-padding-top = 底条高 + 间隙，Tab、scrollIntoView、锚点跳转都按它避让；选择器写成
   html:has(#shell:not([hidden]))，不是光秃秃的 html（T19 的守卫不许 html / :root / body 上出现 scroll-padding），只在外壳显示时
   留，登录页不留；照片页选中照片后选择栏吸在底条下面，留白再加上选择栏的高度（--sel-bar-h，由 settings_common.js 写）。
   本段只用上面已有的变量，没有新颜色、新字号、新自定义属性、动画；每条规则要么在 ≤720px 的 @media 里，要么选择器带
   .shell-banner。旧规则（含 mobile-topbar-menu-v1 段）一行没改，这里用靠后的规则盖掉。 */
@media (max-width: 720px) {
  /* 伪元素不吃文件开头的 * { box-sizing: border-box }：不写 border-box 的话底条是 56px 加 1px 下边线 = 57px。 */
  .topbar::before {
    content: ""; position: fixed; top: 0; left: 0; right: 0; z-index: 35; box-sizing: border-box;
    height: var(--topbar-h); background: var(--surface); border-bottom: 1px solid var(--border);
  }
  .topbar::after { content: ""; flex: 0 0 100%; height: var(--control-h-touch); }
  /* 品牌留在第一行原来的位置（上内距 --sp-2，在 --control-h-touch 高的一行里垂直居中）；左边让出刘海屏安全区，和 .topbar 的内距一致。
     top 里不用 lh 单位：品牌的行高是 1.6（这里写明，和 body 继承来的一样）、字号 --fs-xl，所以一行 32px，top = 8 + (44 - 32) / 2 = 14px，
     和改版前品牌的盒子同一个矩形。lh 要 Safari 16.4+ / Chrome 109+，而带 var() 的声明在解析时不检查单位，更老的浏览器（iOS 15、
     16.0–16.3）读到 1lh 是“计算时无效”：top 退回 auto——前面再写一条不带 lh 的 top 也救不回来（后一条赢了再作废）——fixed 的品牌就落在
     静态位置（顶栏 113px 高时 y≈40），压在页面内容上而且不随页面滚。 */
  .topbar > .brand {
    position: fixed; z-index: 35; line-height: 1.6;
    top: calc(var(--sp-2) + (var(--control-h-touch) - 1.6 * var(--fs-xl)) / 2);
    left: max(var(--sp-3), env(safe-area-inset-left));
  }
  /* 按钮靠右：原来是 margin-left: auto 推到行尾，脱离文档流以后用 right 量。 */
  .topbar > .nav-toggle {
    position: fixed; z-index: 35; top: var(--sp-2); right: max(var(--sp-3), env(safe-area-inset-right)); margin-left: 0;
  }
  .topbar > .nav-toggle[aria-expanded="true"] { position: fixed; z-index: 53; }
  html:has(#shell:not([hidden])) { scroll-padding-top: calc(var(--topbar-h) + var(--sp-3)); }
  html:has(#shell:not([hidden])):has(.sel-bar.has-selection) {
    scroll-padding-top: calc(var(--topbar-h) + var(--sel-bar-h, 56px) + var(--sp-4));
  }
  .sel-bar { top: var(--topbar-h); }
}

/* 初始密码横幅：一句话加一个按钮（index.html）。图标、句子、按钮按基线对齐；放不下时按钮换到句子下面一行，而不是把句子
   挤成一条窄缝：句子的 flex-basis 是 20em 和“整行减 2em（图标的位置）”里小的那个，宽屏上图标 + 句子 + 按钮一行放得下，
   窄屏上句子占满图标右边的整行、按钮自己掉到下一行（图标永远和句子在同一行）。按钮是普通 .btn，≤720px 或粗指针下 44px
   高由“触屏高度”那一块给；这里不碰 .notice 本身（别的提示条在用）。 */
.shell-banner { flex-wrap: wrap; align-items: baseline; row-gap: var(--sp-2); }
.shell-banner > span { flex: 1 1 min(20em, calc(100% - 2em)); min-width: 0; text-wrap: pretty; }
.shell-banner > .btn { flex: none; }

/* console-i18n-v2-b9-b06 */
/* console-i18n-v2-b9 server-codes — B06 (layout test F1, retest R1): the cloud-check row of the capture test card (storage page) in English. Its text is now
   whole English sentences ("Size (6183424 bytes) and hash match"); `.cm4-capture-body .st-row { overflow-wrap: anywhere }` lets the value shrink to a
   single letter, so between 721 and 760px (value column about 95px, the photo takes the rest) "bytes" and the badge "Verified" broke in the middle of a word.
   In English this one row is a wrapping flex row instead of the label / value grid: the label keeps the grid's label column, the value wants 10em, and when
   the line cannot give it that the value goes under its label with the whole width of the row. The label column is the one the English grids of the card
   use (7.5 --fs-sm, the W03 rule above), not --kv-col (7 --fs-sm): with the narrower one the value began 6.5px left of the other rows' (B08, 900px and up:
   340px against 346.5px). Words break only when one is longer than the whole line
   (`break-word`; the pieces of the value are flex items, so they get `min-width: 0` or their minimum would be the longest word, a long path
   "Dropbox 上找不到 /Acme/…/a.jpg" ran out of the card at 320px). Only under :lang(en) with the switch on the page: the Chinese page, and the other rows of
   the card (the paths, which need `anywhere`), are not touched. */
:lang(en):has(.lang-switch) .cm4-capture-body .cm4-capture-verify { display: flex; flex-wrap: wrap; overflow-wrap: break-word; }
:lang(en):has(.lang-switch) .cm4-capture-body .cm4-capture-verify > .st-label { flex: 0 0 calc(var(--fs-sm) * 7.5); }
:lang(en):has(.lang-switch) .cm4-capture-body .cm4-capture-verify > .st-value { flex: 1 1 10em; }
:lang(en):has(.lang-switch) .cm4-capture-body .cm4-capture-verify .st-value > * { min-width: 0; }

/* ux-user-menu-v1 */
/* 桌面顶栏的用户菜单（lib/user_menu.js，需求 ux-user-menu-v1）：>720px 时顶栏右边是一个「人像 + 用户名 + ▾」按钮，点开是一张下拉面板，
   里面就是原来的账号区（.user-actions：语言入口、修改密码、退出；同一个节点，不克隆）。≤720px 整个用户菜单不显示，账号区照旧由
   lib/nav_menu.js 挪进手机菜单的面板。
   本段只写 .user-menu 一族（user-menu、-button、-panel、-icon、-prefix、-name、-chevron）。往 .user-actions、.lang-switch、.lang-switch-button、
   .btn、.user 里写的规则一律挂在 .user-menu-panel 下面；顶栏自己的规则（.topbar 的内距、间距、换行）不写在本段，只有挂在 .topbar 或
   .topbar:has(.pickers:not([hidden]))（带项目 / 相机下拉框的页面）下面、选择器以 .user-menu 一族结尾的规则（用户名的宽度分档，见下）；不写 touch-action。
   只用 :root 里的令牌：不写死颜色（没有十六进制，也不用 rgb、hsl 这类颜色函数），字号只用 --fs-*。
   层级：.user-menu 只是 position: relative，不写 z-index（不建层叠上下文）；面板 z-index 30，和「?」宽屏气泡同层，高于吸底的保存条和
   吸顶的选择栏（都是 5），低于图表提示 40、轻提示 50、手机菜单遮罩 52、弹窗 60。hidden 在文件前面是 display: none !important，
   面板写 display 不会让它失效。
   面板外框和「?」宽屏气泡（.help-tip-panel）逐项一致：--surface-raised 底、1px --border、圆角 10px（气泡也是 10px，不是 --radius）、--shadow-pop。
   面板里的账号区参照手机菜单（.menu-panel .user-actions）：竖排，整行可点，不带下划线，--ink-2 字，悬停浅底，焦点环向内；
   语言入口占满一行，English 在前。用户名按钮上已经有了，桌面上面板里的 #userName 不显示。
   用户名的宽度（ux-user-menu-v1 T06，在真实顶栏上量的）：名字最宽 12em（180px），再长的截断加省略号。带下拉框的页面（顶栏里有 .pickers）在窄的宽度上把地方让给
   下拉框：721–759px 7em，760–799px 9.5em，800px 起 12em。依据：演示数据里品牌 + 两个下拉框 + 各项间距 + 左右内距 + 按钮自己的框和图标占 604px，顶栏仍是一行
   的名字最宽 = 视口宽 − 604px（721px 117px，760px 156px，784px 起 180px 不再限制），每档留 12px 上下的余量（滚动条、字体差异）。管理页等没有下拉框的页面 721px 起
   就放得下 12em，不分档。不需要“只留图标”那一档：带下拉框的 721px 上 7em 也放得下，任何宽度都看得到名字，面板里的 #userName 因此一直只在手机菜单里显示。
   项目 / 相机名更长时下拉框更宽，和以前一样先换行（旧的 a263f9c 顶栏段当年也是按演示数据量的）。
   面板（§8 三条）：至少和按钮一样宽（长名字时左右两条边都和按钮对齐）；名字为空的过渡态不留两份间距；语言入口上方的距离和左右一样（边线 + 面板内距 + 外距 = 16px + 1px）。 */
/* 靠右：顶栏是 flex-wrap: wrap，项目 / 相机名长到两个下拉框都撑满时（名字越长，换行的宽度越大，实测 721 到 1024px 都出现过）顶栏换行，用户菜单单独落在第二行。.spacer 在第一行，
   第二行没有东西把它推到右边，它就落在最左边，面板从按钮右边向左伸，左边会伸出视口（721px 上 -118px，页面还滚不过去）。margin-left: auto 让它换行后仍在最右边，
   面板的右边和按钮的右边对齐，距视口右边至少是顶栏的右内距；不换行时 .spacer 已经吃掉了整行的剩余宽度，auto 外距是 0，位置和以前逐像素相同。 */
.user-menu { position: relative; min-width: 0; margin-left: auto; }
.user-menu-button {
  display: inline-flex; align-items: center; gap: var(--sp-2); max-width: 100%;
  min-height: var(--control-h); margin: 0; padding: var(--sp-1) var(--sp-3);
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
  color: var(--ink); font-size: var(--fs-base); white-space: nowrap; cursor: pointer;
}
.user-menu-button:hover { border-color: var(--muted); }
/* 展开时和手机的「菜单」按钮一样：底色带一点强调色，边线换成强调色。 */
.user-menu-button[aria-expanded="true"] {
  background: color-mix(in srgb, var(--accent) 14%, var(--surface)); border-color: var(--accent-text);
}
.user-menu-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.user-menu-icon { flex: none; }
/* 读屏前缀（「账号」）：视觉隐藏，读屏仍然念出来。全站没有通用的 sr-only 类，写法同前面 .picker label 那一组。 */
.user-menu-prefix {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  border: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
}
/* 长名字只在显示上截断（省略号），读屏和悬停（按钮的 title）都是全名。 */
.user-menu-name { min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 名字还没写进来（登录后到 /me 回来之前）或者没有名字：不留一个空的 flex 项，人像和 ▾ 之间只有一份间距。 */
.user-menu-name:empty { display: none; }
.user-menu-chevron { flex: none; color: var(--muted); transition: transform 150ms ease; }
.user-menu-button[aria-expanded="true"] .user-menu-chevron { transform: rotate(180deg); }
.user-menu-panel {
  position: absolute; top: calc(100% + var(--sp-1)); right: 0; z-index: 30;
  width: max-content; min-width: max(14rem, 100%); max-width: calc(100vw - 2 * var(--sp-4));
  padding: var(--sp-2) 0; background: var(--surface-raised); border: 1px solid var(--border);
  border-radius: 10px; box-shadow: var(--shadow-pop); text-align: left;
}
/* 账号区竖排；盖掉 console-i18n-v1 段里 .user-actions:has(.lang-switch) 的靠右（margin-left: auto）和前面按宽度收窄的 gap。 */
.user-menu-panel > .user-actions {
  display: flex; flex-direction: column; align-items: stretch; flex-wrap: nowrap; justify-content: flex-start; gap: 0; margin: 0;
}
.user-menu-panel .lang-switch { display: flex; align-self: stretch; margin: var(--sp-2) var(--sp-4) var(--sp-4); }
.user-menu-panel .lang-switch-button { flex: 1 1 0; min-width: 0; min-height: var(--control-h-touch); }
/* 修改密码、退出：整行可点，不低于 --control-h-touch（鼠标和触屏一样），没有下划线。 */
.user-menu-panel > .user-actions > .btn {
  justify-content: flex-start; min-height: var(--control-h-touch); padding: var(--sp-3) var(--sp-4);
  border: 0; border-radius: 0; text-align: left; text-decoration: none; color: var(--ink-2);
}
.user-menu-panel > .user-actions > .btn:hover:where(:not(:disabled)) { color: var(--ink); background: color-mix(in srgb, var(--ink) 6%, transparent); }
/* 语言入口和下面两行之间一条分隔线（只有语言入口在的时候才有，开关关着时账号区上面不画线）。 */
.user-menu-panel .lang-switch ~ .btn:first-of-type { border-top: 1px solid var(--border); }
/* 整行宽的项，环画在行里面（offset 为负），免得伸出面板的边框。语言按钮留一圈边距，环画在外面（沿用 console-i18n-v1 段的写法）。 */
.user-menu-panel .btn:focus-visible { outline-offset: -2px; }
@media (min-width: 721px) {
  .user-menu-panel .user { display: none; }
}
/* 用户名的宽度分档（依据见段头）：只在顶栏里有项目 / 相机下拉框时让给下拉框。 */
@media (min-width: 721px) and (max-width: 759px) {
  .topbar:has(.pickers:not([hidden])) .user-menu-name { max-width: 7em; }
}
@media (min-width: 760px) and (max-width: 799px) {
  .topbar:has(.pickers:not([hidden])) .user-menu-name { max-width: 9.5em; }
}
@media (max-width: 720px) {
  .user-menu { display: none; }
}
@media (pointer: coarse) {
  .user-menu-button { min-height: var(--control-h-touch); }
}
@media (prefers-reduced-motion: reduce) {
  .user-menu-chevron { transition: none; }
}
