Files
shiroko-monitor/public/assets/style.css
T
tamakyi 752fad412b feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)
① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

② 管理 WebUI + 2FA
- 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403
- 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量)
- 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分),
  用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容
- 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码;
  完成后凭本次验证码直接换取会话
- 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict;
  30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性;
  登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For
- 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、
  目标只读列表、最近事件
- 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json

过程中修掉三个真 bug:
1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401
2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字)
3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
2026-10-14 12:37:07 +08:00

559 lines
20 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--border: #e5e7eb;
--text: #111827;
--text-2: #6b7280;
--up: #16a34a;
--up-text: #15803d;
--up-bg: #f0fdf4;
--down: #dc2626;
--down-text: #b91c1c;
--down-bg: #fef2f2;
--pending: #d97706;
--pending-text: #92400e;
--pending-bg: #fffbeb;
--accent: #2563eb;
--uptime-ok: #16a34a;
--uptime-warn: #f59e0b;
--uptime-bad: #dc2626;
--uptime-none: #e5e7eb;
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--shadow: 0 1px 2px rgb(0 0 0 / 0.04);
/* 表格行高基准:固定像素,行高与内容长度彻底解耦(任意长度内容下行高都相同)。
72px = 名称两行(36) + 徽标行(18) + 上下内边距(16),监控表与事件表共用同一个值,
两张表因此在视觉上也严格对齐。 */
--row-h: 72px;
--group-row-h: 32px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--surface: #171a21;
--border: #2a2f3a;
--text: #e5e7eb;
--text-2: #9ca3af;
--up: #4ade80;
--up-text: #4ade80;
--up-bg: #10281c;
--down: #f87171;
--down-text: #f87171;
--down-bg: #2a1517;
--pending: #fbbf24;
--pending-text: #fbbf24;
--pending-bg: #2a2113;
--accent: #60a5fa;
--uptime-none: #2a2f3a;
--shadow: none;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
}
/* ---------- 顶栏 ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.logo {
width: 26px;
height: 26px;
flex: none;
border-radius: 50%;
background: var(--up);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--up) 18%, transparent);
animation: pulse 2.4s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 30%, transparent); }
70% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--up) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); }
}
.brand-text { min-width: 0; }
.brand-text h1 { margin: 0; font-size: 15px; font-weight: 650; white-space: nowrap; }
.brand-text p { margin: 0; font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.stats { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.controls { display: flex; align-items: center; gap: 8px; }
.pill {
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.pill.ok { background: var(--up-bg); color: var(--up-text); }
.pill.bad { background: var(--down-bg); color: var(--down-text); }
.pill.pending { background: var(--pending-bg); color: var(--pending-text); }
.stat { text-align: center; min-width: 44px; }
.stat b { display: block; font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat label { font-size: 11px; color: var(--text-2); }
.stat-up b { color: var(--up-text); }
.stat-down b { color: var(--down-text); }
#refresh-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s;
}
#refresh-btn:hover { background: var(--bg); }
#refresh-btn:disabled { opacity: 0.6; cursor: default; }
/* 加载态用的车轮图标(砂狼白子骑行意象) */
.wheel { width: 14px; height: 14px; margin-right: 6px; animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 图标按钮(主题切换) */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text-2);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.icon-btn svg { width: 16px; height: 16px; }
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
.icon-btn.active { color: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); }
/* ---------- 视图切换 ---------- */
.view-switch { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.view-btn {
padding: 5px 10px;
border: 0;
background: var(--surface);
color: var(--text-2);
font-size: 12px;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-btn + .view-btn { border-left: 1px solid var(--border); }
.view-btn:hover { color: var(--text); }
.view-btn.active { background: var(--accent); color: #fff; }
/* ---------- 分组页签 ---------- */
.group-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 1200px;
margin: 0 auto;
padding: 16px 20px 0;
}
.tab {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-2);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ---------- 卡片网格 ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
gap: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 16px 20px 8px;
}
.group-title {
grid-column: 1 / -1;
margin: 8px 0 0;
font-size: 13px;
font-weight: 650;
color: var(--text-2);
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
/* 网格项默认 min-width:auto,长 URL/名称会把卡片撑破容器 —— 显式允许收缩并裁剪 */
min-width: 0;
overflow: hidden;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--pending);
border-radius: 12px;
box-shadow: var(--shadow);
}
.card[data-status="up"] { border-left-color: var(--up); }
.card[data-status="down"] { border-left-color: var(--down); }
.card-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.card-head h3 { flex: 1; margin: 0; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dot { width: 10px; height: 10px; flex: none; border-radius: 50%; background: var(--pending); }
/* 通用着色:卡片 / 表格行 / 极简列表 都以 data-status 标记状态 */
[data-status="up"] .dot { background: var(--up); }
[data-status="down"] .dot { background: var(--down); }
[data-status="pending"] .dot { background: var(--pending); }
.card[data-status="up"] .dot { animation: pulse-dot 2.4s ease-out infinite; }
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 35%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--up) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); }
}
.type-badge {
flex: none;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-2);
}
.card-target {
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metrics { display: flex; gap: 8px; }
.metric { flex: 1; min-width: 0; }
.metric b { display: block; font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.metric label { font-size: 11px; color: var(--text-2); }
.heartbeat { height: 32px; }
.heartbeat svg { display: block; width: 100%; height: 100%; }
.hb-empty { display: flex; align-items: center; height: 100%; font-size: 12px; color: var(--text-2); }
/* 90 天可用率 */
.uptime { display: flex; align-items: center; gap: 8px; }
.uptime-bar { display: flex; gap: 1px; flex: 1; min-width: 0; height: 10px; }
.uptime-cell { flex: 1; min-width: 0; border-radius: 1px; background: var(--uptime-none); }
.uptime-cell.lv-ok { background: var(--uptime-ok); }
.uptime-cell.lv-warn { background: var(--uptime-warn); }
.uptime-cell.lv-bad { background: var(--uptime-bad); }
.uptime-label { font-size: 11px; color: var(--text-2); white-space: nowrap; font-variant-numeric: tabular-nums; }
.card-detail {
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card[data-status="down"] .card-detail { color: var(--down-text); }
.empty {
grid-column: 1 / -1;
padding: 60px 0;
text-align: center;
color: var(--text-2);
}
/* 过滤口径不一致时的提示条 */
.notice {
grid-column: 1 / -1;
margin-bottom: 10px;
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--pending) 45%, var(--border));
border-radius: 10px;
background: var(--pending-bg);
color: var(--pending);
font-size: 12px;
line-height: 1.7;
}
.notice code { font-family: var(--mono); background: color-mix(in srgb, var(--pending) 14%, transparent); padding: 0 4px; border-radius: 4px; }
/* ---------- 底栏 ---------- */
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 16px;
max-width: 1200px;
margin: 0 auto;
padding: 8px 20px 28px;
font-size: 12px;
color: var(--text-2);
}
.footer .version { margin-left: auto; }
.demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending-text); font-weight: 600; }
.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down-text); font-weight: 600; }
/* 窄屏(手机 / 竖屏平板):顶栏改为三行——标题 / 统计 / 控件,避免挤成一团 */
@media (max-width: 820px) {
.topbar { flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 14px 14px; }
.summary { flex-direction: column; align-items: stretch; gap: 8px; }
.stats { justify-content: space-between; gap: 4px; }
.stat { min-width: 0; flex: 1 1 0; }
.stat b { font-size: 15px; }
.stat label { font-size: 10px; }
.controls { justify-content: flex-end; gap: 10px; }
.footer { flex-direction: column; align-items: flex-start; gap: 4px; padding-bottom: 20px; }
.footer .version { margin-left: 0; }
}
@media (max-width: 640px) {
.grid { grid-template-columns: 1fr; padding: 12px 14px 8px; }
.group-tabs { padding: 12px 14px 0; }
.footer { padding: 8px 14px 24px; }
}
/* ================= 视图二:表格(高密度列表) ================= */
body[data-view="table"] .grid { display: block; }
.table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.monitor-table {
width: 100%;
/* 列宽按百分比分配 + 固定表格算法:表格永远恰好铺满容器宽度,不会撑出横向滚动 */
table-layout: fixed;
border-collapse: collapse;
font-size: 13px;
}
/* 行高基准:所有数据行恒为 --row-h,与内容长度彻底解耦。
内容再长也不会把某一行撑高(文本列各自限行截断 + 单元格 overflow:hidden)。
分组标题行另设 --group-row-h,用 :not() 排除掉,否则同样会被这条规则套上。 */
.monitor-table tbody tr:not(.group-row) { height: var(--row-h); }
.group-row td { height: var(--group-row-h); }
/* 表格列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 90天可用率 = 100%
地址与结果列给足比例,避免长 URL、长名称被截断 */
.monitor-table th:nth-child(1) { width: 7%; }
.monitor-table th:nth-child(2) { width: 15%; }
.monitor-table th:nth-child(3) { width: 24%; }
.monitor-table th:nth-child(4) { width: 7%; }
.monitor-table th:nth-child(5) { width: 9%; }
.monitor-table th:nth-child(6) { width: 10%; }
.monitor-table th:nth-child(7) { width: 15%; }
.monitor-table th:nth-child(8) { width: 13%; }
.monitor-table th {
padding: 10px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.monitor-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; line-height: 1.4; }
/* 所有行统一基线:可用率的 mini 条是 inline-flex,其基线会把行盒撑高十几像素,
导致带条的行比不带的行高出一截(实测 58px vs 43px) */
.monitor-table td > * { vertical-align: middle; }
.u-mini { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; }
/* 限行截断:文本再多也只占固定的两行,行高因此与内容长度彻底解耦。
height 而非 max-height —— 只有 1 行内容时也照样占满 2 行,行高才恒定;
max-height 会让短内容缩成 1 行、长内容撑到 2 行,反而出现两种行高。
-webkit-line-clamp 是 Chrome/Safari/Edge 的实现,height + overflow 兜底 Firefox。 */
.clamp2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
height: 2.8em;
}
.monitor-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
.monitor-table tbody tr:last-child td { border-bottom: 0; }
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.t-state { white-space: nowrap; }
.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; }
tr[data-status="up"] .t-state { color: var(--up-text); }
tr[data-status="down"] .t-state { color: var(--down-text); }
tr[data-status="pending"] .t-state { color: var(--pending-text); }
/* 名称列:文本区限两行,徽标区固定单行高度 —— 整格高度恒定 */
.t-name { white-space: normal; }
.t-name .clamp2 { overflow-wrap: anywhere; }
.t-tags {
display: flex;
align-items: center;
gap: 6px;
height: 16px;
margin-top: 2px;
overflow: hidden;
white-space: nowrap;
}
.t-name .type-badge { margin-left: 0; }
.t-target {
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.45;
color: var(--text-2);
white-space: normal;
}
.t-target .clamp2 { overflow-wrap: anywhere; word-break: break-all; }
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
.tls-badge, .maint-badge {
margin-left: 8px;
padding: 0 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 650;
vertical-align: middle;
white-space: nowrap;
cursor: help;
}
.tls-badge.warn { background: var(--pending-bg); color: var(--pending-text); }
.tls-badge.danger { background: var(--down-bg); color: var(--down-text); }
.maint-badge { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.card-head .tls-badge, .card-head .maint-badge { flex: none; }
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
.t-uptime { white-space: normal; }
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
.uptime-bar-mini { display: flex; flex: none; width: clamp(32px, 6vw, 84px); height: 8px; margin-right: 0; vertical-align: middle; }
.t-pct { font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
@media (max-width: 640px) {
.uptime-bar-mini { display: none; }
}
/* ---------- 最近事件:时间线风格(故障 / 恢复 / 证书提醒) ---------- */
.events {
max-width: 1200px;
margin: 18px auto 0;
padding: 0 20px;
}
.events-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.events-head h2 { margin: 0; font-size: 13px; font-weight: 650; color: var(--text); letter-spacing: 0.02em; }
.events-count { font-size: 11px; color: var(--text-2); }
.events-more {
margin-left: auto;
padding: 3px 10px;
font-size: 11px;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: 999px;
cursor: pointer;
}
.events-more:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
/* 事件表:直接复用监控表格的外观(圆角、边框、行分隔、hover、状态底色),
只额外定义列宽与类型徽标,保证两处视觉完全一致 */
/* 事件表:显式声明与监控表格相同的观感(不共用 .monitor-table 类,
避免被其他按类名统计/定位的逻辑误算),列宽与类型徽标单独定义 */
.events-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.events-table tbody tr { height: var(--row-h); }
.events-table th {
padding: 10px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
background: var(--bg);
border-bottom: 1px solid var(--border);
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
}
.events-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; line-height: 1.4; overflow: hidden; }
.events-table tbody tr:last-child td { border-bottom: 0; }
.events-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.events-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.events-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
.events-wrap { margin-top: 0; }
.events-table th:nth-child(1) { width: 11%; }
.events-table th:nth-child(2) { width: 9%; }
.events-table th:nth-child(3) { width: 26%; }
.events-table th:nth-child(4) { width: 54%; }
.ev-time { color: var(--text-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.ev-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 650;
}
tr[data-status="down"] .ev-badge { background: var(--down-bg); color: var(--down-text); }
tr[data-status="up"] .ev-badge { background: var(--up-bg); color: var(--up-text); }
tr[data-status="pending"] .ev-badge { background: var(--pending-bg); color: var(--pending-text); }
.ev-maint { margin-left: 6px; border-radius: 999px; padding: 0 6px; font-size: 10px; font-weight: 650; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.ev-name { font-weight: 600; }
.ev-detail { color: var(--text-2); }
/* 事件表的长文本同样限两行,保证与监控表格一致的等高行 */
.ev-name, .ev-detail { white-space: normal; }
.ev-name > .clamp2, .ev-detail > .clamp2 { overflow-wrap: anywhere; }
.events-empty { margin: 0; padding: 12px; font-size: 12px; color: var(--text-2); background: var(--surface); border: 1px dashed var(--border); border-radius: 12px; }
@media (max-width: 640px) {
.events { padding: 0 14px; }
.events-table th:nth-child(4), .ev-detail { display: none; }
}
/* ---------- 卡片:90 天日均延迟趋势 ---------- */
.lat-trend { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.lat-label { font-size: 11px; color: var(--text-2); flex: none; }
.lat-bars { display: flex; align-items: flex-end; gap: 1px; height: 20px; flex: 1; min-width: 0; }
.lat-cell { flex: 1; min-width: 2px; background: color-mix(in srgb, var(--accent) 50%, transparent); border-radius: 1px; }
.lat-cell:hover { background: var(--accent); }
.lat-avg { font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; flex: none; }