        /* ============================================================
           app.css — 状态页组件层样式
           ============================================================
           设计令牌（颜色 / 排版 / 间距 / 动效 / 圆角）**不在这里定义**。
           唯一真源是 design/tokens.css，经 tools/sync_tokens.py 同步为
           static/tokens.css，本文件只允许通过 var() 引用。

           本文件只负责「组件怎么摆」：布局、层级、状态样式。
           需要新增一个设计常量时，先去 design/tokens.css 加令牌，
           不要在这里写裸值——tests/test_design_tokens.py 会让裸值变红。

           v12：现代杂志风（暗）—— Vercel / Linear / Stripe 那种大留白、
           超大半圆角、浮起阴影的产品质感。基础改动：
             · 卡片圆角 6 → 14（v12 tokens: --radius-sm）
             · 卡片浮起：常态双层投影 + hover 上浮更强
             · 内边距加大：device-card 20→32，hero 24→48，子模块各自呼吸
             · 状态指示器：7-8px 圆点 → 10px，外圈柔光晕更明显
             · 头部 bar：从分隔线 → 浮起的玻璃条（半透明 + blur + 投影）

           组件分区：
             基础 / 布局外壳 / 顶部 stripe / 告警横幅 / 品牌栏
             Hero 状态带 / 设备卡 / 今日时间线 / KPI
             图表卡 / 热力图 / 刷新控件 / 复制按钮 / 空态 / Toast / 页脚
             响应式断点 / 无障碍
           ============================================================ */

        /* ============================================================
           基础
           ============================================================ */
        * { box-sizing: border-box; }

        /* 字距统一归零。
           上一版给每个标题/标签各写一遍 letter-spacing（.2px / .3px / .5px / .6px
           等 22 处），本轮改版把它们全部收回 0。既然结论是"这个界面不需要
           字距微调"，就没有理由让 21 处 `letter-spacing: 0` 分散在文件里 ——
           它们只是把同一个决定抄了 21 遍。放在基础层声明一次，
           任何新写的规则默认继承 0，要加字距时再显式覆盖。 */
        html, body, button, input, select, textarea {
            letter-spacing: 0;
        }

        html, body {
            margin: var(--space-0);
            padding: var(--space-0);
            /* 现代简约：去掉 radial-gradient 极光，纯色 --bg 就够。
               上一版三层径向渐变让整页背景不均匀，扫一眼分不清哪里是内容、
               哪里是装饰。状态信息由左侧状态竖条、状态点、时间线三态承担，
               颜色是有含义的，不应该再由背景去"帮忙表达"。 */
            background: var(--bg);
            color: var(--text-hi);
            font-family: var(--font-sans);
            font-size: var(--fs-md);
            line-height: var(--leading-normal);
            -webkit-font-smoothing: antialiased;
            -webkit-text-size-adjust: 100%;
            text-rendering: optimizeLegibility;
        }
        /* v25+: 极窄屏（≤ 360px 设备视口，如 iPhone SE 1st / Galaxy Fold
           外屏）的内容防线 —— Chart.js canvas / heatmap canvas 偶尔会
           按其内部 max-width 撑大、把 .device-card 推出右侧。加
           overflow-x: hidden 在 body 上，兜住任何子元素的越界，但不切
           掉垂直滚动。这是「显示层面止血」，真正的根因（具体哪个
           子元素没有 min-width: 0）见 heatmap / chart 各自容器规则。    */
        body { overflow-x: hidden; }
        /* v25+: 极窄屏（≤ 360px 视口）兜底 —— .shell 套住整个内容
           区，max-width: 100% 防止任何内部越界把 <body> 撑宽。         */
        .shell, main, .devices, .device-card, .timeline-today, .kpi-row,
        .timeline-cells, .heatmap-wrap, .chart-wrap, .device-charts,
        .device-info, .device-name, .device-subtitle, .device-head {
            min-width: 0;
        }

        a {
            color: var(--accent);
            text-decoration: none;
            border-bottom: 1px solid transparent;
            transition: border-color var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard);
        }
        a:hover { color: var(--accent-hi); border-bottom-color: var(--accent-hi); }

        code, .mono {
            font-family: var(--font-mono);
            font-feature-settings: "tnum", "zero";
        }

        ::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); color: var(--text-hi); }

        /* ============================================================
           外壳布局
           ============================================================
           v12 现代杂志风：上下留白从 32 / clamp(48-80) 加大到
           40 / clamp(64-96)，让首屏给标题、设备卡之间留出杂志级的呼吸。
           两侧水平 padding 维持 --shell-px(流体 clamp 16-40)，不动。  */
        /* v15: shell 在宽屏上从 1600 提到 1760，让 ~1840~1920 视口下设备卡两列能
           各拿到 ~880px 而不是被 1600 限制。1440 视口下 shell 实际宽度 ~1360，
           远小于 1600，所以 1600 → 1760 在 1440 上无效。

           v16 修正：`.devices` 已改为显式断点（默认 1 列、≥1100 永远 2 列），
           不再 auto-fit 出 3 列。所以这里 max-width 1760 的全部目的是「在 2K+
           极宽视口（2560/3440）下，每张设备卡拿到最多 ~860px 而不致过宽、留白过多」
           —— 之前提的「3 列由 auto-fit 自动决定」是 v15 的设计，这一轮已废止。 */
        .shell {
            width: 100%;
            max-width: 1760px;
            margin: var(--space-0) auto;
            padding: var(--space-10) var(--shell-px) var(--shell-pb);
        }

        /* ============================================================
           顶部状态条
           ============================================================
           现代简约：去掉左右两端浅/中央饱满的渐变，直接用 2px 实色。
           状态色已降到与 text-md 同一明度档（"信号灯"→"提示"），
           不再需要靠渐变压低饱和度。状态切换时颜色突变本身就是信号，
           不需要叠加任何装饰。                                    */
        .stripe {
            height: var(--status-stripe-h);
            background: var(--border-2);
            transition: background var(--dur-base) var(--ease-standard);
        }
        .stripe.online  { background: var(--success); }
        .stripe.offline { background: var(--error); }
        .stripe.partial { background: var(--warning); }

        /* ============================================================
           头部
           ============================================================
           v12 现代杂志风：header 从「贴底分隔线」升级为「浮起的玻璃条」。
           半透明 surface + backdrop-filter blur(16px) 让头部与背景
           视觉上脱钩，像浮在画布上。这是 Vercel / Linear / Stripe 2025
           一致的处理 —— 信息密度大的页面靠这种"分层"让眼睛有锚点。
           配合底下的 stripe 状态条，头部仍然不抢状态色的注意力。 */
        header.bar {
            display: flex;
            align-items: center;
            justify-content: space-between;
            flex-wrap: wrap;
            gap: var(--space-3);
            margin-bottom: var(--space-8);
            /* header 用 backdrop-filter 已经被强制成 stacking context，
               z-index 不写就停在 auto，被后面的 hero 按 DOM 顺序压上去
               —— 主题下拉菜单会从 .bar 边缘溢出，与 hero stat 重叠时
               被 hero 抢 pointer-events。这里 z=5 让 header 整体浮在 hero
               之上，菜单再在自己 context 内用更高的 z。 */
            position: relative;
            z-index: 5;
            padding: var(--space-3_5) var(--space-5);
            background: color-mix(in srgb, var(--surface-canvas) 80%, transparent);
            backdrop-filter: blur(16px) saturate(140%);
            -webkit-backdrop-filter: blur(16px) saturate(140%);
            border: 1px solid var(--border);
            border-radius: var(--radius-md);
            box-shadow: var(--shadow-card);
        }

        /* v18: .brand 改回 <div>，里面只有 .brand-link（包 logo+title）是 <a>。
   align-items: baseline + gap --space-2：让 title / · / subtitle 三者共享同一条
   文本基线 —— 与 v17 的「同一行 baseline 对齐」语义保持一致，只是 baseline 现在
   跨 flex 兄弟项对齐（之前在 .brand-text 内）。                                */
        .brand {
            display: flex;
            align-items: baseline;
            gap: var(--space-2);
        }
        /* v18: .brand-link = 真正可点击的图标+标题。
   覆盖两层「下划线」：
     1. UA 对 <a> 的 :hover/:active 默认 text-decoration:underline；
     2. 全站 a 样式用 1px border-bottom 实现视觉下划线（hover 时 transparent
        变 accent-hi）—— 仅靠 text-decoration:none 压不住，第二条会漏出来。
   五个状态 (visited / hover / focus / focus-visible / active) 都要清掉，
   active 也包进来：鼠标按下那一刻虽短暂，浏览器仍会闪一次 border 颜色。 */
        .brand-link {
            display: inline-flex;
            align-items: center;
            gap: var(--space-3);
            color: inherit;
            text-decoration: none;
            border-bottom: 0;
        }
        .brand-link:visited,
        .brand-link:hover,
        .brand-link:focus,
        .brand-link:active,
        .brand-link:focus-visible {
            color: inherit;
            text-decoration: none;
            border-bottom: 0;
        }
        /* 键盘可达：focus-visible 给一个低调的 outline，鼠标点击不触发 */
        .brand-link:focus-visible {
            outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
            outline-offset: 4px;
            border-radius: 6px;
        }
        .brand .brand-sep { color: var(--text-lo); font-size: var(--fs-xs); }
        /* v18: brand 标记从 11px 渐变球换成 icon.jpg —— 用户指定的品牌图。
   v19: 尺寸从 22px → 26px。比标题字号 fs-xl(24) 微大 2px，让品牌图
   在视觉上稍占中心（原来 22 比标题小，icon 视觉被压成"标题装饰"），
   但不至于大到盖过 hero.status —— header 里 icon 必须明显小于下方
   的 hero 主状态，否则层级就翻车了。改图后不再需要 accent halo：
   旧渐变球是 11px 极小元素，要靠 halo 抢眼；图片本身已经自带视觉
   重量，再叠 halo 会变成"两层蓝框"，显脏。浅圆角 6px：和设计系统
   里卡片 / 按钮的 rounding 同一语言；切到 50% 圆形会裁掉 icon 顶部
   留白，把西瓜挤扁。                                                       */
        .brand .logo {
            width: 26px;
            height: 26px;
            border-radius: 6px;
            object-fit: cover;
            display: block;
            flex-shrink: 0;
            transition: transform var(--dur-base) var(--ease-standard);
        }
        /* v25+: 1.12 → 1.06。1.12 把 26px logo 拉到 29.1px，
           让 brand-link 容器向右"鼓"出 1.5px，与右边的 .brand-sep / subtitle
           产生 ~1px 的视觉错位。1.06 是 27.6px（溢出 0.8px），仍能感受到
           hover 反馈但不破环布局节奏。                                       */
        .brand-link:hover .logo {
            transform: scale(1.06);
        }
        /* @keyframes brand-pulse 已删除 —— 现代简约不需要常驻动画。 */
        /* v18: 标题 fs-lg(15px) → fs-xl(24px) —— 用户要求头部更醒目。
           fs-xl 在设计令牌里的语义是「主箭头 — 标题 / hero 状态」，
           现在 brand title 与 hero-status 共享同一字号 —— "实时状态监测"
           作为页面品牌名应与下方 hero 的"全部在线"承担同等的视觉权重，
           二者读作「页面级标题」同一家族，而不是被压成 hero 的元信息。
           subtitle 保持 fs-xs(12px)，字号差 2× 形成清晰的"主 + 副"层次。  */
        .brand .title {
            font-size: var(--fs-xl);
            font-weight: var(--weight-semibold);
            line-height: var(--leading-snug);
            /* v14：标题字距 -0.01em，与下方 device-name / hero-status 同一节奏，
               让"面板标题 / 卡片标题 / hero 状态"三大标题在视觉上读作同一家族。 */
            letter-spacing: var(--text-spacing);
        }
        /* v25+: mobile (<640) 把 brand title 从 fs-xl(24) 收到 fs-lg(15)。
           24px 在 360-480 视口占 ~5-7% 屏幕宽，比 header 里的时钟 14px 大 70%，
           让"实时状态监测"看起来反而像"页面大字"，跟 header 的辅助元信息
           （时间、刷新、主题按钮）严重失衡。15px 与时钟同档，header 才像
           "水平排布的一行元信息"而不是"标题+元信息"的两层。
           v26+: white-space:nowrap + 360 以下隐藏 subtitle/separator。
           原版 .brand 是 baseline 对齐的 flex row，subtitle 是 flex 子项
           —— 在 360-400 视口下"status.guanjihuan.com · 3 台设备"会撑爆
           容器，把 title "实时状态监测"挤到第二行断成"实时状态 / 监测"。
           标题是品牌名不能换行；subtitle 在极窄屏本身也是冗余信息（用户
           来这个页面已经知道自己有几台设备），干脆隐藏并压缩 brand
           占用空间，让 header 第一行只剩 logo+title。                           */
        @media (max-width: 639px) {
            .brand .title {
                font-size: var(--fs-lg);
                white-space: nowrap;
            }
        }
        @media (max-width: 400px) {
            .brand .brand-sep,
            .brand .subtitle { display: none; }
            /* v30: 极窄屏（≤400px）压缩 .refresh-info —— "下次刷新" 三个字 +
               间隔 dot 占 ~70px。360-400 视口下右侧已经没有它的位置
               （连 <480 那条已隐藏的 .updated 都没让 0:25 完整露出）。
               只留 .countdown 数字 + aria-label 给屏幕阅读器，鼠标 hover
               时 .refresh-info 的 title 还能补回语义。                       */
            .refresh-info > span:not(.countdown) { display: none; }
        }
        .brand .subtitle {
            font-size: var(--fs-xs);
            color: var(--text-xlo);
            /* v14：letter-spacing 0.01em，与下方时间码风格保持一致 */
            letter-spacing: 0.01em;
        }
        /* v17: 时钟区从两行竖排压成单行 —— flex row + baseline 对齐，
           与左侧品牌区的 title/subtitle 同行处理对称；text-align: right
           在 row 模式下保留，确保 "刚刚更新" 在 flex-end 容器里仍然靠右贴齐。 */
        .now {
            display: flex;
            flex-direction: row;
            align-items: baseline;
            gap: var(--space-2);
            font-size: var(--fs-sm);
            color: var(--text-md);
            text-align: right;
            justify-content: flex-end;
        }
        .now .clock {
            font-family: var(--font-mono);
            font-size: var(--fs-md);
            color: var(--text-hi);
            font-feature-settings: "tnum";
            font-variant-numeric: tabular-nums;
        }
        .now .updated {
            font-size: var(--fs-2xs);
            color: var(--text-lo);
            font-family: var(--font-mono);
        }

        /* header 右侧组合：时钟 + 刷新控件 + 主题切换
           v18: align-items: center → baseline —— 旧版把每个子元素（clock 14px /
           refresh-info 11px / btn 12px+padding）的"盒子"中心对齐，但字号不同
           时文字基线会错开（clock 文字沉在盒子下半部，refresh-info 文字贴在
           盒子正中，按钮文字贴在 padding 中间），看着像"上上下下"。
           改 baseline 后所有文字共享同一基线 —— 与品牌区内部 .brand-text
           的处理一致：title/subtitle 基线对齐，中间用 · 分隔。                */
        .header-right {
            display: flex;
            align-items: baseline;
            gap: var(--space-4);
            flex-wrap: wrap;
        }
        @media (max-width: 639px) {
            .header-right { justify-content: flex-end; }
        }
        /* v30: 极窄屏（≤480px）隐藏 .now .updated（"X 秒前更新"）。
           在 360-400 视口，header 右侧同时塞 clock + updated + 下次刷新 + 0:25 +
           刷新 + 主题 按钮会撑爆容器，让 "0:25" 被 button 挡掉。
           clock 已经能告诉用户"这是当前时间"；"距离上次刷新多久"由右侧
           倒计时承担（0:30 跳到 0:00 时就是"刚更新完"的信号）—— 两个
           概念重复，省掉 updated 就够了。 */
        @media (max-width: 480px) {
            .now .updated { display: none; }
        }

        /* ============================================================
           Hero 状态带
           ============================================================
           配色纪律：**一个状态 = 一处颜色**。

           上一轮之前，Hero 这一块把同一个"离线"语义染了三遍：
             左侧竖条 + 圆点 + 标题文字，三处全是 --error。
           加上顶部 stripe 和三张设备卡的竖条/圆点/状态文字，
           "全部离线"这一屏出现了十几个红色元素 —— 每个都在喊同一句话，
           结果反而没有一处突出。而且标题已经写着"全部离线"，
           把它染成红色不增加任何信息，只增加视觉噪声。

           现在的分工：
             彩色 = 圆点（唯一的"当前状态"指示器，任何时候都能扫到）
             中性 = 竖条与文字（负责定位与阅读，不参与喊话）
           这样一屏里"红色"只剩一个 8px 的点，其余靠文字和结构说话。

           上一版背景是纯 surface-canvas。这一版改成 state-aware 的
           径向渐变：从左上角向四周淡出，状态色浓度仅 6%~9%，远低于
           任何 surface 分层 —— 不与卡片形成对比，读者不会误以为
           "这是另一张卡"。意义是把状态色"渗"进 Hero 的背景：
           在线时背景微微发绿、离线时微微泛红，第一眼就能感觉到
           "整体是什么状态"，而不必专门去看圆点。                       */
        .hero {
            display: grid;
            grid-template-columns: minmax(0, 1fr);
            gap: var(--space-7);
            /* v15：内边距 40/40 → 32/40，让 hero 在不撑高首屏高度的前提下更紧凑。
               纵向 32 比设备卡 padding 28 略大 4px —— hero 是页面最大浮起单元，
               保留这点层级差与下方设备卡形成"主从"；横向 40 保留，让
               "全部离线"这种长标题与左侧 indicator 拉开距离，不贴住圆点。     */
            padding: var(--space-8) var(--space-10);
            margin-bottom: var(--space-10);
            background: var(--surface-canvas);
            border: 1px solid var(--border);
            border-left: 3px solid var(--border-2);
            border-radius: var(--radius-md);
            box-shadow: var(--shadow-card);
            transition: border-left-color var(--dur-base) var(--ease-standard),
                        background var(--dur-base) var(--ease-standard);
        }
        /* v25+: mobile (<480) hero padding 32/40 → 20/24。
           40px 在 360 视口是 11%，整个 hero 在窄屏第一屏就只看到 hero 顶部。
           改成 20/24 后"全部离线"标题、第一行 sub、stats 第二行 都能塞进首屏。
           同步把 border-left 从 3px 收到 2px（24 padding 下 3px 竖条占 12.5%，
           跟 hero 内容区比例失衡）。                                            */
        @media (max-width: 480px) {
            .hero {
                padding: var(--space-5) var(--space-6);
                border-left-width: 2px;
            }
        }
        /* v13: 状态色"渗"进 hero —— 从左上角向四周淡出的径向渐变。
           颜色浓度仅 6~9%（远低于 surface 分层），不会让人误以为
           "这是另一张卡"。状态色同时由三处表达：
             1) 左侧 3px 描边色（最强信号，视觉锚点）
             2) hero-indicator 中央圆点（点状信号，可被迅速识别）
             3) 背景左上角 tint（让 hero 在房间里有自己的色调）
           第一眼进页时 tint 是最先到达眼睛的——比圆点和描边都更早。  */
        .hero.online {
            border-left-color: var(--success);
            background:
                radial-gradient(circle at 0% 0%,
                    color-mix(in srgb, var(--success) 9%, var(--surface-canvas)) 0%,
                    var(--surface-canvas) 65%);
        }
        .hero.partial {
            border-left-color: var(--warning);
            background:
                radial-gradient(circle at 0% 0%,
                    color-mix(in srgb, var(--warning) 8%, var(--surface-canvas)) 0%,
                    var(--surface-canvas) 65%);
        }
        .hero.offline {
            border-left-color: var(--error);
            background:
                radial-gradient(circle at 0% 0%,
                    color-mix(in srgb, var(--error) 7%, var(--surface-canvas)) 0%,
                    var(--surface-canvas) 65%);
        }

        .hero-main {
            display: flex;
            align-items: baseline;
            gap: var(--space-4);
            position: relative;
            min-width: 0;
        }

        /* 大状态指示点：静态实心点 + 极淡光晕。
           上一版是 64px 容器 + 2.4s 无限脉冲环 + 28px glow + 两层外扩光圈，
           并且 hero-status 用了 56px 巨字 + 40px text-shadow 发光。
           一个静态页面挂三处无限循环动画，会让"是否真的在线"这件事
           始终在动 —— 反而削弱了"变化"的信号力：真正该被注意的瞬间
           （设备掉线）淹没在常态的呼吸里。

           v12：指示点 8 → 10，外圈柔光晕 4 → 8px。状态变化时颜色渐变就好，
           不依赖任何脉冲动画 —— 让"变化"只在真正发生时出现。         */
        .hero-indicator {
            flex: 0 0 auto;
            width: 14px;
            height: 14px;
            position: relative;
            align-self: center;
        }
        .hero-indicator-pulse { display: none; }
        .hero-indicator-dot {
            position: relative;
            width: 10px;
            height: 10px;
            border-radius: 50%;
            background: var(--neutral);
            /* v15：inset 0 1px 0 var(--p-white) 30% — 给圆点顶部加一道
               极淡白色高光，让点看起来"鼓"起来而不是平的。配合外圈
               8px 光晕 + 20px 远场辉光，三层结构（高光/实色/光晕）让
               状态点更立体，符合 macOS / iOS 状态指示的视觉语言。        */
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent);
            transition: background var(--dur-base) var(--ease-standard),
                        box-shadow var(--dur-base) var(--ease-standard);
        }
        .hero.online  .hero-indicator-dot {
            background: var(--success);
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent),
                        0 0 0 8px color-mix(in srgb, var(--success) 22%, transparent),
                        0 0 20px color-mix(in srgb, var(--success) 28%, transparent);
        }
        .hero.offline .hero-indicator-dot {
            background: var(--error);
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent),
                        0 0 0 8px color-mix(in srgb, var(--error) 22%, transparent),
                        0 0 20px color-mix(in srgb, var(--error) 28%, transparent);
        }
        .hero.partial .hero-indicator-dot {
            background: var(--warning);
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 35%, transparent),
                        0 0 0 8px color-mix(in srgb, var(--warning) 22%, transparent),
                        0 0 20px color-mix(in srgb, var(--warning) 28%, transparent);
        }
        /* v25+: mobile (<480) 缩 halo —— 8px halo + 20px 远场辉光在 360 视口上
           占首屏很高的视觉权重，状态点"看起来像信号灯而不是状态指示"。
           改成 4px halo，保留 inset 高光 + 一层轻辉光。
           注意不能用 currentColor：dot 的 background 是 var(--success) /
           var(--error) / var(--warning)，但 box-shadow 不继承 currentColor，
           必须按状态分别赋值（否则 offline 设备的 halo 会用 success 色）。    */
        @media (max-width: 480px) {
            .hero.online .hero-indicator-dot {
                box-shadow:
                    inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent),
                    0 0 0 4px color-mix(in srgb, var(--success) 22%, transparent);
            }
            .hero.offline .hero-indicator-dot {
                box-shadow:
                    inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent),
                    0 0 0 4px color-mix(in srgb, var(--error) 22%, transparent);
            }
            .hero.partial .hero-indicator-dot {
                box-shadow:
                    inset 0 1px 0 color-mix(in srgb, var(--p-white) 35%, transparent),
                    0 0 0 4px color-mix(in srgb, var(--warning) 22%, transparent);
            }
        }

        .hero-text { min-width: 0; flex: 1 1 auto; }
        /* v12 现代杂志风：hero status 24 → 32（display 段），与下面
           26px 数字形成 32 / 26 的层级 —— 让"标题字号"和"数字字号"
           之间拉开足够距离。字重仍是 weight-medium(500)，避免大字号
           上的喊话感。                                          */
        .hero-status {
            font-size: var(--fs-3xl);  /* tokens.css: 24 → 32 */
            font-weight: var(--weight-medium);
            line-height: var(--leading-tight);
            color: var(--text-hi);
            letter-spacing: var(--text-spacing);
            transition: color var(--dur-base) var(--ease-standard);
        }

        .hero-sub {
            font-size: var(--fs-sm);
            color: var(--text-lo);
            margin-top: var(--space-2);
            margin-bottom: var(--space-2);
            transition: color var(--dur-base) var(--ease-standard);
        }
        /* v13: hero-sub 里加粗的数字跟随状态色 —— "1 台在线" 的 1
           跟 success 色，"2 台离线" 的 2 跟 error 色。这是让副标题
           也承载"状态分布"信息，避免读完大标题还要再读一遍数字。*/
        .hero-sub strong { font-weight: var(--weight-semibold); transition: color var(--dur-base) var(--ease-standard); }
        .hero.online  .hero-sub strong:first-of-type { color: var(--success-hi); }
        .hero.offline .hero-sub strong:last-of-type  { color: var(--error-hi); }

        .hero-meta {
            font-size: var(--fs-sm);
            color: var(--text-md);
            word-break: break-word;
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: var(--space-0) var(--space-3);
        }
        .hero-meta code { color: var(--text-hi); font-size: var(--fs-xs); }

        /* Hero 右侧：4 个关键数字，用竖线分隔而不是各自装进小卡。
           上一版每个数字都套一个半透明底 + 圆角 + hover 上浮，
           于是"统计值"和"可点的按钮"长得一样。数字不该看起来可点。
           v12：行间距 12/20 → 16/28，给 32px 数字留出上下呼吸。
           v14：4 列 grid 列间距 28 → 32（每列宽 ~120px 时，数字 79% 加 unit 仍宽松）；
                列项与列项之间多了 4px 呼吸，避免"%"和下一个数字之间挤在一起。
                行间距 16 维持原值（两行布局时仍需要紧）。 */
        .hero-stats {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: var(--space-4) var(--space-8);
            position: relative;
            padding-top: var(--space-5);
            border-top: 1px solid var(--border);
        }
        @media (min-width: 640px) {
            .hero { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); align-items: center; }
            .hero-stats {
                padding-top: var(--space-0);
                padding-left: var(--space-9);
                border-top: 0;
                border-left: 1px solid var(--border);
            }
        }
        @media (min-width: 960px) {
            .hero-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
            /* v33+：4 列布局下每张 stat 之间加竖向 hairline，把 4 个数字
               划分成"4 个独立锚点"而非一长串连读的色块。用 :not(:first-child)
               + left-border 实现 —— 不增加 DOM 节点。border 在 surface-canvas
               之上、accent 30% 透出，几乎察觉不到分裂，只在扫视时给眼睛
               提供步进节拍。                                                  */
            .hero-stat + .hero-stat {
                padding-left: var(--space-7);
                border-left: 1px solid color-mix(in srgb, var(--border-2) 60%, transparent);
            }
        }

        .hero-stat {
            display: flex;
            flex-direction: column;
            gap: var(--space-1);
            min-width: 0;
        }
        .hero-stat-label {
            font-size: var(--fs-2xs);
            /* v16: xlo → lo。hero 区 4 个 "近 72h 在线率" 是首屏第二扫描点
               （仅次于大状态），11px 上维持 xlo (≈ 6.8:1) 仍偏弱，
               升 lo (≈ 7.85:1, dark) 让「在线率」/「在线设备」类标签
               一眼可读，不依赖 hero tint 的语义提示。lo 与 xlo 的明度差
               仅 ≈ 1.15 倍，仍保留「次要文字」的非主层级感。            */
            color: var(--text-lo);
            font-weight: var(--weight-regular);
            white-space: nowrap;
            /* v14：标签字符略略放松到 0.01em，让"近 72h 在线率"这种短词组
               在窄列里读起来不像散落字母。负字距在大字号上收紧，
               但 11px 标签需要的是"易扫"而不是"成块"，轻微正字距帮助切分。 */
            letter-spacing: 0.01em;
        }
        .hero-stat-value {
            font-family: var(--font-mono);
            font-weight: var(--weight-semibold);
            line-height: var(--leading-tight);
            /* v30：text-hi → accent。KPI 大数字是 hero 区最显眼的色块，
               跟随调色板让"切换调色板"产生即时视觉反馈。--p-pal-amber-700
               在 light 组合下保证 AAA 文字对比度
               （见 themes.css 复合选择器块覆盖）。unit（%/次）保持 text-lo
               不变，让主数字独占色彩身份。
               v31+：emerald 调色板已下架，复合选择器只剩 light × amber。 */
            color: var(--accent);
            /* v12 现代杂志风：26 → 32，与 hero-status 同档但视觉上
               因为是数字（mono + tabular）会有"硬数据"的视觉重量。 */
            font-size: var(--fs-3xl);
            font-feature-settings: "tnum";
            font-variant-numeric: tabular-nums;
            letter-spacing: var(--text-spacing);
            display: inline-flex;
            align-items: baseline;
            gap: var(--space-1);
            /* v33+：32px 大数字在暗底上若没阴影会更像"贴上去的字"而不是
               "刻上去的字"。1px accent 透出阴影（28% 透明度）让数字
               边缘多一道极淡的"光"，但不变成发光的嘈杂感。              */
            text-shadow: 0 1px 0 color-mix(in srgb, var(--accent) 28%, transparent);
            /* v30：加 color 进 transition，让切调色板时数字从 indigo 平滑
               过渡到 amber，而非突兀闪变。device-name 已自带此
               transition，hero-stat-value / kpi-num 之前漏掉，本轮补齐。 */
            transition: color var(--dur-base) var(--ease-standard),
                        text-shadow var(--dur-base) var(--ease-standard);
        }
        /* v14：unit 字号 11 → 12，颜色 xlo → lo，让"%" / "次" / "/ 3 台"和数字
           之间读起来更平衡 —— 上一版 unit 太小，会让数字像"被砍掉一块"。
           同时 baseline 对齐 + display:inline-block 让 unit 在视觉上
           锚在数字基线上，而不是飘到下一行。 */
        .hero-stat-value .unit {
            font-family: var(--font-sans);
            font-weight: var(--weight-regular);
            font-size: var(--fs-xs);
            line-height: var(--leading-none);
            color: var(--text-lo);
            display: inline-block;
            transform: translateY(-0.08em);
        }

        /* ============================================================
           设备网格
           v16: 从 auto-fit 改成显式断点 ——
             · 默认（<1100）：1 列（手机 / 小平板）
             · 1100+：始终 2 列（标准桌面 / 宽屏 / 2K / 4K）
           旧版 auto-fit + 480min 在 1920 视口会自动跳 3 列，把
           「两列为主」的常规桌面体验吞掉；显式断点把这件事交还给
           设计意图而不是算法兜底。

           始终停在 2 列、不到 3 列：3 列在 1700+ 视口下每张卡
           ~520-570px，虽然空间够用，但「信息密度过多」是用户
           实际反馈：折线图/热力图在窄卡里被压扁，纵向滚动距离
           增加，扫一眼要看的元素变多。设备卡的内容量本来就大
           （时间线 + 3 KPI + 折线 + 热力图），2 列是这种信息
           密度下的甜点。

           1100 起跳（而不是 720）：720-1099 区间 2 列下每张卡只有
           ~340-500px，会落到 @container device 480 阈值以下，
           卡内图表/热力图都退回基础布局；保持 1 列直到 1100
           才能让卡宽稳定 ≥ ~500px，与 chart/heatmap @container
           中段断点对齐，避免「同一卡片忽大忽小」。

           v12 现代杂志风：卡间距 20 → 28（用 --space-7），给浮起阴影
           留出空间 —— 两张卡之间需要足够留白才能让"每张卡都是独立
           产品卡"的语言成立。
           ============================================================ */
        .devices {
            display: grid;
            grid-template-columns: 1fr;
            gap: var(--space-7);
        }
        @media (min-width: 1100px) {
            .devices {
                grid-template-columns: repeat(2, minmax(0, 1fr));
            }
        }

        /* 设备卡片：浮在画布上的产品卡。
           v12 现代杂志风：
             · 圆角 6 → 14（--radius-sm）
             · 内边距 20 → 32，让卡内各段都呼吸
             · 常态浮起：--shadow-card 三层投影叠加（近 1px + 中 8px + 远 24px）
             · hover：浮起更强（--shadow-card-hover） + 上移 2px
             · 卡间 gap 20 → 28，让浮起阴影不被相邻卡覆盖

           状态色由左侧 8px 圆点承载，整张卡保持中性 —— 不让整屏发红。 */
        .device-card {
            container-type: inline-size;
            container-name: device;
            background: var(--surface-canvas);
            border: 1px solid var(--border);
            border-radius: var(--radius-sm);
            /* v15: padding 32→28, gap 28→24，让 1440 视口 3 列下卡片不至于太满。
               卡内 28 + 卡间 24 = 卡内比卡间宽 4px，配合 device-charts
               margin-top: 0 取消段间额外留白，整体让 3 列下信息密度更高
               但不拥挤，避免"卡片像墙"。                                       */
            padding: var(--space-7);
            box-shadow: var(--shadow-card);
            transition: border-color var(--dur-base) var(--ease-standard),
                        box-shadow var(--dur-base) var(--ease-standard);
            min-width: 0;
            display: flex;
            flex-direction: column;
            gap: var(--space-6);
            position: relative;
            /* v25+: 极窄屏（≤ 360px 视口）兜底 —— 当某个子元素（chart/heatmap
               canvas、.status-pill 等）的固有宽度撑爆 device-card 时，overflow:
               hidden 把越界部分裁掉，不让它把整张卡横向推出视口右侧。真正的
               根因在 chart-canvas / heatmap-canvas 的 max-width 规则与 .devices
               grid 的 min-width 链，这里是显示层兜底。                          */
            overflow: hidden;
        }
        /* v22：删掉 .device-card.online/offline/idle 三条 border-color 规则。
           它们都赋值为 var(--border)，而 .device-card 默认就是这个值 —— 写
           一遍等于没写。三状态靠 .status-dot + .status-pill 表达，不靠边框
           颜色，所以边框保持单一色即可。                                    */
        /* v12 悬停：边框升一档 + 浮起阴影。卡片没有真实链接，
           cursor 仍保持 default，避免让它看起来可点。
           v14：translateY -2 → -1，缓动改成减速曲线，让浮起感更"沉"
                 而非"跳"。
           v15：去掉 translateY(-1px)。hover 时整张卡 1px 的位置跳变
                 在与 box-shadow（不同缓动曲线）同步过渡时被感知为抖动，
                 且 grid 布局里 1px transform 没有任何附加语义——
                 --shadow-card-hover 已经把模糊半径从 24px 推到 32px、
                 外延从 60px 推到 80px，阴影面积扩大约 35%，足以传达
                 "浮起更深"。只留 box-shadow + border-color 过渡，
                 卡片完全不移动。                                          */
        @media (hover: hover) {
            .device-card:hover {
                border-color: var(--border-2);
                box-shadow: var(--shadow-card-hover);
            }
        }
        /* 触屏设备没有 hover：保持默认态，避免触摸后"粘"在 hover 上 */
        @media (hover: none) {
            .device-card:hover {
                border-color: var(--border);
                transform: none;
                box-shadow: var(--shadow-card);
            }
        }

        /* v16: 窄屏（≤480px）收一下卡内 padding ——
           28px 在 320px 视口是 8.75%，240px 内容空间里像「卡里还套了框」。
           改 24px（--space-6），让窄屏卡内更透气而不是更紧凑。
           段间 gap（device-card 自身的 gap:space-6）维持 24，
           不再叠加收 —— 卡内段间距本就靠 section margin，
           在窄屏额外缩会让 KPI / chart 紧贴边缘。 */
        @media (max-width: 480px) {
            .device-card {
                padding: var(--space-6);
            }
        }

        /* 状态变化：一次性背景提示，结束后回到常态 */
        .device-card.flash-online { animation: flashOnline 1.2s ease; }
        .device-card.flash-offline { animation: flashOffline 1.2s ease; }
        @keyframes flashOnline {
            0%, 100% { background: var(--surface-canvas); }
            20%      { background: var(--success-bg); }
        }
        @keyframes flashOffline {
            0%, 100% { background: var(--surface-canvas); }
            20%      { background: var(--error-bg); }
        }

        /* 头部：设备名 + 状态点 + status-pill */
        .device-head {
            display: flex;
            align-items: center;
            gap: var(--space-2_5);
        }
        /* 设备级状态点：全页唯一承载状态色的元素之一（另一个是 Hero 圆点）。
           v12：7 → 10，加柔光晕（与 hero-indicator 对齐）。            */
        .device-head .status-dot {
            flex: 0 0 auto;
            width: 10px;
            height: 10px;
            border-radius: 50%;
            background: var(--neutral);
            /* v15：顶部 inset 白色高光，让状态点从"色块"升级为"鼓起的
               圆点"，与 hero-indicator-dot 同套视觉语言。
               这里 30% alpha 在小尺寸下不会被察觉为白色，仅提供层次。 */
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent);
            transition: background var(--dur-base) var(--ease-standard),
                        box-shadow var(--dur-base) var(--ease-standard);
        }
        .device-card.online  .status-dot {
            background: var(--success);
            color: var(--success);
            /* v13: 设备级状态点光晕 14% → 24%，与 hero-indicator 对齐。
               暗底上 status dot 周围有一圈柔光，让 10px 的小圆点
               "站得住"而不是孤立像素。
               v15：inset 高光 + 外圈光晕 + 远场辉光，三层结构。        */
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent),
                        0 0 0 5px color-mix(in srgb, currentColor 24%, transparent),
                        0 0 12px color-mix(in srgb, currentColor 35%, transparent);
        }
        .device-card.offline .status-dot {
            background: var(--error);
            color: var(--error);
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent),
                        0 0 0 5px color-mix(in srgb, currentColor 24%, transparent),
                        0 0 12px color-mix(in srgb, currentColor 35%, transparent);
        }
        .device-card.idle    .status-dot {
            background: var(--neutral);
            color: var(--neutral);
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 30%, transparent);
        }

        .device-info {
            flex: 1 1 auto;
            min-width: 0;
            /* v19：display 加上 flex/wrap，让 name+pill 与"监控起始 + 日期"
               在宽屏并排同一行 —— 之前两块都是 div（默认 block），所以无论
               如何都会各占一行。column-gap 12 把两个数据块隔开（块内各自的
               gap 仍由子规则控制），row-gap 4 让窄屏回到上下两行时仍保留
               旧版的"1px 呼吸"节奏（margin-top 1 由 device-info 接手）。  */
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            column-gap: var(--space-3);
            row-gap: var(--space-1);
        }
        .device-name {
            display: flex;
            /* v14：baseline → center。pill 用 baseline 对齐会让不同字号的
               圆点、状态文字、设备名在 baseline 上打架：圆点是 10px 圆形
               （无 baseline），pill 文字 12px，设备名 14px —— 三者的视觉
               "重心"不在同一高度。改 center 后三块以视觉重心对齐，
               整体看起来更稳。                                              */
            align-items: center;
            gap: var(--space-2_5);
            flex-wrap: wrap;
            margin-bottom: 0;
        }
        .device-name .name {
            /* v19：fs-md (14px) → fs-md-2 (16px)。设备名是卡片的主标识，
               之前 14px mono 视觉上与状态胶囊(12px)、监控起始(12px) 拉开
               不够，"主 + 副"层级弱。提到 16px 后层级清晰：16 / 12 / 12。
               仍用等宽（mono 在短 ID 类名称下看起来更稳），
               letter-spacing 略收一点，对抗 mono 在 16px 的宽度感。            */
            font-size: var(--fs-md-2);
            font-weight: var(--weight-semibold);
            /* v30：text-hi → accent，让设备名跟随调色板。设备名是设备卡
               最显眼的标识符，与 hero KPI 数字共同承担"色彩身份"语义：
               切换调色板时这一行立刻变色，给用户最直接的"切了生效"反馈。 */
            color: var(--accent);
            font-family: var(--font-mono);
            letter-spacing: -0.005em;
            /* v25+: word-break: break-all → overflow-wrap: anywhere。
               break-all 在 CJK 字符之间也会强制断行（"我的电脑"会变
               "我的 / 电脑"），丑；anywhere 只在长字符串超出容器时才断，
               CJK 字符保持原状。长 ASCII 设备名（"office-replica-01"）
               仍能优雅换行。
               v31+：emerald 调色板已下架（与 --success 同源撞色），
               设备名跟随调色板时只在 indigo / amber 之间切换。        */
            overflow-wrap: anywhere;
            line-height: var(--leading-snug);
            transition: color var(--dur-base) var(--ease-standard);
        }

        /* status-pill：状态 + 心跳时间。这是卡片里唯一的彩色元素，
           保持胶囊形态（它确实是个"标签"），但去掉描边与半透明底，
           改为纯文字 + 前置圆点 —— 颜色由点承担，文字保持中性可读。
           这一版给胶囊加回极浅底色（surface-panel 透明度 55%）与微圆角，
           让"在线 / 离线"两种状态在视觉上更分组，又不抢状态点本身的戏。
           圆点统一用 background 着色（与左侧 status-dot 同源），
           文本字符 ●/○ 设为透明避免与背景圆重叠造成锯齿。                */
        .status-pill {
            display: inline-flex;
            align-items: center;
            /* v14：gap 6 → 8，圆点与文字不再"咬"在一起 */
            gap: var(--space-2);
            /* v14：padding 2/8/2/6 → 3/10/3/8，让胶囊有上下 3px 的呼吸；
                 上下 3px 是 figma 推荐的"12px 字号 × 18px 总高"的舒适比例。 */
            padding: var(--space-0_5) var(--space-2_5) var(--space-0_5) var(--space-2);
            border-radius: var(--radius-full);
            font-size: var(--fs-xs);
            font-weight: var(--weight-regular);
            background: color-mix(in srgb, var(--surface-panel) 55%, transparent);
            transition: color var(--dur-base) var(--ease-standard),
                        background var(--dur-base) var(--ease-standard),
                        transform var(--dur-fast) var(--ease-out);
            cursor: default;
            white-space: nowrap;
            /* v14：line-height 锁 none，让胶囊高度由 padding + 字号决定，
                 多行 / 单行切换时不撑高。 */
            line-height: var(--leading-none);
        }
        .status-pill .icon {
            font-family: var(--font-mono);
            font-size: var(--fs-2xs);
            line-height: var(--leading-none);
            display: inline-block;
            /* v14：宽高 10 → 8，让 pill 内的圆点比 device-head 主点略小一档，
                 形成"主从关系"——主点是"这是什么状态"，pill 圆点是"具体标签"。
                 同样在 8px 圆点外加 3px 软光晕（color-mix 24% alpha），
                 让 pill 内圆点不至于在胶囊底色上"消失"。                     */
            width: 8px;
            height: 8px;
            border-radius: 50%;
            color: transparent;  /* 隐藏 ●/○ 字符，避免与背景圆重叠 */
            flex: 0 0 auto;
            box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 24%, transparent);
        }
        /* 在线时圆点继承状态色（沿用 device-card 的彩色点同一来源） */
        .device-card.online  .status-pill .icon { background: var(--success); color: var(--success); }
        .device-card.offline .status-pill .icon { background: var(--error); color: var(--error); }
        .device-card.idle    .status-pill .icon { background: var(--neutral); color: var(--neutral); }
        /* v13: 状态文字与胶囊底色一起染上状态色，让 pill 成为卡片里
           第二处显眼的彩色元素（第一处是 device-head 的状态圆点）。
           上一版中性文字 + 中性胶囊底色，让整张卡看起来「空有红点，没有
           状态」——用户反馈里「黑白化」的来源之一。

           v13.1: 把底色饱和度从 75% 降到 55% —— "全部离线" 场景下三张卡
           同时显示亮红胶囊会让整行充满噪声。底色降到 55% 后，
           文字承担主信号（hi 档状态色），底色只起"分组感"（与 surface
           区分即可），不再抢夺视觉注意力。                                  */
        .device-card.online  .status-pill {
            color: var(--success-hi);
            background: color-mix(in srgb, var(--success-bg) 55%, transparent);
        }
        .device-card.offline .status-pill {
            color: var(--error-hi);
            background: color-mix(in srgb, var(--error-bg) 55%, transparent);
        }
        .device-card.idle    .status-pill { color: var(--text-xlo); }

        .device-subtitle {
            font-size: var(--fs-xs);
            color: var(--text-lo);
            line-height: var(--leading-normal);
            word-break: break-word;
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: var(--space-0) var(--space-2_5);
            /* v19：删 margin-top。间距由 .device-info 的 row-gap 接管，
                 避免叠加（flex-item margin 不折叠，与 row-gap 同时存在会变 8px）。
                 v14：margin-top 2 → 3，曾为"设备名 + pill"与"监控起始"之间
                 加 1px 呼吸；并排后这条已不需要。                          */
        }
        .device-subtitle code {
            color: var(--text-md);
            font-size: var(--fs-xs);
            font-family: var(--font-mono);
            /* v14：letter-spacing 0.01em，让日期码不那么紧凑 */
            letter-spacing: 0.01em;
        }
        .device-subtitle .since-label { color: var(--text-xlo); margin-right: var(--space-1); }

        /* ============================================================
           今日时间线
           这是卡片里信息密度最高的一块，保留全部功能，去掉发光。
           当前小时仍要有明确标记 —— 但用「描边 + 加深底色」这种静态形态，
           而不是 2.4s 无限呼吸（24 格同时呼吸会让整张卡片持续闪）。 */
        .timeline-today {
            display: flex;
            align-items: center;
            gap: var(--space-3);
            /* flex-wrap：≤480px 时 .timeline-stat 被设为 flex:1 1 100%（见下方
               媒体查询）需要换行到第二行；不开 flex-wrap 时 stat 永远挤在
               同一行，把 .timeline-cells 压成 ~9px 宽的"竖条"，24 格
               时序不可读。align-items 也同步改 baseline，让 stat 在第二行
               时与左侧 label 走同一基线，而不是被 center 拉到中间。          */
            flex-wrap: wrap;
            align-items: baseline;
            /* v12 现代杂志风：内边距加大、圆角 4 → 14，让时间线感觉
               像嵌在卡里的一张独立子卡（与 device-card 主卡形成层级）。
               v14：上下 padding 14 → 12，让 timeline 与 KPI 行间距更均匀；
                 圆角 14 → 12（--radius-sm）保持子卡的视觉语言一致。       */
            padding: var(--space-3) var(--space-4);
            background: var(--surface-panel);
            border: 1px solid var(--border);
            border-radius: var(--radius-sm);
        }
        .timeline-label {
            font-size: var(--fs-2xs);
            /* v16: xlo → lo。"今日"标签与 3 张 .kpi-label 是同一卡的同区域扫描锚点，
               11px 上统一到 lo，与 kpi-label/hero-stat-label/chart-toggle-option
               同家族。lo 与 xlo 的 1.15× 对比仍把 timeline-label 框在「次要文字」
               那一档（在线/离线态的格子颜色仍远强于 lo）。                        */
            color: var(--text-lo);
            font-weight: var(--weight-regular);
            flex: 0 0 auto;
            /* v14：letter-spacing 0.02em，让"今日"两字在 11px 上不那么紧凑，
                 与下方 KPI 标签的 letter-spacing 形成节奏呼应。 */
            letter-spacing: 0.02em;
        }
        .timeline-cells {
            flex: 1 1 auto;
            display: grid;
            grid-template-columns: repeat(24, minmax(0, 1fr));
            gap: var(--timeline-cell-gap);
            min-width: 0;
        }
        /* 24 格是这个页面里最大的一块连续色面。v13：上一版的 72% 混合让绿色
           看起来像「淡绿灰块」，与中性 border 几乎同色 —— "今天开机了吗"
           这个问题用户看 timeline 0.5 秒就应该知道答案。现在用满档 success：
           满格绿色一眼可辨"今天开机了"，但因为格子小且每格 ~10px 宽，
           不会变成"满屏荧光"。hover 仍保持"上浮 1px"的微反馈。
           v14：圆角 1 → 2（仍然保持"硬方块"语言，但比 1px 多一丁点呼吸）；
                 增加 inset box-shadow 0 1px 0 高光，让格子上沿在暗底上有
                 "轻微立体感"，避免"色块"看起来只是 2D 像素。
           v31+：默认 cell 改用 --border-2（去掉 opacity），把"已过整点"/
           "未到整点"两种"非在线"状态拉到清晰可见的"实色方块"档，与 timeline
           surface-panel 之间的明度差从 ΔL≈3 拉到 ΔL≈8-10。三个原因：
             1) 暗色主题下 --border (#26272B) 在 #18181B 上面几乎不可见，
                用户看截图反馈"今日 24 格像空表"——其实有数据，是太隐形；
             2) opacity .55 让 8% 白色 inset 高光也被压成 4.4%，"立体感"
                设计意图彻底消失，格子看起来纯是 2D 像素；
             3) 24h 时间线是"扫一眼判断今天开机没"的高频信息，
                ΔL=3 不达可读性下限（业内经验 ≥ 4-5 才能被快速识别）。
           is-offline 同色同档：旧版 .75 opacity 比默认 .55 略高，但实际
           渲染后两者仍 ΔL≈2 之差，扫描时几乎分不出。统一后留出
           "在线绿"做唯一色彩对比，三态语义改成"颜色 = 在线 / 形状 = 已过-未到"。 */
        .timeline-cell {
            height: 20px;
            border-radius: 2px;
            background: var(--border-2);
            /* v31+：去掉 opacity —— 见上方注释。transition 仍保留以
                 兼容将来若有"瞬时高亮"需求。                                  */
            transition: background var(--dur-fast) var(--ease-standard),
                        transform var(--dur-fast) var(--ease-standard);
            position: relative;
            cursor: default;
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 8%, transparent);
        }
        .timeline-cell.is-online {
            background: var(--success);
        }
        /* 离线格 = 默认同色，已过整点统一显形。
           旧版用 opacity 区分 offline vs pending 实测无效（v31+ 注释详述），
           离线与默认统一为 var(--border-2) 后，扫视时三态变成
           "绿色 = 在线 / 灰色实块 = 离线 / 空心描边 = 未到"——更易读。       */
        .timeline-cell.is-offline {
            background: var(--border-2);
        }
        /* 未来小时：还没发生 —— 空心格，与"已发生"明确区分。
           v30: --border → --border-2。dark 主题 --border = #26272B 在
           #1B1D22 (timeline-today 的 surface-panel) 上几乎与底色同色
           (ΔL ≈ 3)，眼睛扫过去常把 pending 漏看成"还没轮到的小透明块"。
           提到 --border-2 (#2D2E33) 后边框 ΔL ≈ 6，浅底 (--border = 8%
           黑) 上变化不大不影响视觉重量，暗底上多出的 3-4 个明度档足够
           让 24h 轨道的"已过 / 当前 / 未到"三段一眼可分。
           v31+：空心 cell 不再使用 border 模拟，改用 outline 1px + offset
           0 —— border 会让 20px 高的格子的实际渲染高度变成 18px（border-box
           默认），与"实心 cell 高度 20px"差出 1px 一眼就能看出来，造成
           24 格轨道高低不齐。outline 不占盒子空间，格子内框仍为 20px。   */
        .timeline-cell.is-pending {
            background: transparent;
            outline: 1px solid var(--border-3);
            outline-offset: 0;
            box-shadow: none;
        }
        /* v31+：pending cell 改用 outline 而非 border —— outline 切到 accent
           不会让 20px 格子高度缩水成 18px，hover 时仍保持轨道整齐。
           底色用 12% accent 透出，给到"未到但可关注"的视觉提示。            */
        .timeline-cell.is-pending:hover {
            outline-color: var(--accent);
            background: color-mix(in srgb, var(--accent) 12%, transparent);
        }
        /* 当前小时：用 inset box-shadow 画 accent 内框 —— 取代 v14 的外 outline。
           外 outline 在"绿实色 + 蓝外框"组合里会让格子看起来"两层颜色叠加"，
           且 outline-offset 2px 在 20px 高的格子上让"现在"格被推出轨道 2px，
           与左右两格产生视觉错位。改 inset 后：
             · accent 永远在格子内部，无论格子是 online/offline/pending 都可见；
             · 不再撑大轨道、不再与外层成功绿冲突；
             · 同时保留原 inset 高光（白半透明顶部）—— 用双 inset 叠加。 */
        .timeline-cell.is-now {
            z-index: 2;
            box-shadow:
                inset 0 0 0 2px color-mix(in srgb, var(--accent) 80%, transparent),
                inset 0 1px 0 color-mix(in srgb, var(--p-white) 10%, transparent);
        }
        /* is-online + is-now：实色绿 + accent 内框同时呈现时，accent 在绿底上
           视觉对比太弱（accent #5E6AD2 与 success #3DBC7E 都是中等饱和）。
           改成"高亮底色 + accent 内框"两层叠加：底色升一档（--success-hi），
           让"现在"格的视觉锚点既与状态色同步，又能在视觉上读出"我是现在"。 */
        .device-card.online .timeline-cell.is-now.is-online {
            background: var(--success-hi);
            box-shadow:
                inset 0 0 0 2px color-mix(in srgb, var(--accent) 85%, transparent),
                inset 0 1px 0 color-mix(in srgb, var(--p-white) 14%, transparent);
        }
        /* is-offline + is-now：原 is-offline 是 var(--border-2) 较暗，
           内 accent 框直接读得清楚，保留原色即可。 */
        /* 悬停：在线格 hover 时轻微变亮 + 上浮 1px；离线格底色升一档（v31+ 去掉 opacity）。 */
        @media (hover: hover) {
            .timeline-cell.is-online:hover {
                background: var(--success-hi);
                transform: translateY(-1px);
            }
            .timeline-cell.is-offline:hover {
                background: var(--border-3);
            }
        }
        .timeline-stat {
            font-family: var(--font-mono);
            font-size: var(--fs-xs);
            font-weight: var(--weight-regular);
            color: var(--text-lo);
            flex: 0 0 auto;
            white-space: nowrap;
            transition: color var(--dur-base) var(--ease-standard);
            /* v15：11px → 12px，让"已连 12h" / "离线 41 分钟前" 这种
                 state-tinted 短语在颜色饱和时不至于被字号稀释辨识度。
                 仍保持 var(--font-mono) 让数字与时间单位紧贴。            */
            letter-spacing: 0.01em;
        }
        /* v25+: 极窄屏（≤ 480px 视口）兜底 —— "已过 13 小时 · 在线 2 小时"
           在 360 视口约 200px，会和左侧 24 格 + label 一起把 .timeline-today
           撑出 device-card。允许 stat 文本换行（min-width: 0 + 字号降档），
           让 24 格仍能保持可见 —— stat 在窄屏降为次要信息，"今日 24 格
           时序"才是第一扫描锚点。
           v26+: 同时把 cell 高度从 20px 收到 14px。stat 换行后 24 格在
           360 视口拿到 ~11px 宽、保留 20px 高仍是 1:2 的"竖条"。
           14px 高 + 11px 宽 ≈ 1:1.3，肉眼仍能识别每个整点，但不再像
           "被压扁的状态指示器"。                                              */
        @media (max-width: 480px) {
            .timeline-stat {
                flex: 1 1 100%;
                white-space: normal;
                font-size: var(--fs-2xs);
                margin-top: var(--space-0_5);
            }
            .timeline-cell {
                height: 14px;
                /* inset 顶部 1px 高光在 14px 格子上占比偏大，缩到 .5px
                   让「方块感」不被高光吃成「竖条感」 */
                box-shadow: inset 0 .5px 0 color-mix(in srgb, var(--p-white) 8%, transparent);
            }
            .device-card.online .timeline-cell.is-now.is-online {
                box-shadow:
                    inset 0 0 0 2px color-mix(in srgb, var(--accent) 85%, transparent),
                    inset 0 .5px 0 color-mix(in srgb, var(--p-white) 14%, transparent);
            }
            .timeline-cell.is-now {
                box-shadow:
                    inset 0 0 0 2px color-mix(in srgb, var(--accent) 80%, transparent),
                    inset 0 .5px 0 color-mix(in srgb, var(--p-white) 10%, transparent);
            }
        }
        /* v13: 右侧 "已连 12h" / "离线 10 小时前" 跟随设备状态色 —— 让
           timeline 左右两端在视觉上对齐：左边 24 格是绿/灰，右边文字
           也是绿/灰对应，强化"timeline 是一个状态单元"而不是
           "左边色块 + 右边普通文字"的拼接感。                            */
        .timeline-today.is-online  .timeline-stat { color: var(--success-hi); }
        .timeline-today.is-offline .timeline-stat { color: var(--error-hi); }

        /* ============================================================
           KPI 行
           上一版每个 KPI 是一个带左侧渐变细线的小卡，数字用 accent 渐变文字，
           标签前还有一个 accent 圆点，且 hover 上浮。三张卡并排时，
           满屏都是同一抹紫色，数字本身的层级被装饰吃掉了。
           现在：数字用主文本色（它们是数据，不是品牌色），
           标签去掉圆点与 uppercase，靠字号和颜色分层次。

           这一版把三张 KPI 进一步分层：
             · "今日在线"（最重要，今天已经发生）→ 沿用 surface-canvas
               主卡底色 + 顶部 1px state-aware 强调线，让用户进卡片时
               先看到这条；其余两张仍是 inset 的 surface-panel。
             · "72h / 10d 在线率/小时" → 维持原来的 surface-panel，
               数值大小、字重不变，避免 KPI 行整体变得"五颜六色"。
           为什么 "今日" 是最重的：用户打开面板第一件事就是想看
           "今天开了多久"——这条信息有"截至当前"的实际边界，
           而 72h / 240h 是回看数据，时间紧迫性低一档。             */
        .kpi-row {
            display: grid;
            grid-template-columns: repeat(3, minmax(0, 1fr));
            /* v14：3 张 KPI 之间留出更多呼吸 —— 从 16 → 14。
                 16px 在 320px 卡片里把 3 列挤得只剩 ~98px，
                 "10天 在线率"标签需要 ellipsis。
                 14px 时每列 ~104px，标签仍有 ellipsis 但更接近完整。
                 与 device-card padding 32 → 28 同步 —— 让 KPI 行在视觉上
                 整体收紧，避免"3 张卡占满整个卡宽"的拥挤感。              */
            gap: var(--space-3_5);
        }
        /* v26+: 极窄屏（≤ 480px）3 列 → 第一张占满、第二行 2 列。
           360 视口 3 列下每张 ~84px，"10天 在线率"标签 60px + padding
           32px = 92px 仍超 84px，被截成 "10天 在..." —— 主 KPI 的
           标签都读不全。
           改成 "今日 KPI 全宽 + 72h/10d 两列分摊"：今日本来就被设计成
           主卡（surface-canvas 升档 + 顶部 state-aware 强调线），单
           独占满一行的视觉权重更匹配它的语义；72h/10d 在第二行各拿到
           ~150px，标签与数字都舒展开。                                        */
        @media (max-width: 480px) {
            .kpi-row {
                grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
            }
            /* :has(.kpi-num.kpi-today) 选「今日」那张（class 由 JS 写到 .kpi-num 上），
               强制占满第一行。grid-column: 1 / -1 是「从第 1 列线到最后一列线」。*/
            .kpi:has(.kpi-num.kpi-today) {
                grid-column: 1 / -1;
            }
        }

        .kpi {
            background: var(--surface-panel);
            border: 1px solid var(--border);
            /* v12：圆角 4 → 14。KPI 也是独立子卡。 */
            border-radius: var(--radius-sm);
            /* v14：padding 16/16 → 14/16，让数字与上沿留 10px 呼吸
                 （14px 标签 + 6px margin = 20，减去顶部 padding 14 = 6px 间隙）。
                 横向 16 保留，让长单位（"小时"）不挤在右边。 */
            padding: var(--space-3_5) var(--space-4);
            position: relative;
            overflow: hidden;
            /* v14：transition 添加，让 hover 时（如果将来加）有平滑过渡。
                 当前没有 hover 态，但 transform 不写在 transition 里
                 加上会更稳。 */
            transition: border-color var(--dur-fast) var(--ease-standard);
        }
        /* 今日卡：唯一主卡。背景升一档到 surface-canvas，顶部 1px
           状态色细线做"高亮"。细线只 1px，与左侧 KPI 行的 inset
           卡形成层级（主卡向外浮一格），但仍远低于 stripe / hero 主线。

           v16 修复：JS 把 `kpi-today` 放在 `.kpi-num`（写值的钩子），
           不在 `.kpi` 上。改成 `:has(.kpi-num.kpi-today)` 把这两者
           桥起来 —— 早一版的 `.kpi.kpi-today` 复合选择器要求两个类
           在同一元素上，从来没匹配过，意味着：
             · 这条背景升档的规则从来没生效过
             · 顶部 1px 强调线从来没出现
             · 「今日在线」数字在在线设备上从来没染绿
           把「主卡」视觉的源头修通，否则「今日 KPI 与另外两张卡长得
           一样」之前一直在被用户接受为「设计如此」。                       */
        .kpi:has(.kpi-num.kpi-today) {
            background: var(--surface-canvas);
        }
        /* 状态色细线必须跟设备当前是否在线走：在线 → success 强调，
           离线 → neutral 强调（不要让离线卡也带红，那是说设备今天
           没开过而不是"全天没开机"，混淆状态语义）。                */
        .device-card.online  .kpi:has(.kpi-num.kpi-today)::before {
            background: var(--success);
        }
        .device-card.offline .kpi:has(.kpi-num.kpi-today)::before {
            background: var(--neutral);
        }
        .kpi:has(.kpi-num.kpi-today)::before {
            content: "";
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            height: 1px;
            background: var(--neutral);
            opacity: .65;
        }
        .kpi-label {
            font-size: var(--fs-2xs);
            /* v16: xlo → lo。"今日在线 / 72h 在线 / 10天 在线率" 3 张 KPI
               是设备卡里的关键扫描锚点，11px 仍偏弱。上一版 kpi-today
               已经升到 lo，72h / 10d 两张走 xlo 让三张卡的标签明度
               不齐——升统一后，"哪张是主卡"由背景 surface-canvas 与
               顶部 state-aware 1px 强调线承担，不再靠「标签更暗」区分。
               lo 与 xlo 的 1.15× 对比仍保留「次要文字」的层级感。      */
            color: var(--text-lo);
            margin-bottom: var(--space-1_5);
            font-weight: var(--weight-regular);
            display: block;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            /* v14：letter-spacing 0.02em，与 timeline-label 的节奏一致 */
            letter-spacing: 0.02em;
        }
        /* 今日 KPI 的标签与基数保持同档 ── 上一版 kpi-today 升 lo、其他两张
           留 xlo，本轮 v16 已经把全部 kpi-label 拉到 lo，下方原
           `.kpi.kpi-today .kpi-label` 规则移除 —— 该选择器是复合（要求两个
           类在同一元素上），但 JS 把 kpi-today 放在 .kpi-num 上，从来没匹配过；
           并且即使匹配也只是重复 base .kpi-label 已声明的 text-lo。          */
        /* v13: 在线设备的"今日在线"数字用 success 色 —— 让"今天开了多久"
           这件事在视觉上一眼可读。离线设备保留中性，避免数字被红/绿
           染色掩盖（offline 时用户更需要的是清晰看到"0 小时"）。

           v16 修复：`.device-card.online .kpi.kpi-today .kpi-num`（要求三个
           类在同一元素）从来不会命中，因为 kpi-today 与 kpi-num 同行而
           kpi 是它们的父亲。改为 `.device-card.online .kpi-num.kpi-today`
           —— 这两个类同时放在 .kpi-num 上，能正确命中；没有 :has()，因为
           目标元素本身就是 .kpi-num，不需要选择父级。                      */
        .device-card.online .kpi-num.kpi-today { color: var(--success); }
        .kpi-num {
            font-family: var(--font-mono);
            font-weight: var(--weight-semibold);
            line-height: var(--leading-tight);
            font-size: var(--fs-2xl);
            /* v30：text-hi → accent，让设备卡 KPI 数字跟随调色板。
               specificity (0,1,1) < .device-card.online .kpi-num.kpi-today 的
               (0,2,2) —— "今日"行在 online 状态下仍正确显示 success 绿。
               .pct / .unit 保持 text-lo 不变，主数字独占色彩身份。 */
            color: var(--accent);
            font-feature-settings: "tnum";
            font-variant-numeric: tabular-nums;
            display: flex;
            align-items: baseline;
            gap: var(--space-1);
            /* v14：letter-spacing -0.01em，与大字号节奏一致 */
            letter-spacing: var(--text-spacing);
            /* v30：加 color 进 transition，切调色板时数字平滑过渡。 */
            transition: color var(--dur-base) var(--ease-standard);
        }
        /* v14：unit 字号 11 → 12，与 hero-stat unit 同步升级；
                 颜色 xlo → lo，让"小时"和数字之间更平衡。                  */
        .kpi-num .unit {
            font: 400 var(--fs-xs)/1 var(--font-sans);
            color: var(--text-lo);
        }
        .kpi-num .pct {
            font: 400 var(--fs-xs)/1 var(--font-mono);
            color: var(--text-lo);
        }

        /* ============================================================
           v11: 图表（合并为单图 + chart-toggle 切换）
           ============================================================ */
        /* v34+：.device-grid 始终 column 堆叠 ——
           撤销 v33 引入的"宽屏 ≥720 设备卡宽 = 1.55fr / 1fr 左右并排"。
           原因：折线图（chart-card）和活跃时段分布（heatmap-card）信息密度
           不同，硬塞进左右两列后，heatmap 在 ~280px 窄列里 7×24 格子被压成
           1.5×12px 的"竖条色块"，用户分不清"哪个时段活跃"。让 heatmap
           单独一行铺满卡宽后，每行 ~22px、行高回到能扫读的舒适区。
           折线图也回到完整卡宽（~880px 视宽），7:1 比例下 min-height 140
           仍够，不影响阅读。                                                   */
        .device-grid {
            display: flex;
            flex-direction: column;
            gap: var(--space-6);  /* 与 device-card 段间 gap 拉齐 */
            min-width: 0;
        }
        /* .device-charts 在 .device-grid 内仍是 chart-card 的纯承载容器。
           v33 时的"无 column 间距"备注仍然适用 —— flex column 下自己 gap
           为 0 与父层 gap:space-6 不冲突，留这一层是为日后若往里塞更多子卡
           时结构清晰，不让 chart-card 直接挂在 device-grid 下。              */
        .device-grid > .device-charts {
            display: flex;
            flex-direction: column;
            gap: var(--space-0);
            min-width: 0;
            margin-top: 0;
        }

        .chart-card {
            background: var(--surface-panel);
            border: 1px solid var(--border);
            border-radius: var(--radius-sm);
            /* v15: padding 18/20 → 16/18，让卡片内的图表在卡片整体收紧后
               不至于被 padding 进一步压缩；与 device-card padding 28 同步
               形成"卡片 28 / 子卡 18 = 内紧外松"的层次感。                  */
            padding: var(--space-4) var(--space-4_5);
            position: relative;
            overflow: hidden;
        }
        /* v14+: 折线图卡顶部 1px state-aware 强调线 —— 与 kpi-today 同套语言，
           让"今日在线 / 折线图 / 热力图"三块子卡在视觉上对齐到同一节奏：
           "这一行承载状态"。在线 → success，offline → neutral（不染红，
           避免三块子卡全部飘红形成满屏警报）。                            */
        .chart-card::before {
            content: "";
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            height: 1px;
            background: var(--neutral);
            opacity: .5;
            transition: background var(--dur-base) var(--ease-standard),
                        opacity var(--dur-base) var(--ease-standard);
        }
        .device-card.online .chart-card::before {
            background: var(--success);
            opacity: .75;
        }
        .chart-card .chart-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: var(--space-3);
            gap: var(--space-3);
            flex-wrap: wrap;
        }

        /* chart-toggle / heatmap-toggle：两张卡里各出现一对"N 选 1"控件
           （chart: 24h/72h/10d；heatmap: 本周/上周）。它们是真正的交互
           控件，保留胶囊与选中态（is-active）。两张卡的视觉语言完全一致
           —— 选择器合并、改一处就同步两处，避免「两张卡漂移」。     */
        .chart-toggle,
        .heatmap-toggle {
            display: inline-flex;
            background: var(--bg);
            border: 1px solid var(--border-2);
            /* v12：圆角 4 → 14（--radius-sm），与卡片语言一致。 */
            border-radius: var(--radius-sm);
            padding: var(--space-0_5);
            height: var(--chart-toggle-h);
            align-items: center;
            gap: var(--space-0_5);
        }
        .chart-toggle-option,
        .heatmap-toggle-option {
            appearance: none;
            border: 1px solid transparent;
            background: transparent;
            /* v16: xlo → lo。这是真控件（24h / 72h / 10d；本周 / 上周），
               选中态走 text-hi，未选中态用 lo 让"这是可点项"一眼可辨，
               避免与元信息（刷新倒计时等仍走 xlo）混在一起。lo 与 xlo 的
               1.15× 对比仍保留「当前未选中」的弱态语义。              */
            color: var(--text-lo);
            font: 500 var(--fs-2xs)/1 var(--font-sans);
            font-feature-settings: "tnum";
            padding: 0 var(--space-2_5);
            height: 100%;
            border-radius: var(--radius-2xs);
            cursor: pointer;
            transition: background var(--dur-fast) var(--ease-standard),
                        color var(--dur-fast) var(--ease-standard),
                        border-color var(--dur-fast) var(--ease-standard);
        }
        .chart-toggle-option:hover,
        .heatmap-toggle-option:hover { color: var(--text-md); }
        .chart-toggle-option.is-active,
        .heatmap-toggle-option.is-active {
            background: var(--surface-inset);
            color: var(--text-hi);
            border-color: var(--border-2);
        }
        /* 键盘焦点：toggle 是真正的控件，但默认 :focus-visible 的 2px 外圈
           会与胶囊边框重叠。改为 inset 1px，与选中态描边共用 border-2，
           让"被聚焦但未选中"也能在视觉上区别于"未被聚焦"。                */
        .chart-toggle-option:focus-visible,
        .heatmap-toggle-option:focus-visible {
            outline: none;
            border-color: var(--border-3);
        }
        .chart-toggle-option.is-active:focus-visible,
        .heatmap-toggle-option.is-active:focus-visible {
            border-color: var(--text-lo);
        }

        /* chart-sub / heatmap-sub：与上方 toggle 选项同字体（mono + 2xs + lo），
           让两张卡在头部各出现一行等重的元信息。统一在一处写：颜色 / 字号 /
           字体家族同步改；letter-spacing 也对齐避免视觉节拍不齐。             */
        .chart-card .chart-sub,
        .heatmap-card .heatmap-sub {
            font-size: var(--fs-2xs);
            color: var(--text-lo);
            font-family: var(--font-mono);
            /* v14：让 "已过 10 小时 · 在线 1 小时" 与 chart-toggle 之间的视觉重量
                 更平衡 —— 上一版用 xlo，sub 与 toggle 选项（也是 xlo）
                 颜色一样，让 sub 看起来"次次要"，但用户其实常看这一行。
                 用 lo 让它在 toggle 旁边也能清晰被读到。

                 v16：toggle 选项已同步升 lo（避免与元信息混在一起），sub 保持
                 lo 是同一家族；不再需要再提「sub 与 toggle 颜色不同」的旧说法。 */
            letter-spacing: 0.01em;
        }

        .chart-wrap {
            position: relative;
            width: 100%;
        }
        /* canvas 默认是 inline，inline 元素会按 baseline 对齐，
           在某些字体下会留下 1~2px 的"半行高"空白。改 block 消除。
           v25+: max-width: 100% 兜住 Chart.js 在极窄屏（< 360px 视口）
           按容器自适应时偶尔把 canvas width 撑到超过父元素的边界。
           width: 100% 不会覆盖 HTML attribute 上的 width，max-width
           会。两条同时声明让 canvas 既继承父宽、又不能超过父宽。     */
        .chart-canvas {
            display: block;
            width: 100%;
            max-width: 100%;
            height: 100%;
        }
        /* v31+：图表"全离线"空态叠加层 —— 当数据数组里没有任何"在线"值
           （即 sum === 0），Chart.js 仍会画一条贴在 y=0 的灰线，整张图
           看起来像"加载失败 / 还没初始化"。叠加一行中性小字说明"无在线
           活动"避免误读，同时让图表"全离线"状态有"被识别"的视觉锚点。
           实现：absolute 定位 + 居中 + pointer-events:none（不抢 hover）。
           触发：app.js 在 updateChartData 末尾检测 dataSum===0 时给
           .chart-wrap 加 .is-all-offline 类。24h/72h/240h 都适用 —— 它们
           共享同一个 chart-wrap 容器。                                            */
        .chart-empty-hint {
            position: absolute;
            inset: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: var(--space-2);
            font-size: var(--fs-2xs);
            color: var(--text-xlo);
            font-family: var(--font-mono);
            letter-spacing: 0.02em;
            pointer-events: none;
            opacity: 0;
            transition: opacity var(--dur-base) var(--ease-standard);
        }
        .chart-empty-hint::before {
            content: "";
            width: 6px;
            height: 6px;
            border-radius: 50%;
            background: var(--neutral);
            box-shadow: 0 0 0 3px color-mix(in srgb, var(--neutral) 18%, transparent);
        }
        .chart-wrap.is-all-offline .chart-empty-hint {
            opacity: 1;
        }
        /* v31+：全离线时把 canvas 整图降到 35% 透明度 —— 让贴在 y=0
           的灰线（"无数据线"）与轴标签都退到背景里，"无在线活动"提示
           才成为视觉中心。35% 而不是 0%：轴标签仍可读，方便用户对到
           时间刻度。chart-wrap 容器本身不被改，所以 box-shadow / 边距
           都不会随状态变化。                                                      */
        .chart-wrap.is-all-offline .chart-canvas {
            opacity: 0.35;
        }
        /* v15: 图表容器高度从 90/220 提到 130/220，让 54px 高的图表区域
           留出 axis label、title padding、gridline 的总高度（约 11+12+8*2），
           不再因为图表太矮而把 y 轴 label "在线/离线" 切掉。
           max 220 是 hover/复制态下图表不会被异常拉伸。容器查询阈值同步
           收敛：默认 4:1 → 6:1（加宽以避免小屏被压扁），480 7:1 → 6:1
           （与默认对齐，单纯加 min-height 140），720 9:1 → 7:1（收窄避免
           大屏被拉得过宽）。min-height 130/140/140 是 4K HiDPI 下的下限。 */
        .chart-wrap.size-toggle { aspect-ratio: 6 / 1; min-height: 130px; max-height: 220px; }
        @container device (min-width: 480px) {
            .chart-wrap.size-toggle { aspect-ratio: 6 / 1; min-height: 140px; }
        }
        @container device (min-width: 720px) {
            .chart-wrap.size-toggle { aspect-ratio: 7 / 1; max-height: 200px; min-height: 140px; }
        }
        /* v34+: 删除 v33 的 chart 窄列宽高比覆盖 ——
           并排布局已撤销，chart 现在永远独占卡宽（~880px @container ≥720），
           上方 7:1 + min-height 140 / max-height 200 已足够，5:2 的窄列补丁
           不再需要。                                                       */

        /* ============================================================
           活动热力图（7 × 24）
           ============================================================ */
        .heatmap-card {
            background: var(--surface-panel);
            border: 1px solid var(--border);
            border-radius: var(--radius-sm);
            /* v15: 与 chart-card 同步 16/18，让两子卡在卡内顶间距对齐 */
            padding: var(--space-4) var(--space-4_5);
        }
        .heatmap-card .heatmap-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: var(--space-3);
            flex-wrap: wrap;
            gap: var(--space-3);
        }
        /* v32+：heatmap-head 里的"周次切换 + 周界文案"打包成一组控件，
           复用 chart-toggle 的视觉语言（胶囊 + 选中态 inset），让两张卡
           在头部出现一对结构相似的控件，避免「第三张卡里突然冒出一种新
           toggle 样式」的视觉割裂感。                                 */
        .heatmap-card .heatmap-controls {
            display: flex;
            align-items: center;
            gap: var(--space-3);
            flex-wrap: wrap;
        }
        /* heatmap-toggle / heatmap-toggle-option / heatmap-sub 与 chart-* 共享
           同一份样式（在更早的 chart-toggle 区块合并写：选择器列表里
           包含 .heatmap-toggle 与 .chart-toggle 等），这里不再重复声明，
           避免「两张卡改了一个忘了另一个」的漂移。                          */
        .heatmap-card h3 {
            margin: var(--space-0);
            font-size: var(--fs-2xs);
            font-weight: var(--weight-regular);
            color: var(--text-lo);
            /* v14：letter-spacing 0.02em，与 timeline-label / kpi-label 同步 */
            letter-spacing: 0.02em;
        }
        .heatmap-legend {
            display: flex;
            align-items: center;
            gap: var(--space-2);
            font-size: var(--fs-2xs);
            color: var(--text-lo);
            font-family: var(--font-mono);
            /* v14：letter-spacing 0.02em，与其他标签节奏一致 */
            letter-spacing: 0.02em;
        }
        /* v14：图例条 8px → 6px（更细），边框 1px 增加"刻度感"，
             radius 2 → 3 (var(--radius-2xs)) 让两端不那么"硬"。
             上一版纯实色 gradient 在 surface-panel 上像是"贴了张色卡"，
             加 1px 内嵌高光后读起来更像"控件"。                              */
        .heatmap-legend-bar {
            width: 60px;
            height: 6px;
            border-radius: var(--radius-2xs);
            border: 1px solid var(--border);
            /* 色阶必须与 app.js 的 renderHeatmap 实际取值一致。
               v25+ 4 档：0.08 (空格) → 0.42 (1 次) → 0.78 (6 次) → 0.97 (peak)。
               旧值 0.12/0.45/0.75/0.95 已经和 0.08/0.20+0.77√ 映射脱钩，
               现在逐档对齐。                                                  */
            background: linear-gradient(90deg,
                color-mix(in srgb, var(--viz-fill) 8%, transparent),
                color-mix(in srgb, var(--viz-fill) 42%, transparent),
                color-mix(in srgb, var(--viz-fill) 78%, transparent),
                color-mix(in srgb, var(--viz-fill) 97%, transparent));
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 6%, transparent);
        }
        /* v15: 热力图容器 min-height 从 124 提到 144，让 7 行格子在窄卡（1440 视口
           3 列布局，card ~395px）下每行至少 ~16px 而不是被压到 ~12px 把日期
           标签挤成竖条。max-height 同步收到 200，避免极宽视口把热力图撑得
           过高、失衡于图表卡。aspect-ratio 阶梯：基础 5/1（窄卡），480 6/1，
           720 7/1（旧值是 7/1/8/1，本轮收窄避免极宽视口被拉得过扁）。
           min-height 同时给 max-height 让宽屏不会过分拉高。                  */
        .heatmap-wrap {
            position: relative;
            width: 100%;
            aspect-ratio: 5 / 1;
            min-height: 144px;
            max-height: 200px;
        }
        @container device (min-width: 480px) {
            .heatmap-wrap { aspect-ratio: 6 / 1; min-height: 144px; }
        }
        @container device (min-width: 720px) {
            .heatmap-wrap { aspect-ratio: 7 / 1; min-height: 156px; }
        }
        /* v34+: 删除 v33 的 heatmap 窄列宽高比覆盖 ——
           并排布局已撤销，heatmap 现在永远独占卡宽（~880px @container ≥720），
           上方 7:1 + min-height 156 / max-height 200 已足够，2:1 的窄列补丁
           不再需要。                                                       */
        .heatmap-canvas {
            display: block;
            width: 100%;
            max-width: 100%;   /* v25+: 兜住极窄屏的 canvas 越界（与 .chart-canvas 同思路） */
            height: 100%;
        }

        /* ============================================================
           链接 / 操作栏 / Footer（v11：footer 静态化，refresh 提到 header）
           ============================================================ */
        .links {
            margin-top: var(--space-10);
            display: flex;
            flex-wrap: wrap;
            gap: var(--space-3);
            font-size: var(--fs-sm);
            color: var(--text-md);
        }
        .links .sep { color: var(--text-xlo); }
        .links a {
            display: inline-flex;
            align-items: center;
            gap: var(--space-1);
            padding: var(--space-1) var(--space-2);
            background: transparent;
            border: 1px solid var(--border);
            border-radius: var(--radius-2xs);
            font-size: var(--fs-xs);
            transition: border-color var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard);
        }
        .links a:hover {
            border-color: var(--text-lo);
        }
        .links a::after {
            content: "→";
            font-size: var(--fs-2xs);
            opacity: .6;
        }

        /* v12 现代杂志风：footer 圆角 6 → 18，与 hero 一致（footer 是
           整个内容区的最后一层浮起，与 hero 首尾呼应）。顶部 margin
           从 40 加大到 48 —— 让 footer 与设备卡之间有明显的"页脚"分界。 */
        .site-meta {
            margin-top: var(--space-12);
            padding: var(--space-4) var(--space-6);
            background: var(--surface-canvas);
            border: 1px solid var(--border);
            border-radius: var(--radius-md);
            box-shadow: var(--shadow-card);
            display: flex;
            flex-wrap: wrap;
            justify-content: space-between;
            align-items: center;
            gap: var(--space-3);
            color: var(--text-xlo);
            font-size: var(--fs-2xs);
        }
        .site-meta .left,
        .site-meta .right {
            display: flex;
            align-items: center;
            gap: var(--space-3);
            flex-wrap: wrap;
        }
        /* 移动端：右侧"按 R 手动刷新"挪到下一行时让它左对齐而不是右对齐，
           否则 justify-content: space-between 会把它推到最右边与版权冲突。 */
        @media (max-width: 480px) {
            .site-meta { justify-content: flex-start; }
        }
        .site-meta .kbd-hint {
            font-family: var(--font-mono);
            font-size: var(--fs-2xs);
            color: var(--text-xlo);
        }
        .site-meta .kbd {
            display: inline-block;
            padding: var(--space-0_5) var(--space-1_5);
            margin: var(--space-0) var(--space-0_5);
            background: var(--surface-panel);
            border: 1px solid var(--border-2);
            /* v14：圆角 3 → 4（--radius-xs）与其他子控件一致 */
            border-radius: var(--radius-xs);
            font-family: var(--font-mono);
            font-size: var(--fs-2xs);
            color: var(--text-md);
            /* v14：去掉 inset 0 -1px 0 rgba(0,0,0,.25) 那个"硬阴影"，
                 在 surface-panel 已经是 --p-n-250 时这个阴影会与底色叠加
                 出一圈死黑的暗带，反而让 kbd 看起来"沉到"面板里。
                 改为顶部 1px 高光 + 底部 1px 阴影（更柔和的立体感），
                 与 timeline-cell 的 inset 高光思路一致。                       */
            box-shadow: inset 0 1px 0 color-mix(in srgb, var(--p-white) 6%, transparent),
                        inset 0 -1px 0 color-mix(in srgb, var(--shadow-color) 18%, transparent);
            transition: color var(--dur-fast) var(--ease-standard),
                        border-color var(--dur-fast) var(--ease-standard);
        }
        .site-meta .kbd:hover {
            color: var(--text-hi);
            border-color: var(--border-3);
        }
        /* v30: dark / hc 主题 kbd 底部 inset 几乎不可见。
           --shadow-color 在这两个主题上都是纯黑（themes.css:156 "黑底黑阴影，
           靠描边撑层级"），kbd 自己的底 --surface-panel 也是近黑 —— 18% 黑叠
           在近黑底上肉眼无差异，让"3D 键帽"在暗底上只剩 top 高光、底面跟
           周围融成一片。把底 inset 提到 38% 才能在 #1A1A1A 底上看出
           一道比 surface 暗半档的"键底"线，立体感才立得起来。
           light 主题不动：浅底上 18% 黑已是清晰可见的细线，38% 会
           显得"底太重"。                                                     */
        html[data-theme="dark"] .site-meta .kbd,
        html[data-theme="hc"] .site-meta .kbd {
            box-shadow:
                inset 0 1px 0 color-mix(in srgb, var(--p-white) 6%, transparent),
                inset 0 -1px 0 color-mix(in srgb, var(--shadow-color) 38%, transparent);
        }

        /* 刷新倒计时：静态显示。上一版有个 2s 无限呼吸的小圆点 + 发光，
           一个每 30 秒才变一次的数字，不该挂一个两秒循环的动画。 */
        .refresh-inline {
            display: flex;
            /* v18: align-items: center → baseline —— 与 .header-right 一致。
               .refresh-info(fs-2xs 11px) 与 .btn#refreshBtn(fs-xs 12px+padding)
               在 row 容器里，旧版按"盒子中心"对齐，两段文字基线错开；
               改 baseline 后两段文字共享同一条基线，再由 .header-right 的
               baseline 对齐把整个 refresh-inline 拉到时钟基线上。           */
            align-items: baseline;
            gap: var(--space-2);
        }
        .refresh-info {
            display: flex;
            align-items: center;
            gap: var(--space-1_5);
            font-family: var(--font-mono);
            font-size: var(--fs-2xs);
            color: var(--text-xlo);
            white-space: nowrap;
        }
        .refresh-info .dot-sep { color: var(--text-xlo); }
        .refresh-info .countdown {
            color: var(--text-lo);
            font-weight: var(--weight-regular);
            font-feature-settings: "tnum";
            font-variant-numeric: tabular-nums;
            min-width: 28px;
            text-align: right;
            display: inline-block;
        }
        .refresh-info .countdown.warn { color: var(--warning); }
        .refresh-info .countdown.error { color: var(--error); }
        .refresh-info.error { color: var(--error); }

        /* 按钮：唯一保留"可点"视觉语言的元素（描边 + hover 变亮）。
           v12：圆角 4 → 14，与卡片语言一致。 */
        .btn {
            display: inline-flex;
            align-items: center;
            gap: var(--space-1_5);
            padding: var(--space-1_5) var(--space-3);
            background: transparent;
            border: 1px solid var(--border-2);
            border-radius: var(--radius-sm);
            color: var(--text-md);
            font: inherit;
            font-size: var(--fs-xs);
            font-weight: var(--weight-regular);
            cursor: pointer;
            transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
        }
        .btn:hover  {
            color: var(--text-hi);
            border-color: var(--text-xlo);
        }
        /* v25+: mobile (<480) 隐藏 .theme-trigger 的"主题"文字，仅留 icon。
           360 视口下 header-right 同时塞时钟 + 刷新按钮 + 主题按钮 + 主题文字
           会强制 flex-wrap 把"主题"挤到下一行，header 高度暴增。
           主题按钮保留 1 个 ◐ icon 即可识别（与"刷新"的 ↻ icon 同一家族）。 */
        @media (max-width: 480px) {
            .theme-trigger > span:not(.icon) { display: none; }
            .theme-trigger { padding: var(--space-1_5) var(--space-2_5); }
        }
        /* v18: icon 用 inline-flex + line-height: 1 强制字符在 12×12 盒子里居中。
           旧版 display: inline-block + 固定 height 时，↻ / ◐ 等 unicode 字符在
           Inter/PingFang/MicrosoftYaHei 的 ascent-descent 不一致，glyph 视觉
           重心与「刷新」「主题」这种全高 CJK 文字错开 ~1-2px，眼睛会读成
           "icon 偏上、字偏下"——尽管 getBoundingClientRect 算出的几何中心
           是对齐的（diff=0），但视觉上"看着不齐"。
           用 flex + line-height: 1 把字符在盒子里强制居中，不再依赖字体度量。  */
        .btn .icon {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 12px;
            height: 12px;
            line-height: 1;
        }
        .btn.spinning .icon { animation: spin 1s linear infinite; }
        @keyframes spin {
            from { transform: rotate(0deg); }
            to   { transform: rotate(360deg); }
        }

        /* v11: error-badge 改为顶部全局横幅（紧贴 stripe 下方）
           v12：圆角 4 → 14，阴影升级到 toast-级。 */
        .error-banner {
            position: fixed;
            top: calc(var(--status-stripe-h, 2px) + var(--space-3));
            left: 50%;
            transform: translate(-50%, -120%);
            display: flex;
            align-items: center;
            gap: var(--space-2);
            padding: var(--space-2_5) var(--space-3_5);
            background: var(--surface-panel);
            border: 1px solid var(--error-bd);
            border-left: 3px solid var(--error);
            border-radius: var(--radius-sm);
            box-shadow: var(--shadow-toast);
            color: var(--error-hi);
            font-size: var(--fs-xs);
            font-weight: var(--weight-regular);
            cursor: pointer;
            opacity: 0;
            transition: transform var(--dur-base) var(--ease-out), opacity var(--dur-base) var(--ease-standard);
            z-index: 60;
        }
        /* H3 + L7：删掉 :hover 改 border-color 的"再强调一遍红边"样式。
           role=status + tabindex=0 之后，键盘用户已能聚焦到这里；鼠标 hover
           再叠红色边框是冗余信号，且在长距离滚动时容易把视线拉错方向。
           改用 :focus-visible 提供键盘焦点环。 */
        .error-banner:focus-visible {
            outline: 2px solid var(--focus-ring);
            outline-offset: 2px;
        }
        .error-banner.show {
            opacity: 1;
            transform: translate(-50%, 0);
        }
        .error-banner .count {
            font-family: var(--font-mono);
            font-size: var(--fs-2xs);
        }

        /* 采集服务告警横幅：listener 停更时显示。
           与 error-banner（浏览器→服务端网络异常）区分开——那条是红色可点击重试，
           这条是黄色纯提示：问题在服务端进程，不在用户的网络，点它没用。 */
        .collector-banner {
            position: fixed;
            top: calc(var(--status-stripe-h, 2px) + var(--space-3));
            left: 50%;
            transform: translate(-50%, -120%);
            display: flex;
            align-items: center;
            gap: var(--space-2);
            padding: var(--space-2_5) var(--space-3_5);
            background: var(--surface-panel);
            border: 1px solid var(--warning-bd);
            border-left: 3px solid var(--warning);
            border-radius: var(--radius-sm);
            box-shadow: var(--shadow-toast);
            color: var(--warning-hi);
            font-size: var(--fs-2xs);
            font-weight: var(--weight-regular);
            white-space: nowrap;
            max-width: calc(100vw - 32px);
            overflow: hidden;
            text-overflow: ellipsis;
            opacity: 0;
            pointer-events: none;
            transition: opacity var(--dur-base) var(--ease-standard), transform var(--dur-base) var(--ease-standard);
            z-index: 40;
        }
        .collector-banner.show {
            opacity: 1;
            transform: translate(-50%, 0);
        }
        .collector-banner .why { font-weight: var(--weight-regular); }

        /* v11+: chart.js 加载失败告警横幅（默认隐藏，加 .chart-js-failed 后显示）。
           v12：圆角 4 → 14，阴影升级。 */
        .chart-js-failed-banner {
            position: fixed;
            top: calc(var(--status-stripe-h, 2px) + var(--space-3));
            left: 50%;
            transform: translate(-50%, -120%);
            display: flex;
            align-items: center;
            gap: var(--space-2);
            padding: var(--space-2_5) var(--space-3_5);
            background: var(--surface-panel);
            border: 1px solid var(--warning-bd);
            border-left: 3px solid var(--warning);
            border-radius: var(--radius-sm);
            box-shadow: var(--shadow-toast);
            color: var(--warning-hi);
            font-size: var(--fs-xs);
            font-weight: var(--weight-regular);
            opacity: 0;
            transition: transform var(--dur-base) var(--ease-out), opacity var(--dur-base) var(--ease-standard);
            z-index: 61;
            pointer-events: none;  /* 静态告警，不抢点击 */
        }
        body.chart-js-failed .chart-js-failed-banner {
            opacity: 1;
            transform: translate(-50%, 0);
            pointer-events: auto;
        }

        /* ============================================================
           空状态
           ============================================================
           上一版是虚线描边 + 一个 32px 的心跳 SVG。改成实色细描边 +
           顶部 1px 微弱的 accent 强调线：把"等第一台设备接入"这件事
           从"页面坏了"转成"页面在等你"，与已有设备的卡片形成层级。 */
        .empty {
            grid-column: 1 / -1;
            /* v12 现代杂志风：内边距从 48/20 加大到 64/32，配合圆角升级
               让"暂无设备"也像一张被精心排版的卡片，而不是一个空白块。 */
            padding: var(--space-16) var(--space-8);
            text-align: center;
            background: var(--surface);
            border: 1px solid var(--border);
            border-radius: var(--radius-md);
            color: var(--text-md);
            font-size: var(--fs-md);
            line-height: var(--leading-normal);
            position: relative;
            overflow: hidden;
            box-shadow: var(--shadow-card);
        }
        .empty::before {
            content: "";
            position: absolute;
            top: 0; left: 0; right: 0;
            height: 1px;
            background: linear-gradient(90deg,
                transparent 0%,
                color-mix(in srgb, var(--accent) 45%, transparent) 50%,
                transparent 100%);
        }
        .empty .empty-icon {
            /* v33+：icon 区从 32px 容器提到 44px，加入一道心电脉冲的
               辅助小点（视觉锚点）—— 让"等第一台设备接入"的页面有
               视觉中心，不只是一个孤零零的 SVG icon。保持 stroke
               极淡（仍 xlo 色），避免与大状态条/状态点抢戏。        */
            width: 44px;
            height: 44px;
            margin: var(--space-0) auto var(--space-4);
            color: var(--text-xlo);
        }
        .empty .empty-icon svg { width: 44px; height: 44px; display: block; }
        .empty .empty-title {
            font-size: var(--fs-lg);
            font-weight: var(--weight-semibold);
            color: var(--text-hi);
            margin-bottom: var(--space-2);
            letter-spacing: var(--text-spacing);
        }
        .empty .empty-desc {
            color: var(--text-lo);
            font-size: var(--fs-sm);
            max-width: 520px;
            margin: var(--space-0) auto var(--space-7);
        }
        /* v33+：双选项并排容器 —— 窄屏（<720）退回 column，每张 option
           占据整宽；宽屏两列对齐，中间一条细分隔线。                */
        .empty .empty-options {
            display: grid;
            grid-template-columns: 1fr;
            gap: var(--space-5);
            max-width: 760px;
            margin: var(--space-0) auto;
            text-align: left;
        }
        @media (min-width: 720px) {
            .empty .empty-options {
                grid-template-columns: 1fr auto 1fr;
                gap: var(--space-5);
                align-items: stretch;
            }
        }
        /* v33+：单张 option 子卡 —— 用 surface-panel 浅底 + border 勾边，
           与"它就是一张子卡"的视觉语言一致。tag + title/meta + cmd 三段
           错落排版，让"两种启动方式"读起来像对比，而不是平铺命令。      */
        .empty .empty-option {
            background: var(--surface-panel);
            border: 1px solid var(--border);
            border-radius: var(--radius-sm);
            padding: var(--space-4) var(--space-4_5);
            display: flex;
            flex-direction: column;
            gap: var(--space-3);
            position: relative;
            min-width: 0;
        }
        /* v33+：tag 徽标 —— 用 surface-inset + 极细描边做出"小药丸"，
           "最快 / 图形" 等极短中文标签刚好能装下。配色走 accent，
           弱化但不与设备名（也 accent）撞视觉重量（tag 不超过 fs-2xs）。   */
        .empty .empty-option-tag {
            display: inline-flex;
            align-self: flex-start;
            align-items: center;
            padding: 2px var(--space-2);
            border-radius: var(--radius-full);
            background: color-mix(in srgb, var(--accent) 12%, transparent);
            border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
            color: var(--accent);
            font-family: var(--font-mono);
            font-size: var(--fs-2xs);
            font-weight: var(--weight-medium);
            letter-spacing: 0.04em;
        }
        .empty .empty-option-head {
            display: flex;
            align-items: baseline;
            flex-wrap: wrap;
            column-gap: var(--space-2);
            row-gap: var(--space-0_5);
        }
        .empty .empty-option-title {
            font-size: var(--fs-sm);
            font-weight: var(--weight-semibold);
            color: var(--text-hi);
            letter-spacing: var(--text-spacing);
        }
        .empty .empty-option-meta {
            font-size: var(--fs-xs);
            color: var(--text-xlo);
            font-family: var(--font-mono);
        }
        /* cmd 在 option 子卡里变成 block（不再 inline-flex），填满子卡
           宽度；上一版 "inline-flex + max-width 100%" 在 option 子卡里
           会因为父级是 flex column 把 cmd 压到 80% 宽出右，偶发折行。*/
        .empty .empty-cmd {
            display: flex;
            align-items: center;
            gap: var(--space-2);
            padding: var(--space-2) var(--space-2_5);
            background: var(--bg);
            border: 1px solid var(--border-2);
            /* v12：圆角 4 → 14。 */
            border-radius: var(--radius-sm);
            font-family: var(--font-mono);
            font-size: var(--fs-xs);
            color: var(--text-hi);
            width: 100%;
            max-width: 100%;
            flex-wrap: wrap;
            transition: border-color var(--dur-fast) var(--ease-standard);
        }
        .empty .empty-cmd:focus-within { border-color: var(--accent); }
        .empty .empty-cmd code {
            word-break: break-all;
            color: var(--text-hi);
            flex: 1 1 auto;
            min-width: 0;
        }
        /* v33+：Electron 项下"需要 Node.js 18+" 的灰色提示 —— 与 cmd
           同行，靠右，颜色 xlo 让它不抢命令的视觉重量。                 */
        .empty .empty-cmd-hint {
            font-size: var(--fs-2xs);
            color: var(--text-xlo);
            font-family: var(--font-mono);
            flex-shrink: 0;
        }
        /* v33+：宽屏中间分隔 —— 上下两条 1px 横线 + 中间一个"或"字，
           比纯文字"或"更有"二选一"的视觉锚点。窄屏不显示（已有
           column 间距自然分开）。                                            */
        .empty .empty-option-sep {
            display: none;
        }
        @media (min-width: 720px) {
            .empty .empty-option-sep {
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                gap: var(--space-2);
                color: var(--text-xlo);
                font-family: var(--font-mono);
                font-size: var(--fs-xs);
                letter-spacing: 0.06em;
                align-self: stretch;
                /* 上下两条等长 1px 边线把"或"字夹在中央，宽度 1px + 短横 */
            }
            .empty .empty-option-sep::before,
            .empty .empty-option-sep::after {
                content: "";
                width: 1px;
                flex: 1 1 auto;
                background: var(--border);
                min-height: 12px;
            }
        }
        /* 保留 .empty-alt 让旧选择器不报错（v33+ 不再渲染，但万一
           hooks / JS 仍查询它，display:none 兜底保证不出现脏布局）。     */
        .empty .empty-alt {
            display: none;
        }
        .empty .empty-hint {
            margin-top: var(--space-2);
            font-size: var(--fs-2xs);
            color: var(--text-xlo);
            font-family: var(--font-mono);
        }
        .copy-btn {
            padding: var(--space-1_5) var(--space-3);
            background: transparent;
            color: var(--text-md);
            border: 1px solid var(--border-2);
            /* v12：圆角 3 → 14，与卡片语言一致。 */
            border-radius: var(--radius-sm);
            font: inherit;
            font-size: var(--fs-2xs);
            font-weight: var(--weight-regular);
            cursor: pointer;
            transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
            flex-shrink: 0;
        }
        .copy-btn:hover { color: var(--text-hi); border-color: var(--text-lo); }
        .copy-btn:focus-visible {
            border-color: var(--text-lo);
            color: var(--text-hi);
        }
        .copy-btn.copied { color: var(--success); border-color: var(--success-bd); }

        /* ============================================================
           Toast 通知
           ============================================================ */
        .toast-stack {
            position: fixed;
            right: var(--space-5);
            /* v11 footer 已静态化，不再 sticky，无需再让位 96px */
            bottom: var(--space-5);
            display: flex;
            flex-direction: column-reverse;
            gap: var(--space-2_5);
            z-index: 1000;
            pointer-events: none;
            max-width: 360px;
            width: calc(100% - var(--space-10));
        }
        @media (max-width: 599px) {
            .toast-stack {
                right: var(--space-3);
                left: var(--space-3);
                bottom: var(--space-3);
                width: auto;
                max-width: none;
            }
        }
        .toast {
            pointer-events: auto;
            background: var(--surface-canvas);
            border: 1px solid var(--border);
            /* v12：圆角 4 → 14。 */
            border-radius: var(--radius-sm);
            /* v12：内边距加大，让浮起阴影有空间延展。 */
            padding: var(--space-3) var(--space-4);
            display: flex;
            align-items: center;
            gap: var(--space-3);
            font-size: var(--fs-xs);
            animation: toastIn .2s var(--ease-out);
            /* v12：用更柔和的 --shadow-toast 替代 --shadow-sm。 */
            box-shadow: var(--shadow-toast);
            transition: opacity var(--dur-base) var(--ease-standard), transform var(--dur-base) var(--ease-standard);
        }
        .toast.hide {
            opacity: 0;
            transform: translateX(20px);
            pointer-events: none;
        }
        /* 状态由左侧一条实色边承担，不叠投影 */
        .toast-success { border-left: 2px solid var(--success); }
        .toast-error   { border-left: 2px solid var(--error); }
        .toast-warning { border-left: 2px solid var(--warning); }
        .toast .toast-dot {
            width: 6px;
            height: 6px;
            border-radius: 50%;
            flex-shrink: 0;
        }
        .toast-success .toast-dot { background: var(--success); }
        .toast-error   .toast-dot { background: var(--error); }
        .toast-warning .toast-dot { background: var(--warning); }
        .toast .toast-body { flex: 1 1 auto; min-width: 0; line-height: var(--leading-normal); }
        .toast .toast-title {
            color: var(--text-hi);
            font-weight: var(--weight-medium);
            display: block;
            line-height: var(--leading-snug);
        }
        .toast .toast-meta {
            color: var(--text-xlo);
            font-size: var(--fs-2xs);
            font-family: var(--font-mono);
            margin-top: var(--space-0_5);
        }

        @keyframes toastIn {
            from { opacity: 0; transform: translateX(16px); }
            to   { opacity: 1; transform: translateX(0); }
        }

        /* ============================================================
           可访问性 / 偏好
           ============================================================ */
        /* 焦点环：唯一保留的实色 accent 用法。
           它不是装饰 —— WCAG 2.2 SC 1.4.11 要求焦点指示至少 3:1 对比，
           keyboard 用户全靠它定位。实色 accent 实测 4.93:1 以上达标。
           去掉外圈的 color-mix 光晕只影响观感，不影响达标。 */
        :focus-visible {
            outline: 2px solid var(--accent);
            outline-offset: 2px;
            border-radius: var(--radius-2xs);
        }
        .btn:focus-visible,
        .copy-btn:focus-visible {
            outline-offset: 2px;
        }

        @media (prefers-reduced-motion: reduce) {
            *, *::before, *::after {
                animation-duration: 0.01s !important;
                animation-iteration-count: 1 !important;
                transition-duration: 0.01s !important;
            }
            /* 保留颜色/背景/边框过渡，避免突兀 */
            .device-card, .kpi, .hero-stat, .btn,
            .refresh-info, .toast, .links a, .copy-btn {
                transition-property: color, background, background-color, border-color, box-shadow !important;
            }
        }

        /* 滚动条 */
        ::-webkit-scrollbar { width: 8px; height: 8px; }
        ::-webkit-scrollbar-track { background: transparent; }
        ::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: var(--radius-2xs); }
        ::-webkit-scrollbar-thumb:hover { background: var(--border-3); }
