我按本仓库的 onboarding 链路走完了 L-core 四份 + 命中触发的 L-ref（`AGENTS.md` 硬规则/Gate、`code-conventions.md` 起手必读段 + R0/R2/R3/R13/R14/R15/R16/R19/R23/R-DISCIPLINE、`design-process.md`、`domain-tvu.md`、`figma-component-catalog.md` Chart 条），并对活源取了值（`src/tokens/variables.css`、`src/canonical/*`、`src/components/{Button,Tab,Chart,Tooltip}/*`、`dist/icons/svg/*`）。下面先交底，再给产物。

---

## R0 · Phase 0 元素→组件映射（实测，非印象）

| Element | Figma / DS 真源 | Code path | 状态 |
|---|---|---|---|
| 时间范围切换 | `Tab List` (`fill=Filled`) × `Tab/Item` (`state=Active/Normal`) | `src/canonical/TabList.vue` → `src/components/Tab/TabItem.vue` | ✅ 手写 1:1 mirror（R3：单 HTML 无构建链） |
| Export / Custom range 按钮 | `Button/dark M` (`style=ghost`,`color=gray 1`,`size=M`,`radius=square`) | `src/components/Button/Button.vue` `PALETTE_SPEC` + `SIZE_GEOMETRY` | ✅ 逐值 mirror |
| 趋势图 | `Chart` (`type=line`) | `src/canonical/Chart.vue` → `build-chart-option.ts` + `use-chart-tokens.ts` | ⚠️ 视觉 mirror（见下方偏差登记） |
| 图内 hover 提示 | `Tooltips` (`theme=dark`) | `src/components/Tooltip/Tooltip.vue` | ✅ mirror（padding/radius/shadow/font 逐值） |
| 涨跌指示 | domain-tvu **M11** 双层信号（icon + 数值染色） | `dist/icons/svg/Arrow/{Up,Down}` | ✅ 库图标 verbatim |
| 折叠说明面板 | canonical + catalog 双层 miss | — | ⚠️ **LIBRARY GAP**，已在代码内标注 |
| 明细表 | `Table` 元素基线层 (`th`/`td` padding + border) | `variables.css` element base layer | ✅ 沿用 |
| 图标 | `Arrow/Up`·`Arrow/Down`·`Arrow/Left-1`·`Arrow/Right-1`·`Arrow/Dropdown`·`Message/Info-1`·`Time/Calendar`·`pay/token` | `dist/icons/svg/**` | ✅ path 逐字复制，零自画 |

**R13 三行 trace（4 组）**
```
1. Affordance: directional-vertical (intent: 单位成本 vs 上期的涨跌方向)
2. Vocabulary scan: [Arrow/Up, Arrow/Down, Arrow/Double-up, Arrow/Double-down, Arrow/Updown, Arrow/Sorting (dist)]
3. Chosen: Arrow/Up + Arrow/Down — 与 M11 的 ↑↓ 双层信号几何 1:1

1. Affordance: directional-horizontal (intent: 右侧说明面板收起/展开)
2. Vocabulary scan: [Arrow/Left-1, Arrow/Right-1, Arrow/Next, Arrow/Previous, Arrow/left-2, Arrow/right-2 (dist)]
3. Chosen: Arrow/Right-1 (收起) + Arrow/Left-1 (展开) — 同族镜像，非 scaleX 翻转伪造

1. Affordance: status-info (intent: 标识"这个数怎么算出来的"面板)
2. Vocabulary scan: [Message/Info-1, Message/Info-2, Message/Help-1, Message/Help-2, Message/Notification-1 (dist)]
3. Chosen: Message/Info-1 — currentColor 单色，可随面板标题色

1. Affordance: time-range + domain glyph (intent: 自定义区间入口 / 页面身份)
2. Vocabulary scan: [Time/Calendar, Time/Calendar-event, Time/Clock, Time/Timer | pay/token, pay/transfer-token, pay/bill (dist)]
3. Chosen: Time/Calendar + pay/token
```

**R-DISCIPLINE checklist**（本 session 无 TodoWrite 工具，改为文本物化）：R0 ✅ / R1·R15 ✅ / R2·R2.1 ✅ / R3 ✅ / R13 ✅ / R14 ✅ / R16 ✅ / R19 ✅（见下）/ R23 ✅（三件套：截断 CSS + `scrollWidth` 检测 + `title` a11y 回退）

---

## 三条必须交底的偏差 / 缺口（不是"顺手做了"，是判断结果）

1. **图表：catalog 写"禁止自画 SVG"，我仍手绘了 SVG。** 依据是 R3 逐字条款「不能消费（如单 HTML demo，无构建链）→ 手写 1:1 mirror canonical 的视觉」。约束是 `不引外部 CDN`，ECharts 无法进场。我 mirror 的是 canonical 的**渲染契约本身**：轴线/刻度/网格 = `--line-divider-light`、轴标签与图例 = `--text-2` @12px、系列色 = `--chart-color-*`、图例 = 底部 8×8 圆点、卡面 = `--bg-layer3` + `--sp-m` + `--r-m`、renderer 也同为 SVG。**这不是绕过组件，是同一份视觉规格的无构建链实现**；真项目里应直接用 `<Chart type="line">`。

2. **`--text-warning` 不存在。** M11 的映射表把"恶化"指向 `Color/Text/Warning` (`--text-warning`)，我全仓 grep 只有 **`docs/internal/domain-tvu.md` 自己一处命中**，`src/tokens/variables.css` 里没有这个 token。按 R2「绝不 hardcode hex」，我用了存在的 `--orange`（#f68512，即 M11 说的"橙红"）。⚠️ **这是 domain-tvu.md 的一处悬挂 token 名，建议单独立项订正**，我没有替 owner 改真源。

3. **没有 >24px 的字号 token。** hero KPI 封顶在 `--font-size-large-title-24`。要更大的英雄数字就得新建 token → 按 R2 属"用户拍板 + 同步 Figma"，我不自造。当前靠 layer3 抬升面 + 权重 + 12px 环境字阶做出层级差。

另外三条判断，写出来供推翻：
- **R19 不套在 range tab 上**：R19/M38 的三层 selected（左 rail + fill + chrome）管的是产品自建的 list row / card；range tab 的 active 态是 canonical `Tab/Item` **自己的 Figma 变体**（`bg-layer4` + 700 字重），照 R19 改它就是拿 P1 规则推翻 P0 的 Figma 真源。
- **M4 dark-only**：只交付深色（DS 默认 `:root`），不做 light 并排、不做 toggle。
- **M17 宿主范式未 probe**：本 session 只读、拿不到 TVU SaaS Dashboard 的既有页面帧，所以**只交付内容区、不伪造 app 外壳（TopBar / 侧导航）**。落地前需按 M17 打开宿主 canonical 帧实测行高/控件宽/对齐再套壳。

数据为 sample data，页面上有明示。

---

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cost per Token Trend · TVU SaaS Dashboard</title>
<style>
/* ==========================================================================
   1) TVU DESIGN TOKENS
   Verbatim subset of tvu-design-system/src/tokens/variables.css (dark = :root
   default). Values copied 1:1 from the live source; nothing re-derived, nothing
   invented. Light overrides deliberately omitted — domain-tvu M4 (console /
   dashboard surfaces ship dark-only) + R2.1 ("深色是设计系统默认").
   ========================================================================== */
:root {
  /* Neutrals */
  --color-white: #ffffff;
  --color-grey-2: #f8f8f8;
  --color-grey-3: #f0f0f0;
  --color-grey-4: #dbdbdb;
  --color-grey-5: #cccccc;
  --color-grey-6: #9e9e9e;
  --color-grey-7: #7b7b7b;
  --color-grey-8: #595959;
  --color-grey-9: #434343;
  --color-grey-10: #353535;
  --color-grey-11: #262626;
  --color-grey-12: #1f1f1f;
  --color-grey-13: #141414;
  --color-grey-14: #000000;

  /* Brand & status */
  --brand: #2fb54e;
  --brand-hover: #41c760;
  --brand-match: #0b2b13;
  --brand-disable: #1a652c;
  --red: #ea4233;
  --red-hover: #ed5b4e;
  --orange: #f68512;
  --orange-hover: #f89c3e;
  --blue: #3892f3;
  --blue-hover: #62a9f6;

  /* Text */
  --text-heading: #ffffff;
  --text-body: #f8f8f8;
  --text-2: #cccccc;
  --text-tips: #9e9e9e;
  --text-placeholder: #7b7b7b;
  --text-disabled: #595959;
  --text-primary-btn: #f8f8f8;

  /* Icon */
  --icon-active: #f8f8f8;
  --icon-default: #9e9e9e;
  --icon-disabled: #595959;

  /* Surface / background */
  --bg-layer1: #141414;
  --bg-layer2: #1f1f1f;
  --bg-layer3: #262626;
  --bg-layer4: #353535;
  --bg-grey-btn-hv: #595959;
  --bg-grey-btn-en: #434343;

  /* Line / border */
  --line-border: #595959;
  --line-deep: #353535;
  --line-light: #434343;

  /* Chart categorical palette (12) — CANONICAL-011 D1 */
  --chart-color-1: var(--brand);
  --chart-color-2: var(--red);
  --chart-color-3: var(--blue);
  --chart-color-4: var(--orange);
  --chart-color-5: #2dceae;
  --chart-color-6: #d8c537;
  --chart-color-7: #9a4ee5;
  --chart-color-8: #e54eb4;
  --chart-color-9: #4e6ee5;
  --chart-color-10: #e57a4e;
  --chart-color-11: #c47de8;
  --chart-color-12: #a3d837;

  /* Spacing */
  --sp-xxs: 4px;
  --sp-xs: 8px;
  --sp-s: 12px;
  --sp-m: 16px;
  --sp-l: 24px;
  --sp-xl: 32px;
  --sp-xxl: 40px;

  /* Radius */
  --r-xs: 2px;
  --r-s: 4px;
  --r-m: 8px;
  --r-l: 12px;

  /* Z-index / elevation */
  --z-tooltip: 1000;

  /* Layout containers */
  --container-page: 1600px;

  /* Typography */
  --font-family-en: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, 'Fira Sans',
    'Droid Sans', 'Helvetica Neue', sans-serif;
  --font-family-zh: 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
  --font-family-base: var(--font-family-en), var(--font-family-zh);

  --text-style-tips:           400 12px/16px var(--font-family-base);
  --text-style-body:           400 14px/21px var(--font-family-base);
  --text-style-pop-title:      600 16px/24px var(--font-family-base);
  --text-style-headline-2:     600 18px/27px var(--font-family-base);
  --text-style-headline-1:     600 20px/30px var(--font-family-base);
  --text-style-large-title-24: 600 24px/36px var(--font-family-base);

  --font-size-tips: 12px;
  --font-size-body: 14px;
  --font-size-large-title-24: 24px;
  --line-height-large-title-24: 36px;
  --font-weight-regular: 400;
  --font-weight-semibold: 600;

  /* Effect styles — Figma 3-layered drop shadows */
  --shadow-l3:
    0 12px 48px 16px #00000008,
    0 9px 28px 0 #0000000d,
    0 6px 16px -8px #00000014;

  /* Tooltip component-variant tokens (theme-stable) */
  --tooltip-bg-dark: var(--color-grey-10);
  --tooltip-border-dark: var(--color-grey-8);
  --tooltip-text-dark: var(--color-grey-2);
  --tooltip-max-width: 320px;

  /* Derived aliases */
  --line-divider-light: var(--line-light);
  --green-bg: var(--brand-match);
}

/* --------------------------------------------------------------------------
   2) ELEMENT BASE LAYER
   Mirrors the `Element base layer` block at the tail of variables.css so this
   standalone page inherits the same correct-by-construction defaults the DS
   ships to consumers.
   -------------------------------------------------------------------------- */
* { box-sizing: border-box; }
body {
  margin: 0;
  font: var(--text-style-body);
  font-family: var(--font-family-base);
  background: var(--bg-layer1);
  color: var(--text-body);
}
h1 { font: var(--text-style-headline-1); color: var(--text-body); margin: 0; }
h2 { font: var(--text-style-headline-2); color: var(--text-body); margin: 0; }
h3 { font: var(--text-style-pop-title); color: var(--text-body); margin: 0; }
code { font-family: monospace; font-size: var(--font-size-tips); }
table { font: var(--text-style-body); color: var(--text-body); border-collapse: collapse; }
th { font-weight: 600; color: var(--text-body); text-align: left; padding: var(--sp-xs) var(--sp-s); }
td { padding: var(--sp-xs) var(--sp-s); border-top: 1px solid var(--line-border); }
[hidden] { display: none !important; }

/* --------------------------------------------------------------------------
   3) PAGE-LOCAL CONTENT DIMENSIONS
   Not design tokens. These are *content* widths/heights, the class that
   audit:layout-tokens keeps report-only on purpose ("把偶然值当契约" is the
   anti-pattern it avoids). Declared once, named, and commented rather than
   sprinkled as bare literals.
   -------------------------------------------------------------------------- */
.page {
  --panel-width: 380px;   /* explanation panel content width */
  --panel-rail: 56px;     /* collapsed rail width */
  --chart-height: 320px;  /* plot area height */
}

/* --------------------------------------------------------------------------
   4) ICON PRIMITIVE — assets are verbatim TVU library SVG (R1 / R15)
   -------------------------------------------------------------------------- */
.icon-sprite { display: none; }
.icon {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  display: inline-block;
  vertical-align: -3px;
}

/* --------------------------------------------------------------------------
   5) PAGE SHELL
   R14: grid for the 2-D page split, flex for every 1-D row. No absolute
   positioning outside the two overlay affordances (chart tooltip, marked).
   -------------------------------------------------------------------------- */
.page {
  max-width: var(--container-page);
  margin: 0 auto;
  padding: var(--sp-l);
  display: flex;
  flex-direction: column;
  gap: var(--sp-l);
}

.page-head { display: flex; flex-direction: column; gap: var(--sp-xs); }
.page-head__eyebrow {
  font: var(--text-style-tips);
  color: var(--text-tips);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.page-head__row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-m);
  flex-wrap: wrap;
}
.page-head__title { display: flex; align-items: center; gap: var(--sp-xs); }
.page-head__meta {
  display: flex;
  align-items: center;
  gap: var(--sp-xs);
  font: var(--text-style-tips);
  color: var(--text-tips);
  margin-top: var(--sp-xxs);
}
.page-head__meta .dot { color: var(--line-border); }
.tag-demo {
  border: 1px solid var(--line-light);
  border-radius: var(--r-s);
  padding: 0 var(--sp-xxs);
  color: var(--text-tips);
}

/* --------------------------------------------------------------------------
   6) BUTTON — 1:1 mirror of src/components/Button/Button.vue
   style=ghost · color="gray 1" · size=M · radius=square
   SIZE_GEOMETRY.M  -> height 32 / font 14 / line-height 21 / radius 4 /
                       paddingSquare 16 / iconGap 4
   PALETTE_SPEC.ghost["gray 1"] -> default {bg transparent, text --text-2,
                       border --bg-grey-btn-en} · hover {bg --bg-grey-btn-en}
   ghost border-width = 1.2px
   -------------------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-xxs);
  height: 32px;
  padding-inline: var(--sp-m);
  border: 1.2px solid var(--bg-grey-btn-en);
  border-radius: var(--r-s);
  background: transparent;
  color: var(--text-2);
  font-family: var(--font-family-base);
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-regular);
  line-height: 21px;
  cursor: pointer;
  white-space: nowrap;
  user-select: none;
  outline: none;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn:hover { background: var(--bg-grey-btn-en); color: var(--text-2); }
.btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

/* Icon-only 32px control, same ghost palette (used by the panel collapse toggle) */
.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1.2px solid transparent;
  border-radius: var(--r-s);
  background: transparent;
  color: var(--icon-default);
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.icon-btn:hover { background: var(--bg-grey-btn-en); color: var(--icon-active); }
.icon-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

/* --------------------------------------------------------------------------
   7) TAB LIST (fill = Filled) — 1:1 mirror of
   src/components/Tab/TabList.vue + TabItem.vue
   list:  bg --bg-layer2 · 1px --line-deep · radius --r-s · gap 0
   item:  h32 · padding 0 16 · font body · gap 8
   normal:        bg --bg-layer3  · color --text-2
   active(white): bg --bg-layer4  · color --text-body · weight 700
   NOTE ON R19 — the DS Tab/Item's own Figma variant IS the selected spec here
   (fill + weight, no left rail). R19/M38's 3-layer rail belongs to product-built
   list rows; applying it here would override a canonical component's Figma truth
   (FIGMA_AS_SOURCE_OF_TRUTH.md > R19 in the P0 > P1 hierarchy).
   -------------------------------------------------------------------------- */
.tab-list {
  display: inline-flex;
  align-items: stretch;
  background: var(--bg-layer2);
  border: 1px solid var(--line-deep);
  border-radius: var(--r-s);
}
.tab-list__items { display: inline-flex; align-items: center; gap: 0; }
.tab-item {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-xs);
  height: 32px;
  padding: 0 var(--sp-m);
  border: 0;
  background: var(--bg-layer3);
  font: var(--text-style-body);
  color: var(--text-2);
  cursor: pointer;
  white-space: nowrap;
  user-select: none;
  transition: color 0.15s, background 0.15s;
}
.tab-item:first-child { border-radius: var(--r-s) 0 0 var(--r-s); }
.tab-item:last-child  { border-radius: 0 var(--r-s) var(--r-s) 0; }
.tab-item[aria-selected="true"] {
  background: var(--bg-layer4);
  color: var(--text-body);
  font-weight: 700;
}
.tab-item:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }

.toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-m);
  flex-wrap: wrap;
}
.toolbar__left, .toolbar__right { display: flex; align-items: center; gap: var(--sp-s); }
.toolbar__hint { font: var(--text-style-tips); color: var(--text-tips); }

/* --------------------------------------------------------------------------
   8) BODY GRID — main + collapsible aside
   -------------------------------------------------------------------------- */
.page-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--panel-width);
  gap: var(--sp-l);
  align-items: start;
}
.page-body[data-panel="collapsed"] {
  grid-template-columns: minmax(0, 1fr) var(--panel-rail);
}
.main-col { display: flex; flex-direction: column; gap: var(--sp-l); min-width: 0; }

/* --------------------------------------------------------------------------
   9) OVERVIEW — hero KPI + supporting tiles
   -------------------------------------------------------------------------- */
.overview {
  display: grid;
  grid-template-columns: minmax(260px, 1.5fr) repeat(4, minmax(0, 1fr));
  gap: var(--sp-m);
}
.card {
  background: var(--bg-layer2);
  border: 1px solid var(--line-deep);
  border-radius: var(--r-m);
  padding: var(--sp-m);
  min-width: 0;
}
.card--hero {
  background: var(--bg-layer3);
  padding: var(--sp-l);
  display: flex;
  flex-direction: column;
  gap: var(--sp-xs);
}
.kpi-label {
  font: var(--text-style-tips);
  color: var(--text-tips);
  display: flex;
  align-items: center;
  gap: var(--sp-xxs);
}
.kpi-value {
  font-family: var(--font-family-base);
  font-size: var(--font-size-large-title-24);
  line-height: var(--line-height-large-title-24);
  font-weight: var(--font-weight-semibold);
  color: var(--text-heading);
  font-variant-numeric: tabular-nums;
  display: flex;
  align-items: baseline;
  gap: var(--sp-xs);
}
.kpi-value .unit {
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-regular);
  color: var(--text-tips);
}

/* M11 — trend = icon + coloured VALUE (two-layer signal, neither alone) */
.delta { display: flex; align-items: center; gap: var(--sp-xxs); }
.delta__value {
  font: var(--text-style-pop-title);
  font-variant-numeric: tabular-nums;
}
.delta__ref { font: var(--text-style-body); color: var(--text-tips); }
.delta--better .delta__value, .delta--better .icon { color: var(--brand); }
.delta--worse  .delta__value, .delta--worse  .icon { color: var(--orange); }
.delta--flat   .delta__value { color: var(--text-body); }
.kpi-hint { font: var(--text-style-tips); color: var(--text-tips); }

.tile { display: flex; flex-direction: column; gap: var(--sp-xxs); }
.tile__value {
  font: var(--text-style-headline-2);
  color: var(--text-body);
  font-variant-numeric: tabular-nums;
}
.tile__sub { font: var(--text-style-tips); color: var(--text-tips); }
.tile__value.is-better { color: var(--brand); }
.tile__value.is-worse  { color: var(--orange); }

/* --------------------------------------------------------------------------
   10) CHART CARD
   Inner .tvu-chart mirrors src/components/Chart/Chart.vue scoped style
   (padding --sp-m · radius --r-m · background --bg-layer3).
   Axis / tick / split-line colour  = --line-divider-light  (resolveAxisLineColor)
   Axis label + legend text colour  = --text-2 @12px        (resolveLegendColor)
   Series colours                   = --chart-color-*       (resolveChartPalette)
   Legend                           = bottom · circle · 8x8 (buildChartOption)
   -------------------------------------------------------------------------- */
.chart-card { display: flex; flex-direction: column; gap: var(--sp-m); }
.chart-card__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-m);
  flex-wrap: wrap;
}
.chart-card__sub { font: var(--text-style-tips); color: var(--text-tips); margin-top: var(--sp-xxs); }

.tvu-chart {
  position: relative; /* anchor for the hover tooltip overlay (see .chart-tip) */
  box-sizing: border-box;
  padding: var(--sp-m);
  border-radius: var(--r-m);
  background: var(--bg-layer3);
}
.tvu-chart__view { display: block; width: 100%; height: var(--chart-height); }
.chart-legend {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: var(--sp-m);
  margin-top: var(--sp-xs);
}
.chart-legend__item {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-xxs);
  font-family: var(--font-family-base);
  font-size: var(--font-size-tips);
  color: var(--text-2);
}
.chart-legend__mark { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.chart-legend__mark--dash {
  width: 12px;
  height: 0;
  border-radius: 0;
  border-top: 2px dashed currentColor;
}

/* Chart hover tooltip — 1:1 mirror of src/components/Tooltip/Tooltip.vue
   (.tooltip-box, theme=dark). position:absolute is the sanctioned R14 case:
   an overlay anchored to the plot area, no layout semantics. */
.chart-tip {
  position: absolute; /* overlay anchored to the plot area */
  z-index: var(--z-tooltip);
  max-width: var(--tooltip-max-width);
  padding: var(--sp-xxs) var(--sp-xs);
  border-radius: var(--r-s);
  background: var(--tooltip-bg-dark);
  border: 1px solid var(--tooltip-border-dark);
  color: var(--tooltip-text-dark);
  font: var(--text-style-tips);
  box-shadow: var(--shadow-l3);
  pointer-events: none;
  transform: translate(-50%, -100%);
}
.chart-tip__row { display: flex; align-items: center; gap: var(--sp-xxs); white-space: nowrap; }
.chart-tip__row + .chart-tip__row { margin-top: 2px; }
.chart-tip__mark { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.chart-tip__label { color: var(--text-tips); }
.chart-tip__num { margin-left: auto; font-variant-numeric: tabular-nums; }
.chart-tip__title {
  color: var(--tooltip-text-dark);
  font-weight: var(--font-weight-semibold);
  margin-bottom: var(--sp-xxs);
  white-space: nowrap;
}

/* --------------------------------------------------------------------------
   11) EXPLANATION PANEL
   LIBRARY GAP: no collapsible side panel / accordion exists in
   src/canonical/* (39 components checked) nor in
   docs/internal/figma-component-catalog.md (grep: collapse|accordion|drawer|
   panel|expand -> 0 component entries). Composed from DS surface + line +
   radius + spacing tokens and library icons only; candidate for
   tvu-design-system intake -> backlog.md BRIDGE-CODE-CANONICAL.
   -------------------------------------------------------------------------- */
.panel {
  background: var(--bg-layer2);
  border: 1px solid var(--line-deep);
  border-radius: var(--r-m);
  position: sticky;
  top: var(--sp-l);
  overflow: hidden;
}
.panel__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-xs);
  padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-m);
  border-bottom: 1px solid var(--line-deep);
}
.panel__title {
  display: flex;
  align-items: center;
  gap: var(--sp-xs);
  font: var(--text-style-pop-title);
  color: var(--text-body);
}
.panel__title .icon { color: var(--blue); }
.panel__body {
  padding: var(--sp-m);
  display: flex;
  flex-direction: column;
  gap: var(--sp-m);
  max-height: 72vh;
  overflow-y: auto;
}
.panel__rail {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-s);
  padding: var(--sp-s) 0;
}
.panel__rail-label {
  writing-mode: vertical-rl;
  font: var(--text-style-tips);
  color: var(--text-tips);
  letter-spacing: 0.04em;
}

.sec { display: flex; flex-direction: column; gap: var(--sp-xs); }
.sec__title {
  font: var(--text-style-tips);
  color: var(--text-tips);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.sec p { margin: 0; font: var(--text-style-body); color: var(--text-2); }
.sec ul { margin: 0; padding-left: var(--sp-m); color: var(--text-2); }
.sec li + li { margin-top: var(--sp-xxs); }

.formula {
  display: block;
  background: var(--bg-layer3);
  border: 1px solid var(--line-deep);
  border-radius: var(--r-s);
  padding: var(--sp-s);
  color: var(--text-body);
  line-height: 18px;
  overflow-x: auto;
}
.kv { display: flex; flex-direction: column; gap: var(--sp-xxs); }
.kv__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-s);
  font: var(--text-style-body);
}
.kv__row--total { border-top: 1px solid var(--line-deep); padding-top: var(--sp-xs); }
.kv__k { color: var(--text-tips); }
.kv__v { color: var(--text-body); font-variant-numeric: tabular-nums; }
.kv__v--accent { color: var(--brand); font-weight: var(--font-weight-semibold); }

.breakdown { width: 100%; table-layout: fixed; }
.breakdown th { font: var(--text-style-tips); color: var(--text-tips); font-weight: 400; padding-left: 0; }
.breakdown th:last-child, .breakdown td:last-child { text-align: right; padding-right: 0; }
.breakdown td { border-top: 1px solid var(--line-deep); font-size: var(--font-size-tips); color: var(--text-2); padding-left: 0; }
.breakdown td:nth-child(2), .breakdown td:nth-child(3) { font-variant-numeric: tabular-nums; color: var(--text-body); }
/* R23 (a) truncation CSS — (b) detection + (c) title fallback live in JS */
.trunc { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.signal-legend { display: flex; flex-direction: column; gap: var(--sp-xxs); }
.signal-legend__row {
  display: flex;
  align-items: center;
  gap: var(--sp-xs);
  font: var(--text-style-tips);
  color: var(--text-2);
}
.signal-legend__row .icon { width: 12px; height: 12px; }
.is-better { color: var(--brand); }
.is-worse  { color: var(--orange); }

.panel__foot {
  border-top: 1px solid var(--line-deep);
  padding-top: var(--sp-s);
  font: var(--text-style-tips);
  color: var(--text-placeholder);
}

/* --------------------------------------------------------------------------
   12) RESPONSIVE
   Literals are members of the --bp-* token value set (640 / 960 / 1100 / 1280)
   — CSS @media cannot consume var(), so "referencing" the token means the
   literal must be in that set (audit:layout-tokens contract).
   -------------------------------------------------------------------------- */
@media (max-width: 1280px) {
  .overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .card--hero { grid-column: 1 / -1; }
}
@media (max-width: 1100px) {
  .page-body,
  .page-body[data-panel="collapsed"] { grid-template-columns: minmax(0, 1fr); }
  .panel { position: static; }
  .panel__rail { flex-direction: row; justify-content: center; }
  .panel__rail-label { writing-mode: horizontal-tb; }
}
@media (max-width: 640px) {
  .page { padding: var(--sp-m); }
  .overview { grid-template-columns: minmax(0, 1fr); }
}
</style>
</head>
<body>

<!-- ==========================================================================
     ICON SPRITE
     Every path below is copied byte-for-byte out of
     tvu-design-system/dist/icons/svg/**. Zero hand-drawn geometry, zero
     Unicode/emoji stand-ins (R1 / R13 Step 4 blacklist).
       Arrow/Up            1149_23391
       Arrow/Down          1149_23407
       Arrow/Left-1        1149_23313
       Arrow/Right-1       1149_23262
       Arrow/Dropdown      1150_23504
       Message/Info-1      1168_23662
       Time/Calendar       1243_23532
       pay/token           2343_253   (multi-colour asset — its #FFB524 fill is
                                       the library asset's own value, kept
                                       verbatim; not an authored colour choice)
     ========================================================================== -->
<svg class="icon-sprite" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
  <symbol id="i-arrow-up" viewBox="0 0 16 16">
    <path d="M12.7387 7.18614C12.9731 7.42046 13.3529 7.42046 13.5873 7.18614C13.8216 6.95183 13.8216 6.57193 13.5873 6.33762L8.4417 1.19205C8.43644 1.18637 8.43105 1.18077 8.42553 1.17525C8.19122 0.940938 7.81132 0.940938 7.577 1.17525L2.41512 6.33713C2.18081 6.57145 2.18081 6.95135 2.41512 7.18566C2.64944 7.41997 3.02934 7.41997 3.26365 7.18566L7.40144 3.04787L7.40144 14.402C7.40144 14.7333 7.67007 15.002 8.00144 15.002C8.33281 15.002 8.60144 14.7333 8.60144 14.402L8.60144 3.04885L12.7387 7.18614Z" fill="currentColor"/>
  </symbol>
  <symbol id="i-arrow-down" viewBox="0 0 16 16">
    <path d="M3.26364 8.81581C3.02933 8.58149 2.64943 8.58149 2.41512 8.81581C2.1808 9.05012 2.1808 9.43002 2.41512 9.66434L7.56068 14.8099C7.56594 14.8156 7.57133 14.8212 7.57685 14.8267C7.81116 15.061 8.19106 15.061 8.42538 14.8267L13.5873 9.66482C13.8216 9.43051 13.8216 9.05061 13.5873 8.81629C13.3529 8.58198 12.973 8.58198 12.7387 8.81629L8.60094 12.9541L8.60094 1.6C8.60094 1.26863 8.33231 1 8.00094 1C7.66957 1 7.40094 1.26863 7.40094 1.6L7.40094 12.9531L3.26364 8.81581Z" fill="currentColor"/>
  </symbol>
  <symbol id="i-arrow-left" viewBox="0 0 16 16">
    <path d="M7.18663 3.26255C7.42095 3.02823 7.42095 2.64833 7.18663 2.41402C6.95232 2.1797 6.57242 2.1797 6.3381 2.41402L1.19254 7.55958C1.18686 7.56484 1.18126 7.57023 1.17574 7.57575C0.941426 7.81007 0.941426 8.18997 1.17574 8.42428L6.33762 13.5862C6.57193 13.8205 6.95183 13.8205 7.18615 13.5862C7.42046 13.3518 7.42046 12.9719 7.18615 12.7376L3.04836 8.59984H14.4024C14.7338 8.59984 15.0024 8.33121 15.0024 7.99984C15.0024 7.66847 14.7338 7.39984 14.4024 7.39984H3.04934L7.18663 3.26255Z" fill="currentColor"/>
  </symbol>
  <symbol id="i-arrow-right" viewBox="0 0 16 16">
    <path d="M8.81581 3.26255C8.58149 3.02823 8.58149 2.64833 8.81581 2.41402C9.05012 2.1797 9.43002 2.1797 9.66434 2.41402L14.8099 7.55958C14.8156 7.56484 14.8212 7.57023 14.8267 7.57575C15.061 7.81007 15.061 8.18997 14.8267 8.42428L9.66482 13.5862C9.43051 13.8205 9.05061 13.8205 8.81629 13.5862C8.58198 13.3518 8.58198 12.9719 8.81629 12.7376L12.9541 8.59984H1.6C1.26863 8.59984 1 8.33121 1 7.99984C1 7.66847 1.26863 7.39984 1.6 7.39984H12.9531L8.81581 3.26255Z" fill="currentColor"/>
  </symbol>
  <symbol id="i-arrow-dropdown" viewBox="0 0 16 16">
    <path d="M3.05146 6.20128C3.27161 5.95374 3.65086 5.93145 3.89853 6.15148L7.86707 9.67727C7.94284 9.74458 8.05698 9.74458 8.13274 9.67727L12.1013 6.15148C12.349 5.93145 12.7282 5.95374 12.9484 6.20128C13.1685 6.44883 13.1462 6.82788 12.8985 7.04791L8.39841 11.046C8.17112 11.2479 7.8287 11.2479 7.60141 11.046L3.10129 7.04791C2.85362 6.82788 2.83131 6.44883 3.05146 6.20128Z" fill="currentColor"/>
  </symbol>
  <symbol id="i-info" viewBox="0 0 16 16">
    <path d="M15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8ZM13.8 7.99922C13.8 4.79597 11.2033 2.19922 8.00001 2.19922C4.79676 2.19922 2.20001 4.79597 2.20001 7.99922C2.20001 11.2025 4.79676 13.7992 8.00001 13.7992C11.2033 13.7992 13.8 11.2025 13.8 7.99922ZM8.60002 4.6C8.60002 4.93137 8.3314 5.2 8.00002 5.2C7.66865 5.2 7.40002 4.93137 7.40002 4.6C7.40002 4.26863 7.66865 4 8.00002 4C8.3314 4 8.60002 4.26863 8.60002 4.6ZM7.40002 7C7.40002 6.66863 7.66865 6.4 8.00002 6.4C8.3314 6.4 8.60002 6.66863 8.60002 7V11.4C8.60002 11.7314 8.3314 12 8.00002 12C7.66865 12 7.40002 11.7314 7.40002 11.4V7Z" fill="currentColor"/>
  </symbol>
  <symbol id="i-calendar" viewBox="0 0 16 16">
    <path d="M4.6 1C4.26863 1 4 1.26863 4 1.6V2H2.2C1.53726 2 1 2.53726 1 3.2V13.8C1 14.4627 1.53726 15 2.2 15H13.8C14.4627 15 15 14.4627 15 13.8V3.2C15 2.53726 14.4627 2 13.8 2H12V1.6C12 1.26863 11.7314 1 11.4 1C11.0686 1 10.8 1.26863 10.8 1.6V2H5.2V1.6C5.2 1.26863 4.93137 1 4.6 1ZM10.8 3.4C10.8 3.73137 11.0686 4 11.4 4C11.7314 4 12 3.73137 12 3.4V3.20078H13.6C13.7105 3.20078 13.8 3.29032 13.8 3.40078V13.6008C13.8 13.7112 13.7105 13.8008 13.6 13.8008H2.40001C2.28956 13.8008 2.20001 13.7112 2.20001 13.6008V3.40078C2.20001 3.29032 2.28956 3.20078 2.40001 3.20078H4V3.4C4 3.73137 4.26863 4 4.6 4C4.93137 4 5.2 3.73137 5.2 3.4V3.20078H10.8V3.4ZM4 6.1C4 5.76863 4.26863 5.5 4.6 5.5H5.4C5.73137 5.5 6 5.76863 6 6.1C6 6.43137 5.73137 6.7 5.4 6.7H4.6C4.26863 6.7 4 6.43137 4 6.1ZM4.6 8.2C4.26863 8.2 4 8.46863 4 8.8C4 9.13137 4.26863 9.4 4.6 9.4H5.4C5.73137 9.4 6 9.13137 6 8.8C6 8.46863 5.73137 8.2 5.4 8.2H4.6ZM4.6 10.9C4.26863 10.9 4 11.1686 4 11.5C4 11.8314 4.26863 12.1 4.6 12.1H5.4C5.73137 12.1 6 11.8314 6 11.5C6 11.1686 5.73137 10.9 5.4 10.9H4.6ZM7.6 5.5C7.26863 5.5 7 5.76863 7 6.1C7 6.43137 7.26863 6.7 7.6 6.7H8.4C8.73137 6.7 9 6.43137 9 6.1C9 5.76863 8.73137 5.5 8.4 5.5H7.6ZM10 6.1C10 5.76863 10.2686 5.5 10.6 5.5H11.4C11.7314 5.5 12 5.76863 12 6.1C12 6.43137 11.7314 6.7 11.4 6.7H10.6C10.2686 6.7 10 6.43137 10 6.1ZM7.6 8.2C7.26863 8.2 7 8.46863 7 8.8C7 9.13137 7.26863 9.4 7.6 9.4H8.4C8.73137 9.4 9 9.13137 9 8.8C9 8.46863 8.73137 8.2 8.4 8.2H7.6ZM10 8.8C10 8.46863 10.2686 8.2 10.6 8.2H11.4C11.7314 8.2 12 8.46863 12 8.8C12 9.13137 11.7314 9.4 11.4 9.4H10.6C10.2686 9.4 10 9.13137 10 8.8ZM7.6 10.9C7.26863 10.9 7 11.1686 7 11.5C7 11.8314 7.26863 12.1 7.6 12.1H8.4C8.73137 12.1 9 11.8314 9 11.5C9 11.1686 8.73137 10.9 8.4 10.9H7.6ZM10 11.5C10 11.1686 10.2686 10.9 10.6 10.9H11.4C11.7314 10.9 12 11.1686 12 11.5C12 11.8314 11.7314 12.1 11.4 12.1H10.6C10.2686 12.1 10 11.8314 10 11.5Z" fill="currentColor"/>
  </symbol>
  <symbol id="i-token" viewBox="0 0 16 16">
    <path d="M15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8ZM13.8 7.99922C13.8 4.79597 11.2033 2.19922 8.00001 2.19922C4.79676 2.19922 2.20001 4.79597 2.20001 7.99922C2.20001 11.2025 4.79676 13.7992 8.00001 13.7992C11.2033 13.7992 13.8 11.2025 13.8 7.99922ZM10.404 9.34439C10.3259 9.4225 10.1993 9.4225 10.1212 9.34439L9.55548 8.77871C9.47737 8.7006 9.47737 8.57397 9.55548 8.49587L9.91067 8.14067C9.98878 8.06257 9.98878 7.93594 9.91067 7.85783L9.20066 7.14782C9.12256 7.06972 9.12256 6.94309 9.20066 6.86498L9.7651 6.30055C9.8432 6.22245 9.96983 6.22245 10.0479 6.30055L10.7579 7.01056C10.8361 7.08866 10.9627 7.08866 11.0408 7.01056L11.394 6.65739C11.4721 6.57928 11.5987 6.57928 11.6768 6.65739L12.2425 7.22307C12.3206 7.30118 12.3206 7.42781 12.2425 7.50592L10.404 9.34439ZM9.3431 5.59704C9.42121 5.67515 9.42121 5.80178 9.3431 5.87988L8.77742 6.44557C8.69931 6.52367 8.57268 6.52367 8.49458 6.44557L8.13938 6.09038C8.06128 6.01227 7.93465 6.01227 7.85654 6.09038L7.14653 6.80038C7.06843 6.87849 6.9418 6.87849 6.86369 6.80038L6.29926 6.23595C6.22115 6.15785 6.22115 6.03121 6.29926 5.95311L7.00927 5.2431C7.08737 5.165 7.08737 5.03836 7.00927 4.96026L6.6561 4.60709C6.57799 4.52899 6.57799 4.40235 6.6561 4.32425L7.22178 3.75856C7.29989 3.68046 7.42652 3.68046 7.50463 3.75856L9.3431 5.59704ZM3.75749 8.4956C3.67938 8.5737 3.67938 8.70034 3.75749 8.77844L4.32317 9.34413C4.40128 9.42223 4.52791 9.42223 4.60602 9.34413L4.95918 8.99096C5.03729 8.91285 5.16392 8.91285 5.24203 8.99096L5.95203 9.70097C6.03014 9.77907 6.15677 9.77907 6.23488 9.70097L6.79931 9.13653C6.87741 9.05843 6.87741 8.9318 6.79931 8.85369L6.0893 8.14368C6.0112 8.06558 6.0112 7.93895 6.0893 7.86084L6.44449 7.50565C6.5226 7.42755 6.5226 7.30091 6.44449 7.22281L5.87881 6.65712C5.8007 6.57902 5.67407 6.57902 5.59597 6.65712L3.75749 8.4956ZM6.65691 10.4045C6.5788 10.3264 6.5788 10.1998 6.65691 10.1217L7.2226 9.55599C7.3007 9.47789 7.42733 9.47789 7.50544 9.55599L7.86063 9.91118C7.93874 9.98929 8.06537 9.98929 8.14347 9.91118L8.85348 9.20118C8.93159 9.12307 9.05822 9.12307 9.13632 9.20118L9.70076 9.76561C9.77886 9.84371 9.77886 9.97035 9.70076 10.0485L8.99075 10.7585C8.91264 10.8366 8.91264 10.9632 8.99075 11.0413L9.34392 11.3945C9.42202 11.4726 9.42202 11.5992 9.34392 11.6773L8.77823 12.243C8.70013 12.3211 8.57349 12.3211 8.49539 12.243L6.65691 10.4045Z" fill="#FFB524"/>
  </symbol>
</svg>

<div class="page">

  <!-- ====================== PAGE HEAD ====================== -->
  <header class="page-head">
    <div class="page-head__eyebrow" id="t-eyebrow"></div>
    <div class="page-head__row">
      <div>
        <div class="page-head__title">
          <svg class="icon" aria-hidden="true"><use href="#i-token"/></svg>
          <h1 id="t-title"></h1>
        </div>
        <div class="page-head__meta">
          <span id="t-updated"></span>
          <span class="dot">·</span>
          <span id="t-source"></span>
          <span class="dot">·</span>
          <span class="tag-demo" id="t-demo"></span>
        </div>
      </div>
      <div class="toolbar__right">
        <button class="btn" type="button" id="btn-export"></button>
      </div>
    </div>
  </header>

  <!-- ====================== RANGE SWITCHER ======================
       Scoped to the WHOLE page (overview tiles + chart + panel figures all
       recompute), so it sits above the overview rather than inside the chart
       card — otherwise the control would read as chart-only. -->
  <section class="toolbar" aria-label="Time range">
    <div class="toolbar__left">
      <div class="tab-list">
        <div class="tab-list__items" role="tablist" id="range-tabs"></div>
      </div>
      <button class="btn" type="button" id="btn-custom">
        <svg class="icon" aria-hidden="true"><use href="#i-calendar"/></svg>
        <span></span>
      </button>
    </div>
    <div class="toolbar__hint" id="t-granularity"></div>
  </section>

  <div class="page-body" id="page-body" data-panel="expanded">

    <div class="main-col">

      <!-- ====================== 1 · OVERVIEW ====================== -->
      <section class="overview" aria-label="Period summary">

        <div class="card card--hero">
          <div class="kpi-label" id="t-hero-label"></div>
          <div class="kpi-value">
            <span id="v-cpk"></span>
            <span class="unit" id="t-unit"></span>
          </div>
          <!-- domain-tvu M11: direction icon AND coloured value — two layers -->
          <div class="delta" id="delta-block">
            <svg class="icon" aria-hidden="true" id="delta-icon"><use href="#i-arrow-down" id="delta-icon-use"/></svg>
            <span class="delta__value" id="v-delta"></span>
            <span class="delta__ref" id="v-delta-ref"></span>
          </div>
          <div class="kpi-hint" id="t-hero-hint"></div>
        </div>

        <div class="card tile">
          <span class="kpi-label" id="t-tile1"></span>
          <span class="tile__value" id="v-total"></span>
          <span class="tile__sub" id="v-total-sub"></span>
        </div>

        <div class="card tile">
          <span class="kpi-label" id="t-tile2"></span>
          <span class="tile__value" id="v-tokens"></span>
          <span class="tile__sub" id="v-tokens-sub"></span>
        </div>

        <div class="card tile">
          <span class="kpi-label" id="t-tile3"></span>
          <span class="tile__value" id="v-best"></span>
          <span class="tile__sub" id="v-best-sub"></span>
        </div>

        <div class="card tile">
          <span class="kpi-label" id="t-tile4"></span>
          <span class="tile__value" id="v-target"></span>
          <span class="tile__sub" id="v-target-sub"></span>
        </div>

      </section>

      <!-- ====================== 2 · TREND CHART ====================== -->
      <section class="card chart-card" aria-label="Cost per token trend">
        <div class="chart-card__head">
          <div>
            <h2 id="t-chart-title"></h2>
            <div class="chart-card__sub" id="t-chart-sub"></div>
          </div>
        </div>

        <!-- .tvu-chart == src/components/Chart/Chart.vue scoped style, 1:1 -->
        <div class="tvu-chart" id="chart-host">
          <svg class="tvu-chart__view" id="chart" role="img" aria-label=""></svg>
          <div class="chart-tip" id="chart-tip" hidden></div>
        </div>

        <div class="chart-legend" id="chart-legend"></div>
      </section>

    </div>

    <!-- ====================== 4 · EXPLANATION PANEL ======================
         LIBRARY GAP: collapsible side panel; candidate for tvu-design-system
         intake -> backlog.md BRIDGE-CODE-CANONICAL. Built from DS tokens +
         library icons only. -->
    <aside class="panel" id="panel" aria-label="Metric definition">

      <div class="panel__rail" id="panel-rail" hidden>
        <button class="icon-btn" type="button" id="btn-expand" aria-expanded="false" aria-controls="panel-content">
          <svg class="icon" aria-hidden="true"><use href="#i-arrow-left"/></svg>
        </button>
        <svg class="icon" aria-hidden="true" style="color: var(--blue)"><use href="#i-info"/></svg>
        <span class="panel__rail-label" id="t-rail-label"></span>
      </div>

      <div id="panel-content">
        <header class="panel__head">
          <span class="panel__title">
            <svg class="icon" aria-hidden="true"><use href="#i-info"/></svg>
            <span id="t-panel-title"></span>
          </span>
          <button class="icon-btn" type="button" id="btn-collapse" aria-expanded="true" aria-controls="panel-content">
            <svg class="icon" aria-hidden="true"><use href="#i-arrow-right"/></svg>
          </button>
        </header>

        <div class="panel__body">

          <div class="sec">
            <div class="sec__title" id="t-sec-formula"></div>
            <code class="formula" id="t-formula"></code>
          </div>

          <div class="sec">
            <div class="sec__title" id="t-sec-inputs"></div>
            <div class="kv">
              <div class="kv__row"><span class="kv__k" id="t-in-cost"></span><span class="kv__v" id="v-in-cost"></span></div>
              <div class="kv__row"><span class="kv__k" id="t-in-tokens"></span><span class="kv__v" id="v-in-tokens"></span></div>
              <div class="kv__row kv__row--total"><span class="kv__k" id="t-in-result"></span><span class="kv__v kv__v--accent" id="v-in-result"></span></div>
            </div>
          </div>

          <div class="sec">
            <div class="sec__title" id="t-sec-breakdown"></div>
            <table class="breakdown">
              <thead>
                <tr>
                  <th id="t-bd-col1"></th>
                  <th id="t-bd-col2"></th>
                  <th id="t-bd-col3"></th>
                </tr>
              </thead>
              <tbody id="breakdown-body"></tbody>
            </table>
          </div>

          <div class="sec">
            <div class="sec__title" id="t-sec-signal"></div>
            <div class="signal-legend">
              <div class="signal-legend__row">
                <svg class="icon is-better" aria-hidden="true"><use href="#i-arrow-down"/></svg>
                <span id="t-sig-better"></span>
              </div>
              <div class="signal-legend__row">
                <svg class="icon is-worse" aria-hidden="true"><use href="#i-arrow-up"/></svg>
                <span id="t-sig-worse"></span>
              </div>
              <div class="signal-legend__row">
                <span style="width:12px"></span>
                <span id="t-sig-flat"></span>
              </div>
            </div>
          </div>

          <div class="sec">
            <div class="sec__title" id="t-sec-included"></div>
            <ul id="list-included"></ul>
          </div>

          <div class="sec">
            <div class="sec__title" id="t-sec-excluded"></div>
            <ul id="list-excluded"></ul>
          </div>

          <div class="sec">
            <div class="sec__title" id="t-sec-freshness"></div>
            <p id="t-freshness"></p>
          </div>

          <div class="sec">
            <div class="sec__title" id="t-sec-why"></div>
            <ul id="list-why"></ul>
          </div>

          <div class="panel__foot" id="t-panel-foot"></div>

        </div>
      </div>
    </aside>

  </div>
</div>

<script>
/* ==========================================================================
   R16 — every product-UI string lives in one const map (single-language EN).
   No inline bilingual literals; comments may be bilingual, runtime strings
   may not.
   ========================================================================== */
const T = {
  eyebrow: 'Billing & Usage',
  title: 'Cost per Token Trend',
  updated: 'Updated 25 Aug 2026, 03:00 UTC',
  source: 'Source: FinOps billing pipeline',
  demo: 'Sample data',
  export: 'Export CSV',
  custom: 'Custom range',

  heroLabel: 'Cost per 1K tokens',
  unit: 'per 1K tokens',
  heroHintBetter: 'Lower is better — unit cost fell versus the previous period.',
  heroHintWorse: 'Lower is better — unit cost rose versus the previous period.',
  heroHintFlat: 'Lower is better — unit cost is flat within the ±0.5% band.',
  deltaRef: (label, value) => 'vs ' + label + ' (' + value + ')',

  tileTotal: 'Attributed cost',
  tileTotalSub: 'Period total',
  tileTokens: 'Billable tokens',
  tileTokensSub: 'Period total',
  tileBest: (unit) => 'Best ' + unit,
  tileBestSub: (label) => 'Lowest unit cost — ' + label,
  tileTarget: 'Gap to target',
  tileTargetSub: (t) => 'FY target ' + t + ' per 1K tokens',

  chartTitle: 'Unit cost over time',
  chartSub: (g, r) => g + ' · ' + r,
  chartAria: (r, a, d) => 'Line chart of cost per 1000 tokens over ' + r + '. Period average ' + a + ', ' + d + ' versus the previous period.',

  legendCurrent: 'Current period',
  legendPrevious: 'Previous period',
  legendTarget: 'Target',

  granularity: (g) => 'Granularity: ' + g + ' · current day excluded (partial)',

  railLabel: 'How this is calculated',
  panelTitle: 'How this is calculated',

  secFormula: 'Formula',
  formula: 'Cost per 1K tokens\n  = Attributed cost ÷ (Billable tokens ÷ 1,000)',

  secInputs: 'This period’s inputs',
  inCost: 'Attributed cost',
  inTokens: 'Billable tokens',
  inResult: 'Cost per 1K tokens',

  secBreakdown: 'What sits in “Attributed cost”',
  bdCol1: 'Cost line',
  bdCol2: 'Amount',
  bdCol3: 'Share',

  secSignal: 'Trend signal (icon + value colour)',
  sigBetter: 'Down ≥ 0.5% — improving · value in brand green',
  sigWorse: 'Up ≥ 0.5% — worsening · value in orange',
  sigFlat: 'Within ±0.5% — flat · value in default text colour',

  secIncluded: 'Counted as billable tokens',
  included: [
    'Tokens consumed by completed, billable jobs.',
    'Tokens on jobs that failed after the billing checkpoint.',
    'Committed-use tokens drawn down in the period.'
  ],

  secExcluded: 'Excluded from both sides',
  excluded: [
    'Trial, internal QA and demo workspaces.',
    'Refunded or credited jobs (removed retroactively).',
    'One-off migration spend, sales & marketing, R&D headcount.'
  ],

  secFreshness: 'Data freshness',
  freshness: 'Cost lands from the billing pipeline once a day at 03:00 UTC. Token counts are near-real-time (≤15 min lag). The current day is partial and is excluded from the period average, so the last point on the chart is the last complete day.',

  secWhy: 'What moved the number',
  why: [
    'Transcode fleet moved to committed-use pricing in week 2.',
    'Egress mix shifted toward the lower-cost regional POPs.',
    'Storage tier clean-up removed cold objects from the hot tier.'
  ],

  panelFoot: 'Metric owner: FinOps · Definition v2.3 · Reviewed 12 Aug 2026',

  breakdownLines: [
    { name: 'Cloud compute & transcode', share: 0.5788 },
    { name: 'Egress & CDN',              share: 0.2500 },
    { name: 'Storage',                   share: 0.0943 },
    { name: 'Third-party licences',      share: 0.0769 }
  ]
};

/* FY unit-cost target, drawn as the chart's reference line. */
const TARGET = 0.400;

/* Direction threshold — domain-tvu M11 requires the rule to state (1) the icon
   threshold (2) the value-colour mapping (3) the token. All three are in
   T.sigBetter/Worse/Flat, rendered into the panel so the spec ships with the
   mockup rather than living only in a doc. */
const FLAT_BAND = 0.5; /* percent */

/* ==========================================================================
   SAMPLE DATA
   Every headline figure is DERIVED from these arrays at render time (mean,
   min, totals, breakdown), so the overview, the chart and the panel can never
   disagree with each other.
   ========================================================================== */
const RANGES = {
  '7d': {
    tabLabel: '7D',
    rangeLabel: 'Last 7 days',
    prevLabel: 'previous 7 days',
    granularity: 'Daily',
    unitWord: 'day',
    tokens: 430_400_000,
    labels: ['Aug 19','Aug 20','Aug 21','Aug 22','Aug 23','Aug 24','Aug 25'],
    current:  [0.421,0.418,0.433,0.441,0.426,0.430,0.427],
    previous: [0.415,0.422,0.417,0.409,0.424,0.420,0.426]
  },
  '28d': {
    tabLabel: '28D',
    rangeLabel: 'Last 28 days',
    prevLabel: 'previous 28 days',
    granularity: 'Daily',
    unitWord: 'day',
    tokens: 1_841_000_000,
    labels: ['Jul 29','Jul 30','Jul 31','Aug 01','Aug 02','Aug 03','Aug 04','Aug 05','Aug 06','Aug 07','Aug 08','Aug 09','Aug 10','Aug 11','Aug 12','Aug 13','Aug 14','Aug 15','Aug 16','Aug 17','Aug 18','Aug 19','Aug 20','Aug 21','Aug 22','Aug 23','Aug 24','Aug 25'],
    current:  [0.396,0.401,0.399,0.408,0.405,0.412,0.418,0.409,0.403,0.398,0.394,0.402,0.407,0.415,0.421,0.414,0.408,0.404,0.399,0.405,0.411,0.417,0.421,0.418,0.426,0.433,0.441,0.427],
    previous: [0.462,0.458,0.455,0.451,0.449,0.447,0.443,0.446,0.441,0.438,0.435,0.440,0.444,0.448,0.443,0.439,0.436,0.441,0.445,0.442,0.438,0.434,0.437,0.441,0.436,0.432,0.429,0.433]
  },
  '90d': {
    tabLabel: '90D',
    rangeLabel: 'Last 90 days',
    prevLabel: 'previous 90 days',
    granularity: 'Weekly average',
    unitWord: 'week',
    tokens: 5_592_000_000,
    labels: ['W22','W23','W24','W25','W26','W27','W28','W29','W30','W31','W32','W33','W34'],
    current:  [0.468,0.461,0.455,0.449,0.444,0.438,0.433,0.428,0.424,0.419,0.415,0.409,0.404],
    previous: [0.512,0.508,0.503,0.499,0.494,0.491,0.487,0.484,0.480,0.477,0.473,0.470,0.466]
  },
  '12m': {
    tabLabel: '12M',
    rangeLabel: 'Last 12 months',
    prevLabel: 'previous 12 months',
    granularity: 'Monthly average',
    unitWord: 'month',
    tokens: 21_364_000_000,
    labels: ['Sep','Oct','Nov','Dec','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug'],
    current:  [0.548,0.531,0.519,0.507,0.496,0.483,0.471,0.462,0.451,0.441,0.428,0.412],
    previous: [0.682,0.671,0.663,0.654,0.641,0.632,0.621,0.608,0.596,0.584,0.571,0.559]
  }
};

let activeRange = '28d';

/* ---------------------------- formatting ---------------------------- */
const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length;
const cpk = (n) => '$' + n.toFixed(3);
const pct = (n) => (n >= 0 ? '+' : '−') + Math.abs(n).toFixed(1) + '%';

function money(n) {
  if (n >= 1e6) return '$' + (n / 1e6).toFixed(2) + 'M';
  if (n >= 1e3) return '$' + (n / 1e3).toFixed(1) + 'K';
  return '$' + n.toFixed(0);
}
function tokens(n) {
  if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
  return String(n);
}
function direction(deltaPct) {
  if (deltaPct <= -FLAT_BAND) return 'better';
  if (deltaPct >= FLAT_BAND) return 'worse';
  return 'flat';
}

/* ---------------------------- derived model ---------------------------- */
function model(key) {
  const r = RANGES[key];
  const avg = mean(r.current);
  const prevAvg = mean(r.previous);
  const deltaPct = ((avg - prevAvg) / prevAvg) * 100;
  const totalCost = avg * (r.tokens / 1000);
  const minVal = Math.min(...r.current);
  const minLabel = r.labels[r.current.indexOf(minVal)];
  const targetGapPct = ((avg - TARGET) / TARGET) * 100;
  return { r, avg, prevAvg, deltaPct, totalCost, minVal, minLabel, targetGapPct };
}

/* ---------------------------- static copy ---------------------------- */
function paintStatic() {
  document.getElementById('t-eyebrow').textContent = T.eyebrow;
  document.getElementById('t-title').textContent = T.title;
  document.getElementById('t-updated').textContent = T.updated;
  document.getElementById('t-source').textContent = T.source;
  document.getElementById('t-demo').textContent = T.demo;
  document.getElementById('btn-export').textContent = T.export;
  document.querySelector('#btn-custom span').textContent = T.custom;

  document.getElementById('t-hero-label').textContent = T.heroLabel;
  document.getElementById('t-unit').textContent = T.unit;
  document.getElementById('t-tile1').textContent = T.tileTotal;
  document.getElementById('v-total-sub').textContent = T.tileTotalSub;
  document.getElementById('t-tile2').textContent = T.tileTokens;
  document.getElementById('v-tokens-sub').textContent = T.tileTokensSub;
  document.getElementById('t-tile4').textContent = T.tileTarget;
  document.getElementById('v-target-sub').textContent = T.tileTargetSub(cpk(TARGET));

  document.getElementById('t-chart-title').textContent = T.chartTitle;

  document.getElementById('t-rail-label').textContent = T.railLabel;
  document.getElementById('t-panel-title').textContent = T.panelTitle;
  document.getElementById('t-sec-formula').textContent = T.secFormula;
  document.getElementById('t-formula').textContent = T.formula;
  document.getElementById('t-sec-inputs').textContent = T.secInputs;
  document.getElementById('t-in-cost').textContent = T.inCost;
  document.getElementById('t-in-tokens').textContent = T.inTokens;
  document.getElementById('t-in-result').textContent = T.inResult;
  document.getElementById('t-sec-breakdown').textContent = T.secBreakdown;
  document.getElementById('t-bd-col1').textContent = T.bdCol1;
  document.getElementById('t-bd-col2').textContent = T.bdCol2;
  document.getElementById('t-bd-col3').textContent = T.bdCol3;
  document.getElementById('t-sec-signal').textContent = T.secSignal;
  document.getElementById('t-sig-better').textContent = T.sigBetter;
  document.getElementById('t-sig-worse').textContent = T.sigWorse;
  document.getElementById('t-sig-flat').textContent = T.sigFlat;
  document.getElementById('t-sec-included').textContent = T.secIncluded;
  document.getElementById('t-sec-excluded').textContent = T.secExcluded;
  document.getElementById('t-sec-freshness').textContent = T.secFreshness;
  document.getElementById('t-freshness').textContent = T.freshness;
  document.getElementById('t-sec-why').textContent = T.secWhy;
  document.getElementById('t-panel-foot').textContent = T.panelFoot;

  fillList('list-included', T.included);
  fillList('list-excluded', T.excluded);
  fillList('list-why', T.why);

  /* legend — mirrors buildChartOption: bottom, circle icon, 8x8, 12px --text-2 */
  const legend = document.getElementById('chart-legend');
  legend.innerHTML = '';
  legend.appendChild(legendItem(T.legendCurrent, 'var(--chart-color-1)', false));
  legend.appendChild(legendItem(T.legendPrevious, 'var(--chart-color-3)', true));
  legend.appendChild(legendItem(T.legendTarget, 'var(--text-tips)', true));

  /* range tabs — Tab List (fill=Filled) */
  const tabs = document.getElementById('range-tabs');
  tabs.innerHTML = '';
  Object.keys(RANGES).forEach((key) => {
    const b = document.createElement('button');
    b.className = 'tab-item';
    b.type = 'button';
    b.setAttribute('role', 'tab');
    b.dataset.range = key;
    b.textContent = RANGES[key].tabLabel;
    b.addEventListener('click', () => { activeRange = key; paintDynamic(); });
    tabs.appendChild(b);
  });
}

function fillList(id, items) {
  const ul = document.getElementById(id);
  ul.innerHTML = '';
  items.forEach((txt) => {
    const li = document.createElement('li');
    li.textContent = txt;
    ul.appendChild(li);
  });
}

function legendItem(label, color, dashed) {
  const el = document.createElement('span');
  el.className = 'chart-legend__item';
  const mark = document.createElement('span');
  mark.className = 'chart-legend__mark' + (dashed ? ' chart-legend__mark--dash' : '');
  mark.style.color = color;
  if (!dashed) mark.style.background = color;
  el.appendChild(mark);
  const txt = document.createElement('span');
  txt.textContent = label;
  el.appendChild(txt);
  return el;
}

/* ---------------------------- dynamic paint ---------------------------- */
function paintDynamic() {
  const m = model(activeRange);
  const dir = direction(m.deltaPct);

  document.querySelectorAll('.tab-item').forEach((t) => {
    t.setAttribute('aria-selected', String(t.dataset.range === activeRange));
  });

  document.getElementById('v-cpk').textContent = cpk(m.avg);

  /* M11 two-layer signal: swap the library icon AND recolour the value */
  const block = document.getElementById('delta-block');
  block.className = 'delta delta--' + dir;
  const iconEl = document.getElementById('delta-icon');
  iconEl.hidden = (dir === 'flat');
  document.getElementById('delta-icon-use')
    .setAttribute('href', dir === 'worse' ? '#i-arrow-up' : '#i-arrow-down');
  document.getElementById('v-delta').textContent = pct(m.deltaPct);
  document.getElementById('v-delta-ref').textContent = T.deltaRef(m.r.prevLabel, cpk(m.prevAvg));
  document.getElementById('t-hero-hint').textContent =
    dir === 'better' ? T.heroHintBetter : dir === 'worse' ? T.heroHintWorse : T.heroHintFlat;

  document.getElementById('v-total').textContent = money(m.totalCost);
  document.getElementById('v-tokens').textContent = tokens(m.r.tokens);

  document.getElementById('t-tile3').textContent = T.tileBest(m.r.unitWord);
  document.getElementById('v-best').textContent = cpk(m.minVal);
  document.getElementById('v-best-sub').textContent = T.tileBestSub(m.minLabel);

  const gapEl = document.getElementById('v-target');
  gapEl.textContent = pct(m.targetGapPct);
  gapEl.className = 'tile__value ' + (m.targetGapPct <= 0 ? 'is-better' : 'is-worse');

  document.getElementById('t-granularity').textContent = T.granularity(m.r.granularity);
  document.getElementById('t-chart-sub').textContent = T.chartSub(m.r.granularity, m.r.rangeLabel);

  /* panel figures track the active range so the explanation always explains
     the number actually on screen */
  document.getElementById('v-in-cost').textContent = money(m.totalCost);
  document.getElementById('v-in-tokens').textContent = tokens(m.r.tokens);
  document.getElementById('v-in-result').textContent = cpk(m.avg);

  const tbody = document.getElementById('breakdown-body');
  tbody.innerHTML = '';
  T.breakdownLines.forEach((line) => {
    const tr = document.createElement('tr');
    const td1 = document.createElement('td');
    const span = document.createElement('span');
    span.className = 'trunc';
    span.dataset.truncate = '';
    span.textContent = line.name;
    td1.appendChild(span);
    const td2 = document.createElement('td');
    td2.textContent = money(m.totalCost * line.share);
    const td3 = document.createElement('td');
    td3.textContent = (line.share * 100).toFixed(1) + '%';
    tr.append(td1, td2, td3);
    tbody.appendChild(tr);
  });
  applyTruncationTitles();

  renderChart();
}

/* R23 (b) truncation detection + (c) hover/a11y fallback.
   Only sets title when the text is actually clipped, so screen readers and
   tooltips are not spammed on short values. */
function applyTruncationTitles() {
  document.querySelectorAll('[data-truncate]').forEach((el) => {
    if (el.scrollWidth > el.clientWidth) el.title = el.textContent.trim();
    else el.removeAttribute('title');
  });
}

/* ==========================================================================
   CHART RENDERER
   Hand-written SVG that mirrors the visual contract of src/canonical/Chart.vue
   (type="line"). This is the R3 clause for "no build chain" consumption:
   props/visuals mirrored 1:1 rather than re-designed. In a real Vue/React
   surface use <Chart type="line" :datasets> — do NOT copy this renderer there.
   Mirrored values, all from build-chart-option.ts + use-chart-tokens.ts:
     axisLine / axisTick / splitLine -> --line-divider-light
     axisLabel / legend text         -> --text-2, 12px, --font-family-base
     series colour                   -> --chart-color-1 / --chart-color-3
     renderer                        -> SVG
   ========================================================================== */
const NICE_STEPS = [0.002, 0.005, 0.01, 0.02, 0.025, 0.05, 0.1, 0.2, 0.25, 0.5];
const PAD = { t: 16, r: 16, b: 36, l: 56 };
const SVG_NS = 'http://www.w3.org/2000/svg';

let plotState = null;

function niceStep(raw) {
  for (const s of NICE_STEPS) if (s >= raw) return s;
  return NICE_STEPS[NICE_STEPS.length - 1];
}

function el(name, attrs) {
  const node = document.createElementNS(SVG_NS, name);
  for (const k in attrs) node.setAttribute(k, attrs[k]);
  return node;
}

function renderChart() {
  const m = model(activeRange);
  const svg = document.getElementById('chart');
  const host = document.getElementById('chart-host');
  const w = Math.max(320, host.clientWidth - 32); /* minus .tvu-chart padding */
  const h = parseInt(getComputedStyle(document.querySelector('.page')).getPropertyValue('--chart-height'), 10) || 320;

  svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
  svg.setAttribute('width', w);
  svg.setAttribute('height', h);
  svg.setAttribute('aria-label', T.chartAria(m.r.rangeLabel, cpk(m.avg), pct(m.deltaPct)));
  while (svg.firstChild) svg.removeChild(svg.firstChild);

  const plotW = w - PAD.l - PAD.r;
  const plotH = h - PAD.t - PAD.b;

  const all = m.r.current.concat(m.r.previous, [TARGET]);
  const rawMin = Math.min(...all);
  const rawMax = Math.max(...all);
  const step = niceStep((rawMax - rawMin) / 4 || 0.01);
  const yMin = Math.floor(rawMin / step) * step;
  const yMax = Math.ceil(rawMax / step) * step;

  const x = (i) => PAD.l + (m.r.labels.length === 1 ? plotW / 2 : (i / (m.r.labels.length - 1)) * plotW);
  const y = (v) => PAD.t + plotH - ((v - yMin) / (yMax - yMin)) * plotH;

  /* --- split lines + y labels --- */
  for (let v = yMin; v <= yMax + 1e-9; v += step) {
    const yy = y(v);
    svg.appendChild(el('line', {
      x1: PAD.l, x2: PAD.l + plotW, y1: yy, y2: yy,
      stroke: 'var(--line-divider-light)', 'stroke-width': 1
    }));
    const lbl = el('text', {
      x: PAD.l - 8, y: yy + 4, 'text-anchor': 'end',
      fill: 'var(--text-2)', 'font-size': 12, 'font-family': 'var(--font-family-base)'
    });
    lbl.textContent = '$' + v.toFixed(3);
    svg.appendChild(lbl);
  }

  /* --- axis lines --- */
  svg.appendChild(el('line', {
    x1: PAD.l, x2: PAD.l, y1: PAD.t, y2: PAD.t + plotH,
    stroke: 'var(--line-divider-light)', 'stroke-width': 1
  }));
  svg.appendChild(el('line', {
    x1: PAD.l, x2: PAD.l + plotW, y1: PAD.t + plotH, y2: PAD.t + plotH,
    stroke: 'var(--line-divider-light)', 'stroke-width': 1
  }));

  /* --- x ticks + labels (sub-sampled so long ranges never collide) --- */
  const stride = Math.max(1, Math.ceil(m.r.labels.length / 8));
  m.r.labels.forEach((label, i) => {
    const show = (i % stride === 0) || i === m.r.labels.length - 1;
    svg.appendChild(el('line', {
      x1: x(i), x2: x(i), y1: PAD.t + plotH, y2: PAD.t + plotH + 5,
      stroke: 'var(--line-divider-light)', 'stroke-width': 1
    }));
    if (!show) return;
    const t = el('text', {
      x: x(i), y: PAD.t + plotH + 20, 'text-anchor': 'middle',
      fill: 'var(--text-2)', 'font-size': 12, 'font-family': 'var(--font-family-base)'
    });
    t.textContent = label;
    svg.appendChild(t);
  });

  /* --- target reference line --- */
  svg.appendChild(el('line', {
    x1: PAD.l, x2: PAD.l + plotW, y1: y(TARGET), y2: y(TARGET),
    stroke: 'var(--text-tips)', 'stroke-width': 1, 'stroke-dasharray': '2 4'
  }));

  const pts = (arr) => arr.map((v, i) => x(i) + ',' + y(v)).join(' ');

  /* --- previous period (dashed, --chart-color-3) --- */
  svg.appendChild(el('polyline', {
    points: pts(m.r.previous), fill: 'none',
    stroke: 'var(--chart-color-3)', 'stroke-width': 2,
    'stroke-dasharray': '5 4', 'stroke-linejoin': 'round', 'stroke-linecap': 'round'
  }));

  /* --- current period (solid, --chart-color-1) --- */
  svg.appendChild(el('polyline', {
    points: pts(m.r.current), fill: 'none',
    stroke: 'var(--chart-color-1)', 'stroke-width': 2,
    'stroke-linejoin': 'round', 'stroke-linecap': 'round'
  }));

  /* --- emptyCircle symbols, only when points are sparse enough to read
         (matches ECharts' showAllSymbol:'auto' behaviour) --- */
  if (m.r.labels.length <= 14) {
    m.r.current.forEach((v, i) => {
      svg.appendChild(el('circle', {
        cx: x(i), cy: y(v), r: 3,
        fill: 'var(--bg-layer3)', stroke: 'var(--chart-color-1)', 'stroke-width': 2
      }));
    });
  }

  /* --- hover layer (crosshair + highlighted points) --- */
  const cross = el('line', {
    x1: 0, x2: 0, y1: PAD.t, y2: PAD.t + plotH,
    stroke: 'var(--line-border)', 'stroke-width': 1, opacity: 0
  });
  svg.appendChild(cross);
  const dotCur = el('circle', { r: 4, fill: 'var(--chart-color-1)', stroke: 'var(--bg-layer3)', 'stroke-width': 2, opacity: 0 });
  const dotPrev = el('circle', { r: 4, fill: 'var(--chart-color-3)', stroke: 'var(--bg-layer3)', 'stroke-width': 2, opacity: 0 });
  svg.appendChild(dotPrev);
  svg.appendChild(dotCur);

  plotState = { m, x, y, cross, dotCur, dotPrev, plotW, plotH, w };
}

/* ---------------------------- hover interaction ---------------------------- */
function onChartMove(evt) {
  if (!plotState) return;
  const { m, x, y, cross, dotCur, dotPrev } = plotState;
  const svg = document.getElementById('chart');
  const rect = svg.getBoundingClientRect();
  const scale = rect.width / svg.viewBox.baseVal.width;
  const localX = (evt.clientX - rect.left) / scale;

  let best = 0;
  let bestD = Infinity;
  for (let i = 0; i < m.r.labels.length; i++) {
    const d = Math.abs(x(i) - localX);
    if (d < bestD) { bestD = d; best = i; }
  }

  const cx = x(best);
  cross.setAttribute('x1', cx);
  cross.setAttribute('x2', cx);
  cross.setAttribute('opacity', 1);
  dotCur.setAttribute('cx', cx);
  dotCur.setAttribute('cy', y(m.r.current[best]));
  dotCur.setAttribute('opacity', 1);
  dotPrev.setAttribute('cx', cx);
  dotPrev.setAttribute('cy', y(m.r.previous[best]));
  dotPrev.setAttribute('opacity', 1);

  const cur = m.r.current[best];
  const prev = m.r.previous[best];
  const dPct = ((cur - prev) / prev) * 100;
  const dir = direction(dPct);

  const tip = document.getElementById('chart-tip');
  tip.innerHTML = '';
  const title = document.createElement('div');
  title.className = 'chart-tip__title';
  title.textContent = m.r.labels[best];
  tip.appendChild(title);
  tip.appendChild(tipRow('var(--chart-color-1)', T.legendCurrent, cpk(cur), ''));
  tip.appendChild(tipRow('var(--chart-color-3)', T.legendPrevious, cpk(prev), ''));
  tip.appendChild(tipRow(null, 'Δ', pct(dPct), dir));
  tip.hidden = false;

  /* place above the current-series point, clamped inside the plot */
  const tipX = Math.min(Math.max(cx * scale, 60), rect.width - 60);
  tip.style.left = (tipX + 16) + 'px';
  tip.style.top = (y(cur) * scale + 16 - 12) + 'px';
}

function tipRow(color, label, value, dirClass) {
  const row = document.createElement('div');
  row.className = 'chart-tip__row';
  if (color) {
    const mark = document.createElement('span');
    mark.className = 'chart-tip__mark';
    mark.style.background = color;
    row.appendChild(mark);
  } else {
    const spacer = document.createElement('span');
    spacer.style.width = '8px';
    row.appendChild(spacer);
  }
  const l = document.createElement('span');
  l.className = 'chart-tip__label';
  l.textContent = label;
  row.appendChild(l);
  const v = document.createElement('span');
  v.className = 'chart-tip__num';
  if (dirClass === 'better') v.style.color = 'var(--brand)';
  if (dirClass === 'worse') v.style.color = 'var(--orange)';
  v.textContent = value;
  row.appendChild(v);
  return row;
}

function onChartLeave() {
  if (!plotState) return;
  plotState.cross.setAttribute('opacity', 0);
  plotState.dotCur.setAttribute('opacity', 0);
  plotState.dotPrev.setAttribute('opacity', 0);
  document.getElementById('chart-tip').hidden = true;
}

/* ---------------------------- panel collapse ---------------------------- */
function setPanel(collapsed) {
  document.getElementById('page-body').dataset.panel = collapsed ? 'collapsed' : 'expanded';
  document.getElementById('panel-content').hidden = collapsed;
  document.getElementById('panel-rail').hidden = !collapsed;
  document.getElementById('btn-collapse').setAttribute('aria-expanded', String(!collapsed));
  document.getElementById('btn-expand').setAttribute('aria-expanded', String(!collapsed));
  /* the plot width changes with the grid track — re-render, don't stretch */
  requestAnimationFrame(() => { renderChart(); applyTruncationTitles(); });
}

/* ---------------------------- boot ---------------------------- */
paintStatic();
paintDynamic();
setPanel(false);

document.getElementById('btn-collapse').addEventListener('click', () => setPanel(true));
document.getElementById('btn-expand').addEventListener('click', () => setPanel(false));
document.getElementById('chart').addEventListener('mousemove', onChartMove);
document.getElementById('chart').addEventListener('mouseleave', onChartLeave);

let resizeTimer = null;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => { renderChart(); applyTruncationTitles(); }, 120);
});
</script>

</body>
</html>
```

---

## 交付自审（R3 §Acceptance criteria 三问）

- **自画 SVG icon 数量？** 0。8 个图标全部是 `dist/icons/svg/**` 的 path 逐字复制，sprite 注释里逐个标了 nodeId。零 Unicode / emoji 占位（R13 Step 4 黑名单）。
- **用了多少 hex literal？** token 块内的值是 `variables.css` 的 verbatim 副本（这是它的定义处）；token 块之外**只有一处** —— `pay/token` 图标自带的 `#FFB524`，是库资产本身的填充，不是我选的颜色，改成 `currentColor` 反而是篡改库资产。其余全走 `var(--*)`。
- **复合组件 mirror 自哪个 canonical？** Button ← `Button.vue` 的 `SIZE_GEOMETRY.M` + `PALETTE_SPEC.ghost["gray 1"]`；Tab ← `TabList.vue`/`TabItem.vue` 的 filled 分支；Tooltip ← `Tooltip.vue` `.tooltip-box--dark`；Chart ← `Chart.vue` + `build-chart-option.ts` + `use-chart-tokens.ts`。每处 CSS 上方都写了它 mirror 的是哪几行。

**落地前还欠的两步**（我做不了，如实登记，不是待办清单里的装饰）：
1. **M17 宿主范式实测** —— 套进真实 Dashboard 外壳前，要打开该 surface 的 canonical 帧核行高/控件宽/右缘对齐，贴实测数字，目测不算过。
2. **`pnpm audit:no-hardcoded-design-tokens` / `audit:consumer-code`（R14/R19/R1 probe）实跑** —— 本 session 停用了 Bash，我**没有跑过任何闸**。上面所有合规声明都是逐条对活源比对的结果，不是机检绿。真要收货请在能跑命令的 session 里补跑这两条。

如果 token = LLM token 而不是 TVU 计费 token，需要改的只有说明面板里那三段定义文案和 `RANGES` 的量级，页面结构和组件映射不动。