我已走完 onboarding 链路（L-core 4 份 + L-ref 命中项：AGENTS §硬规则/§Onboarding Gate、`code-conventions.md` 起手必读段 + R2/R3/R13/R14/R15/R16/R19/R23/R-DISCIPLINE、`domain-tvu.md` 全 12 条、`design-process.md` M49/M-CARDINALITY/M-IA-FIRST、`figma-component-catalog.md`、`variables.css`、以及 canonical `Button/Table/TopBar/Notification/PopupBox/Badge/PillStatus/Pill/TabItem/MenuList/Input/Tooltip` 与 `src/icons/catalog/generated/*` 活源）。

**先说三条约束对交付的影响（诚实边界）**：
- 本 session 停了 Write/Bash ⇒ **R-DISCIPLINE 要求的 TodoWrite 物化、M49 的 `pnpm design:kickoff --check`、`audit:no-hardcoded-design-tokens` / `audit:consumer-code` 都没跑**。下面的 checklist 与 kickoff packet 以正文形式代替，不是"已机检通过"。
- Pre-Phase 0（US-2 单页）的两个 user gate 被压成"声明假设"——harness 是一次性交付，没有回合可用来对齐。
- 这是 **Path B（写 UI 代码）**，不是 Figma 写操作，所以走 `code-conventions.md`，Mockup Write Gate 的 Figma 四步前置不触发。

---

## 三条会被通用 AI 做反的 TVU 硬约束（先摆出来）

| 约束 | 通用做法 | 本稿按真源做法 |
|---|---|---|
| **M3** 广播行业 ON AIR 惯例 | 正常=绿、告警=黄、异常=红 | **正常推流 = 红**（`--red`，Live=红是广播工程师肌肉记忆）；绿保留给 Preview/Standby；离线=灰 |
| **M7** control surface 视觉权重 | status pill 用饱和实心大色块 | pill 用 `Tag=Line` 低饱和；**actions 才是最显眼的那层**，一律正规尺寸 M 按钮不缩 chip |
| **MenuList horizontal**（Figma 真源，owner 2026-08-14 裁定） | 顶部导航 active 加品牌色下划线 | **没有下划线**，active 与 normal 只差文字色（`--text-body` / `--text-tips`）；那条 brand 下划线是被推翻的 code-first 发明 |

另外两条：**M4** 运营台只交付 dark（不做浅色/不做并排对比）；**catalog `Top bar` §Don't use for** 禁止在 topbar 下方再加重复 page-title strip ⇒ 本页**没有 h1 标题条**，页面身份由导航 active 项承担。

## IA + Cardinality self-check（M-IA-FIRST / M-CARDINALITY）

```
Source (persistent, page-scope list)
 ├ identity      : name + protocol prefix        (1:1)
 ├ ingest URL    : 后端下发、长度不可控           (1:1)  → R23 三件套
 ├ receiver      : 目标接收端                     (N:1  多个源可指同一 receiver)
 └ runtime state : On air | Degraded | Offline    (1:1)
       └ Offline 必分两子型 (M10): Idle(有 history) / NeverStarted(未配置)
Selection model : 无行选中态（M5 = readonly 监控+控制台）⇒ R19 无触发点，
                  actions 常显（M7 要求）而非 selected-driven chrome
```

5 问 5 答：① persistent（row）② 4 态含 Offline 两子型，转换 = Start/Stop/降级/恢复 ③ 有，Offline-Idle 行内 `Last ran HH:MM → HH:MM · N min` ④ Source:Receiver = N:1（第 3、4 行同指 LAX-Receiver-03，一主一备）⑤ 无 parent switch。

## R0 Phase 0 element → component mapping

| Element | Figma component_set | Code 真源 | 状态 |
|---|---|---|---|
| 顶部栏 | `Top bar` (`918b928e…`, Tag=After Login, Menu SLOT) | `src/components/TopBar/TopBar.vue` | ✅ 1:1 mirror |
| 页面级导航 | Menu sub-component in `Top bar` (`4771:7173/7142/7140`) | `MenuList.vue` `--horizontal` | ✅ 无下划线 |
| 过滤 chip | `Tab/Item` (`2267405386…`) + `Tab List` (`47efe04f…`) | `TabItem.vue` `--filled` | ✅ catalog FilterChip |
| 搜索框 | `Input box/filled` | `Input.vue` `--filled --m` | ✅（leading icon 为叠加层） |
| 列表 | `Table` (Type=Tbody, Align=Left, striped) | `components/Table/Table.vue` | ✅ 含 F129① 的 `.tbl td{border-top:0}` 中和 |
| 状态指示 | `Badge` (`4db5246d…`, Color=Red/Orange/Neutral × Tag=Line × Type=Rectangle) | `canonical/Badge.vue` | ✅ 三态全落在已注册轴值内 |
| 行内操作 | `Button/dark M` (`12b38c8b…`, style=ghost, color=red/green/gray 1) | `components/Button/Button.vue` | ✅ PALETTE_SPEC 逐值 mirror |
| 二次确认 | `Notification` (`23535120…`, form=**pop confirm**, type=danger) | `components/Notification/Notification.vue` | ✅ **不是 PopupBox**（catalog：纯确认用 Popup Box = 选错） |
| 外部事件告知 | `Notification` form=alert type=warning / form=slide type=success | 同上 | ✅ M12 三段式 |
| 溢出菜单 | `MenuList` `--vertical` | `MenuList.vue` | 🟡 无自有容器 ⇒ 面板 chrome 用 PopupBox 表面配方组合 |
| 截断提示 | `Tooltip` (theme=dark) | `Tooltip.vue` | ✅ R23 |
| 17 个图标 | `icon/*` | `src/icons/catalog/generated/*.ts` 逐字取 | ✅ 零自画 |

`PillStatus`（published `Pill Status` 5672:140617）我**评估后没用**：它的 Status 轴只有 On-air/Preview/Analyzing/Inactive，**没有 warning 成员**，而 硬规则 #3 禁止自创 variant；`Badge` 的 Color 轴本来就注册了 Orange，三态用一个组件一个 `Tag` 就能表达 ⇒ 同时满足 M49 §2「同 semanticRole 同视觉词汇」。

## R13 affordance trace

```
1. Affordance: status-negative (intent: 'stop the live transmission')
2. Vocabulary scan: [video/record-stop, video/camera-stop, video/rvf-stop, message/error-1..4, indicator/cannot-live]
3. Chosen: video/record-stop — R13 §Step2 矩阵为 status-negative 点名的命中项，几何=实心方块停止符

1. Affordance: status-warning (intent: 'source degraded / alert banner')
2. Vocabulary scan: [message/warning-1, message/warning-2, time/warning, indicator/cannot-live]
3. Chosen: message/warning-1 (行内 16px 描边) + message/warning-2 (alert 24px 实心) —
   后者逐字取自 Notification.vue iconMap.warning，不自选

1. Affordance: directional-vertical / trend (intent: 'latency converging vs diverging' — M11 双层信号)
2. Vocabulary scan: [arrow/up, arrow/down, arrow/updown, arrow/sorting, arrow/double-up, arrow/double-down, arrow/dropdown]
3. Chosen: arrow/down + arrow/up — 真箭头而非 chevron；数值同时染色（M11 要求 icon 不足以单独成信号）

1. Affordance: dismissive (intent: 'close confirm dialog / toast')
2. Vocabulary scan: [edit/close, edit/delete, edit/clip]
3. Chosen: edit/close — canonical 注释逐字指定 popup/notification 的 × 就是 icon/Edit/Close

1. Affordance: overflow / link-share / search / refresh / additive-none
2. Vocabulary scan: [menu/more, menu/menu-1, others/*] · [share/open-link, share/link, edit/copy-link, picture/picture-link]
   · [search/search, search/auto-search, search/zoom-in] · [load/refresh, load/loading-1, load/wait]
3. Chosen: menu/more · edit/copy · search/search · load/refresh —— 其余为几何不匹配
   （另：input/primary-source · input/receiver · preview/main-window · video/play · logo/tvu ·
     message/notification-1 · message/success-2 同法取自 registry，全库实证，零 Unicode / 零 emoji / 零自画）
```

## M49 lite Design Quality Contract（节选）

- **Baseline / Delta**：Source version = 无既有基线（US-2 greenfield）。Remove = `None` ⇒ 不删不折叠任何 baseline 内容。
- **Semantic Pattern**：`status indicator` → 唯一模式 `Badge Rectangle/Line`，只让 Color 轴承载语义；`destructive action` → 唯一模式 `Button ghost/red + M + literal verb + pop-confirm 兜底`；`empty value` → 唯一模式 M29 Form 2 `—` + `--text-placeholder` + 不挂 copy/action。
- **Feedback UI Inventory**：`degraded-detected` → inline alert / `Notification form=alert type=warning` / 480 / one-action；`stop-confirm` → modal notification / `form=pop confirm type=danger` / 480 / two-action；`stop-succeeded` + `url-copied` → toast / `form=slide type=success` / 320 / no-action。三类各自同源，action 数落在 no/one/two 内。
- **UX**：主路径 = 扫状态列 → 落在行尾 actions（M7 权重顺序）；空态与 NeverStarted 子型都给可继续操作的 CTA。

---

## 实现里三处我明确偏离了某条文档的地方（都给了判据）

1. **`--text-warning` 不存在**。M11 引用 `Color/Text/Warning (--text-warning)`，但 `variables.css` 的 Text 段实测没有这个 token（只有 `--orange` 与 code-authored 的 `--prompt-warning-text`，同值 `#f68512`）。我用 **`--orange`**（Figma-synced、受 `audit:token-collateral` 管的那条），不新建 token、不写 hex。**这是一条 doc↔token drift，值得单独立 entry**。
2. **canonical Table 的 hover 在偶数行不可见**：`.tbl-body .tbl-row:hover` 与 `.tbl--striped … :nth-child(even)` 都是 `--bg-layer3`。我**照 canonical 原样 mirror（R3 禁止按自己理解重设计）**，把它作为一条实测发现报出来，而不是就地"改好"。
3. **URL 列同时给 hug+copy 与 ellipsis+tooltip**：R23 反例表说「ID/IP 字段强行 ellipsis 不给 copy」不合格，catalog 又逐字点名「URL 列走 M29 Form 1 = hug + copy」。SRT ingest URL 长度不可控 ⇒ 两个都给（copy 图标常在、真截断时才挂 tooltip+`title`+`aria-describedby`），从而不落进那条反例。

产品 UI 文案按 **R16/M33 走英文单语 + `const T` 映射表**（双语只作用于注释层）——这是刻意的，不是没读中文 brief。

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TVU MediaHub — Sources</title>
<style>
/* ==========================================================================
   1 · DESIGN TOKENS
   Values copied VERBATIM from tvu-design-system/src/tokens/variables.css
   (dark `:root` blocks). R2 / AGENTS 硬规则 #4: no colour is authored here —
   every rule below references var(--*) only.
   M4 (domain-tvu): operator-console surfaces ship DARK ONLY, so the
   [data-theme="light"] override block is deliberately NOT carried over.
   ========================================================================== */
: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;
  --red-bg: #fff1f0;
  --orange: #f68512;
  --orange-hover: #f89c3e;
  --orange-bg: #fff3db;
  --blue: #3892f3;
  --blue-hover: #62a9f6;
  --blue-bg: #e1eefd;

  /* Message soft backgrounds */
  --msg-bg-red: #3b1c1a;
  --msg-bg-brand: #19311e;
  --msg-bg-blue: #1a2b3c;
  --msg-bg-grey: #353535;

  /* 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;
  --icon-placeholder: #7b7b7b;

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

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

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

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

  /* Z-index / elevation */
  --z-dropdown: 1000;
  --z-tooltip: 1000;
  --z-modal: 1000;
  --z-select-portal: 1100;

  /* Layout breakpoints / containers */
  --bp-sm: 640px;
  --bp-md: 960px;
  --bp-lg: 1100px;
  --bp-xl: 1280px;
  --container-page: 1600px;
  --container-wide: 1800px;

  /* Typography.
     NOTE: --font-family-clock ('Digital Numbers', OFL, self-hosted) is REQUIRED by
     variables.css to be reserved for the Top bar timezone clock readout only. The
     font file cannot be embedded in a single self-contained page, so this mockup
     omits the clock entirely rather than shipping a wrong-looking fallback. */
  --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);

  /* Component-variant tokens (theme-stable) */
  --notification-bg-dark: var(--color-grey-10);
  --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;
  --notification-width-default: 480px;
  --notification-width-side-pop: 320px;
  --badge-circle-size: 24px;
  --topbar-section-gap: 80px;
  --topbar-height: 64px;
  --control-height-xxs: 32px;

  /* Effect Style tokens (Figma 3-layered drop shadows) */
  --shadow-l1:
    0 5px 12px 4px #00000017,
    0 3px 6px 0 #0000001f,
    0 1px 2px -2px #00000029;
  --shadow-l2:
    0 9px 28px 8px #0000000d,
    0 6px 16px 0 #00000014,
    0 3px 6px -4px #0000001f;
  --shadow-l3:
    0 12px 48px 16px #00000008,
    0 9px 28px 0 #0000000d,
    0 6px 16px -8px #00000014;
  --mask-overlay: rgba(0, 0, 0, 0.6);
  --mask-overlay-blur: 8px;

  /* Text Style tokens (weight/size/line-height from Figma; family = DS system stack,
     registered divergence font-family-system-stack-not-roboto-2026-08-13) */
  --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);
  --font-size-tips: 12px;
  --line-height-tips: 16px;

  /* Derived aliases */
  --input-filled-bg: var(--bg-layer4);
  --green-bg: var(--brand-match);
  --input-line-border: var(--color-grey-8);
}

/* ==========================================================================
   2 · ELEMENT BASE LAYER — mirrors the tail of variables.css
   ========================================================================== */
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }

body {
  margin: 0;
  font: var(--text-style-body);
  font-family: var(--font-family-base);
  color: var(--text-body);
  background: var(--bg-layer1);
}
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; }
button { font: var(--text-style-body); color: inherit; border-radius: var(--r-s); }
input { font: var(--text-style-body); color: var(--text-body); }
table {
  font: var(--text-style-body);
  color: var(--text-body);
  border-collapse: collapse;
}
th { font-weight: 600; color: var(--text-body); text-align: left; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* Every inlined icon is a verbatim <svg> from src/icons/catalog/generated/*.ts.
   R1/R13/R15: zero hand-drawn paths, zero Unicode glyph icons, zero emoji. */
.ico { display: inline-flex; flex: 0 0 auto; width: 16px; height: 16px; }
.ico svg { display: block; width: 100%; height: 100%; }
.ico--12 { width: 12px; height: 12px; }
.ico--24 { width: 24px; height: 24px; }
.ico--28 { width: 28px; height: 28px; }

/* ==========================================================================
   3 · Top bar — 1:1 mirror of src/components/TopBar/TopBar.vue (Tag=After Login)
   ========================================================================== */
.topbar {
  display: flex;
  align-items: center;
  padding: var(--sp-xs) var(--sp-m);
  background: var(--bg-topbar);
  border: 1px solid var(--line-deep);
  gap: var(--topbar-section-gap);
  flex-shrink: 0;
  width: 100%;
  height: var(--topbar-height);
}
.topbar-brand { display: flex; align-items: center; gap: var(--sp-m); flex-shrink: 0; }
.topbar-title { font: var(--text-style-pop-title); color: var(--text-heading); white-space: nowrap; }
.topbar-menu { display: flex; align-items: center; gap: var(--sp-xxl); flex: 1; }
.topbar-right { display: flex; align-items: center; gap: var(--sp-xs); flex-shrink: 0; margin-left: auto; }

/* MenuList --horizontal — mirror of src/components/MenuList/MenuList.vue.
   Truth = the Menu sub-component INSIDE the published `Top bar` (4771:7173).
   active (4771:7142) vs normal (4771:7140) differ ONLY in text colour; every
   instance under the Menu slot has strokes:[].
   ⛔ There is NO underline in the design. A 2px --brand bottom border was a
   code-first invention shipped 2026-07-02 and REVERTED by owner ruling
   2026-08-14 ("以 Figma 为准"). Do not re-add it. */
.menu-list { list-style: none; margin: 0; padding: 0; display: flex; }
.menu-list--horizontal { flex-direction: row; align-items: center; gap: var(--sp-m); }
.menu-list--horizontal .menu-list__item {
  display: flex;
  align-items: center;
  gap: var(--sp-xxs);
  padding: 0 var(--sp-xs);
  font: var(--text-style-headline-2);
  color: var(--text-tips);
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
  background: none;
  border: 0;
}
.menu-list--horizontal .menu-list__item:hover { color: var(--text-heading); }
.menu-list--horizontal .menu-list__item.is-active { color: var(--text-body); }
.menu-list__item:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }

/* MenuList --vertical (row overflow panel) — mirror of MenuList.vue vertical block. */
.menu-list--vertical { flex-direction: column; gap: var(--sp-xxs); min-width: 180px; }
.menu-list--vertical .menu-list__item {
  display: flex; align-items: center; gap: var(--sp-xs);
  padding: var(--sp-xs) var(--sp-s);
  border-radius: var(--r-s);
  font: var(--text-style-body);
  color: var(--text-2);
  cursor: pointer; user-select: none;
  background: none; border: 0; width: 100%; text-align: left;
}
.menu-list--vertical .menu-list__item:hover { background: var(--bg-layer3); color: var(--text-heading); }
/* MenuList ships without a container of its own (divergence
   logo-menulist-usermenu-code-first-2026-07-02). Panel chrome composed from the
   PopupBox surface recipe — token-only, no new values. */
.menu-panel {
  /* overlay anchored to its trigger button — R14 sanctioned absolute use */
  position: absolute;
  top: calc(100% + var(--sp-xxs));
  right: 0;
  z-index: var(--z-dropdown);
  padding: var(--sp-xxs);
  background: var(--bg-layer2);
  border: 1px solid var(--line-border);
  border-radius: var(--r-s);
  box-shadow: var(--shadow-l1);
}

/* Bare icon button — the sanctioned "not a Button instance" pattern used by
   PopupBox/Notification close × (Figma 1155:24553 / 1392:1817). */
.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--control-height-xxs); height: var(--control-height-xxs);
  padding: 0; border: 0; border-radius: var(--r-s);
  background: transparent; color: var(--icon-default); cursor: pointer;
}
.icon-btn:hover { color: var(--icon-active); background: var(--bg-grey-btn-en); }
.icon-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.icon-btn--bare:hover { background: transparent; }

/* Badge — mirror of src/canonical/Badge.vue (4db5246d…, Color × Tag × Type). */
.badge {
  display: inline-flex; align-items: center; justify-content: center;
  height: var(--badge-circle-size);
  padding-block: var(--sp-xxs);
  padding-inline: var(--sp-s);
  border: 1px solid var(--badge-border);
  border-radius: var(--r-xl);
  background: var(--badge-bg);
  color: var(--badge-text);
  font: var(--text-style-tips);
  white-space: nowrap;
}
.badge--circle {
  width: var(--badge-circle-size); min-width: var(--badge-circle-size);
  padding-inline: 0;
}
.badge--line-red     { --badge-bg: transparent; --badge-border: var(--red);       --badge-text: var(--red); }
.badge--line-orange  { --badge-bg: transparent; --badge-border: var(--orange);    --badge-text: var(--orange); }
.badge--line-neutral { --badge-bg: transparent; --badge-border: var(--line-deep); --badge-text: var(--text-body); }
.badge--filled-red   { --badge-bg: var(--red);  --badge-border: transparent;      --badge-text: var(--text-primary-btn); }

.topbar-bell { position: relative; display: inline-flex; }
/* count badge anchored to the bell glyph — R14 sanctioned overlay */
.topbar-bell .badge {
  position: absolute; top: -6px; right: -8px;
  pointer-events: none;
}

/* ==========================================================================
   4 · Button — 1:1 mirror of src/components/Button/Button.vue
   Geometry: SIZE_GEOMETRY.M = h32 / 14px / 21px / radius square 4 / pad 16 / gap 4
   Colours : PALETTE_SPEC (Figma-derived), ghost border-width 1.2px
   ========================================================================== */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--btn-gap, 0px);
  min-width: 0;
  padding-inline: var(--btn-padding-inline, 16px);
  height: var(--btn-height, 32px);
  border: var(--btn-border-width, 1px) solid var(--btn-border, transparent);
  border-radius: var(--r-s);
  background: var(--btn-bg, transparent);
  color: var(--btn-color, var(--text-2));
  opacity: var(--btn-opacity, 1);
  font-family: var(--font-family-base);
  font-size: 14px; font-weight: 400; line-height: 21px;
  cursor: pointer;
  transition: background .15s, color .15s, border-color .15s, opacity .15s;
  white-space: nowrap; outline: none; user-select: none;
}
.btn--with-icon { --btn-gap: var(--sp-xxs); }
.btn:hover:not(:disabled) {
  background: var(--btn-bg-hover); color: var(--btn-color-hover); border-color: var(--btn-border-hover);
}
.btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.btn:disabled { cursor: not-allowed; }

/* ghost / gray 1 */
.btn--ghost-gray {
  --btn-border-width: 1.2px;
  --btn-bg: transparent;      --btn-color: var(--text-2);  --btn-border: var(--bg-grey-btn-en);
  --btn-bg-hover: var(--bg-grey-btn-en); --btn-color-hover: var(--text-2); --btn-border-hover: var(--bg-grey-btn-en);
}
.btn--ghost-gray:disabled { --btn-color: var(--text-placeholder); }
/* ghost / red — hover bg --red-bg is #fff1f0 in BOTH themes per Figma; mirrored as-is */
.btn--ghost-red {
  --btn-border-width: 1.2px;
  --btn-bg: transparent;      --btn-color: var(--red);     --btn-border: var(--red);
  --btn-bg-hover: var(--red-bg); --btn-color-hover: var(--red); --btn-border-hover: var(--red);
}
.btn--ghost-red:disabled { --btn-opacity: 0.4; }
/* ghost / green */
.btn--ghost-green {
  --btn-border-width: 1.2px;
  --btn-bg: transparent;      --btn-color: var(--brand);   --btn-border: var(--brand);
  --btn-bg-hover: var(--green-bg); --btn-color-hover: var(--brand); --btn-border-hover: var(--brand);
}
.btn--ghost-green:disabled { --btn-color: var(--brand-disable); --btn-border: var(--brand-disable); }
/* filling / red — destructive confirm */
.btn--fill-red {
  --btn-bg: var(--red);       --btn-color: var(--text-primary-btn); --btn-border: transparent;
  --btn-bg-hover: var(--red-hover); --btn-color-hover: var(--text-primary-btn); --btn-border-hover: transparent;
}
/* filling / green */
.btn--fill-green {
  --btn-bg: var(--brand);     --btn-color: var(--text-primary-btn); --btn-border: transparent;
  --btn-bg-hover: var(--brand-hover); --btn-color-hover: var(--text-primary-btn); --btn-border-hover: transparent;
}
/* rimless / gray 1 */
.btn--text-gray {
  --btn-bg: transparent;      --btn-color: var(--text-2);  --btn-border: transparent;
  --btn-bg-hover: var(--bg-grey-btn-en); --btn-color-hover: var(--text-2); --btn-border-hover: transparent;
}

/* ==========================================================================
   5 · Tab/Item --filled  (catalog: Tab/Item as FilterChip — Active+Green+Filled
   selected, Normal+White+Filled default). Mirror of TabItem.vue.
   ========================================================================== */
.tab-list { display: flex; align-items: center; gap: var(--sp-xs); }
.tab-item {
  display: inline-flex; align-items: center; gap: var(--sp-xs);
  height: 32px; padding: 0 var(--sp-m);
  font: var(--text-style-body); color: var(--text-tips);
  cursor: pointer; white-space: nowrap; user-select: none;
  transition: color .15s, background .15s;
  border: 0; border-radius: var(--r-s);
}
.tab-item--filled.tab-item--normal { background: var(--bg-layer3); color: var(--text-2); }
.tab-item--filled.tab-item--active { background: var(--brand); color: var(--text-primary-btn); font-weight: 700; }
.tab-item:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.tab-item__count { font-variant-numeric: tabular-nums; opacity: .85; }

/* ==========================================================================
   6 · Input box/filled — mirror of src/components/Input/Input.vue (--filled --m)
   ========================================================================== */
.field { position: relative; display: block; width: 280px; }
.tvu-input {
  display: block; width: 100%;
  padding: var(--sp-xs) var(--sp-s);
  height: 32px;
  background: var(--input-filled-bg);
  border: 1px solid transparent;
  border-radius: var(--r-s);
  color: var(--text-body);
  font-size: 14px; font-family: var(--font-family-base);
  outline: none; transition: border-color .15s;
}
.tvu-input::placeholder { color: var(--text-placeholder); }
.tvu-input:hover:not(:disabled), .tvu-input:focus:not(:disabled) { border-color: var(--brand); }
/* Input.vue has no icon slot, so the leading glyph is a composed overlay
   anchored to the field box — R14 sanctioned absolute use. The extra
   padding-inline-start below is the only geometry delta vs Input.vue. */
.field .field__icon {
  position: absolute;
  left: var(--sp-xs); top: 50%; transform: translateY(-50%);
  color: var(--icon-default); pointer-events: none;
}
.field .tvu-input { padding-inline-start: var(--sp-xl); }

/* ==========================================================================
   7 · Page shell + toolbar strip
   catalog `Top bar` §Don't use for — page-level elements (counters / filter
   chips / bulk actions) MUST live in a strip BELOW the bar, and there must be
   no duplicate page-title strip. Page identity = the active nav item.
   ========================================================================== */
.page {
  max-width: var(--container-wide);
  margin: 0 auto;
  padding: var(--sp-l);
  display: flex;
  flex-direction: column;
  gap: var(--sp-m);
}
.toolbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-m); flex-wrap: wrap;
}
.toolbar__right { display: flex; align-items: center; gap: var(--sp-xs); }
.page-foot { margin: 0; font: var(--text-style-tips); color: var(--text-tips); }
.page-foot b { color: var(--text-2); font-variant-numeric: tabular-nums; }

/* ==========================================================================
   8 · Table — mirror of src/components/Table/Table.vue (Type=Tbody, striped)
   ========================================================================== */
.tbl-wrap { position: relative; overflow-x: auto; }
.tbl {
  width: 100%;
  min-width: 1240px;
  border-collapse: collapse;
  font: var(--text-style-body);
  color: var(--text-body);
  background: var(--bg-layer2);
}
/* INFRA-F129 ①: neutralise the global element baseline `td { border-top: 1px
   solid var(--line-border) }`. Under border-collapse it wins over the
   component's own row border-bottom (--line-light). Figma tbody cells were
   live-verified to have strokes:[] — the extra line is code-drawn, not design. */
.tbl td { border-top: 0; }
.tbl-head .tbl-cell {
  /* CANONICAL-029: Figma cell auto-layout is L0 / R8 / T8 / B8 (no left padding) */
  padding: var(--sp-xs) var(--sp-xs) var(--sp-xs) 0;
  font: var(--text-style-body); font-weight: 700;
  color: var(--text-tips);
  background: transparent;
  border-bottom: 1px solid var(--line-deep);
  white-space: nowrap;
}
.tbl-body .tbl-row { border-bottom: 1px solid var(--line-light); transition: background .12s; }
.tbl-body .tbl-row:last-child { border-bottom: none; }
/* Mirrored verbatim from Table.vue. NOTE (reported, not silently "fixed"):
   hover and striped-even resolve to the SAME token (--bg-layer3), so hover is
   invisible on even rows in the shipped canonical. R3 forbids redesigning the
   canonical here; logged as a finding instead. */
.tbl-body .tbl-row:hover { background: var(--bg-layer3); }
.tbl--striped .tbl-body .tbl-row.is-even { background: var(--bg-layer3); }
.tbl-body .tbl-cell {
  padding: var(--sp-xs) var(--sp-xs) var(--sp-xs) 0;
  font: var(--text-style-body);
  color: var(--text-body);
  vertical-align: middle;
}
.tbl-cell__content { display: flex; align-items: center; gap: var(--sp-xxs); min-width: 0; }
.tbl-cell--right .tbl-cell__content { justify-content: flex-end; }
.tbl-cell__icon { flex: 0 0 auto; width: 16px; height: 16px; color: var(--icon-default); }

/* Row is a two-line block: primary identifier + M10 sub-line */
.cell-stack { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cell-primary { color: var(--text-body); font-weight: 600; }
.cell-sub {
  display: flex; align-items: center; gap: var(--sp-xxs);
  font: var(--text-style-tips); color: var(--text-tips);
}
.cell-sub--warn { color: var(--orange); }
.cell-sub--warn .ico { color: var(--orange); }
.cell-num { font-variant-numeric: tabular-nums; }
/* M29 Form 2 — empty value cell: em dash, placeholder colour, NO copy/action icon */
.cell-empty { color: var(--text-placeholder); }

/* R23 — constrained-cell overflow: single-line tail ellipsis. The parent flex
   item needs min-width:0 or nowrap blows the layout out instead of truncating. */
.cell-ellipsis {
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* M29 Form 1 — copyable field: text + trailing icon/Edit/Copy 12×12, gap 4 */
.copy-btn {
  flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; padding: 0; border: 0; border-radius: var(--r-xs);
  background: transparent; color: var(--icon-default); cursor: pointer;
}
.copy-btn:hover { color: var(--icon-active); }
.copy-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }

/* M7 — actions are the HEAVIEST layer on a control surface: full-size M
   buttons with literal verbs (M9), never shrunk to XS chips. The column is
   allowed to be wide; the table scrolls horizontally rather than the buttons
   getting smaller (Fitts). */
.row-actions {
  display: flex; align-items: center; justify-content: flex-end;
  gap: var(--sp-xs); flex-wrap: nowrap;
}
.row-actions__more { position: relative; }

/* Tooltip — mirror of src/components/Tooltip/Tooltip.vue, theme=dark */
.tooltip-host { position: relative; display: inline-flex; min-width: 0; }
.tooltip-box {
  /* overlay anchored to its trigger — R14 sanctioned absolute use */
  position: absolute;
  z-index: var(--z-tooltip);
  bottom: calc(100% + 6px);
  left: 0;
  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;
  white-space: normal;
  overflow-wrap: anywhere;
  width: max-content;
}
.tooltip-arrow {
  position: absolute; bottom: -5px; left: var(--sp-s);
  width: 8.5px; height: 8.5px;
  background: var(--tooltip-bg-dark);
  border-right: 1px solid var(--tooltip-border-dark);
  border-bottom: 1px solid var(--tooltip-border-dark);
  transform: rotate(45deg);
}

/* ==========================================================================
   9 · Notification — mirror of src/components/Notification/Notification.vue
   Figma set 23535120… / 1408:17154 · form × type × theme + closable
   ========================================================================== */
.notif {
  display: grid;
  gap: var(--sp-xl);
  width: var(--notification-width-default);
  padding: var(--sp-m) var(--sp-l);
  border-radius: 6px;
  border: 1.2px solid var(--line-light);
  box-shadow: var(--shadow-l3);
  background: var(--notification-bg-dark);
  color: var(--text-body);
}
.notif--slide { width: var(--notification-width-side-pop); gap: var(--sp-s); }
.notif__main { display: flex; align-items: flex-start; gap: var(--sp-m); }
.notif__icon { flex: 0 0 auto; width: 24px; height: 24px; }
.notif__body { flex: 1 1 auto; min-width: 0; display: grid; gap: var(--sp-s); }
.notif__header { display: flex; align-items: center; gap: var(--sp-m); }
.notif__title { flex: 1 1 auto; min-width: 0; margin: 0; font: var(--text-style-pop-title); }
.notif__desc { margin: 0; font: var(--text-style-body); color: var(--text-tips); }
/* pop confirm: the "are you sure" question IS the primary content — Figma binds
   its single Description text to the body colour, not Tips grey. */
.notif--pop-confirm .notif__desc { color: var(--text-body); }
.notif__close {
  flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; padding: 0; border: 0;
  background: transparent; color: var(--icon-default); cursor: pointer;
}
.notif__close:hover { color: var(--icon-active); }
.notif__actions { display: flex; justify-content: flex-end; gap: var(--sp-m); }

/* PopupBox-equivalent backdrop for the pop-confirm.
   overlay covering the viewport, centring the modal — R14 sanctioned fixed use */
.popup-overlay {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex; align-items: center; justify-content: center;
  background: var(--mask-overlay);
  backdrop-filter: blur(var(--mask-overlay-blur));
}

/* toast stack pinned to the viewport corner — R14 sanctioned fixed use */
.toast-region {
  position: fixed;
  right: var(--sp-l); bottom: var(--sp-l);
  z-index: var(--z-modal);
  display: flex; flex-direction: column; gap: var(--sp-s);
}

/* Narrow viewports: the toolbar wraps; the table keeps its min-width and
   scrolls (M7 — never shrink the action buttons to make columns fit). */
@media (max-width: 1100px) {
  .topbar { gap: var(--sp-l); }
  .topbar-menu { gap: var(--sp-m); }
  .field { width: 200px; }
}
</style>
</head>
<body>

<!-- ===== Top bar (Tag=After Login) ===== -->
<header class="topbar">
  <div class="topbar-brand">
    <!-- icon/logo/tvu — library asset kept byte-verbatim. Its fill is the TVU
         logo's own brand colour baked in by Figma's SVG export (the registered
         `icon-variable-resolved-to-hex` divergence); it is NOT a design token
         and must not be swapped for one. -->
    <span class="ico ico--28" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.9933 8.68971V8.6898V8.6899V8.69001C15.9962 8.96317 16 9.31582 16 9.81499V13.9033C16 13.9033 16 16 13.6733 15.9997H7.99586L7.98791 15.9998L7.97996 16H7.97102H7.93922C7.93922 16 7.94419 16 7.9538 15.9997C3.55876 15.9857 0 12.4098 0 8.00017C0 3.5819 3.573 0 7.97996 0C12.1057 0 15.5005 3.13931 15.9165 7.16512L15.9182 7.16545L15.9185 7.18106C15.946 7.45033 15.99 7.97375 15.99 8.25C15.99 8.37606 15.9915 8.51693 15.9933 8.68953L15.9933 8.68971ZM1.19653 7.97028C1.19653 11.7388 4.24392 14.7937 8.00305 14.7937C11.7622 14.7937 14.8096 11.7388 14.8096 7.97028C14.8096 4.20178 11.7622 1.14681 8.00305 1.14681C4.24392 1.14681 1.19653 4.20178 1.19653 7.97028ZM13.4204 15.2121L15.0122 14.2386L13.4204 13.212V15.2121ZM12.6575 12.0533C12.7649 12.0533 12.8543 12.0186 12.926 11.9492C12.9973 11.8798 13.0333 11.7951 13.033 11.6953C13.033 11.6246 13.0119 11.5608 12.9701 11.5041C12.9273 11.448 12.8468 11.39 12.7279 11.3296C12.6304 11.2805 12.5665 11.2378 12.5359 11.2011C12.5057 11.1651 12.4904 11.1274 12.4904 11.088C12.4904 11.0433 12.5087 11.0042 12.5457 10.9705C12.5828 10.9368 12.627 10.9201 12.6789 10.9201C12.7605 10.9201 12.8441 10.9605 12.9293 11.0419L13.032 10.9378C12.9161 10.8281 12.8013 10.7733 12.6871 10.7733C12.5889 10.7733 12.5077 10.804 12.4435 10.8651C12.3793 10.9265 12.347 11.0032 12.347 11.0957C12.347 11.1667 12.367 11.2298 12.4075 11.2849C12.4476 11.3399 12.5237 11.396 12.6358 11.4527C12.7401 11.5051 12.8074 11.5481 12.8369 11.5825C12.8665 11.6175 12.8814 11.6576 12.8814 11.7023C12.8814 11.7567 12.859 11.8041 12.8142 11.8444C12.7693 11.8848 12.7146 11.9048 12.6507 11.9048C12.5593 11.9048 12.473 11.8595 12.3915 11.7694L12.2913 11.8812C12.3337 11.9349 12.3874 11.9769 12.4527 12.0076C12.5175 12.038 12.5858 12.0533 12.6575 12.0533ZM11.9733 12.0533L11.3829 11.4987V12.0533H11.2319V10.3467H11.3829V11.3188L11.9264 10.8219H12.148L11.5037 11.4093L12.1856 12.0533H11.9733ZM10.6556 10.9878C10.7049 10.916 10.7569 10.8623 10.8117 10.8267C10.8665 10.7911 10.9234 10.7733 10.9826 10.7733C11.0274 10.7733 11.075 10.7874 11.126 10.8157L11.0416 10.9512C11.0078 10.9365 10.9792 10.9293 10.9561 10.9293C10.902 10.9293 10.85 10.9512 10.8 10.9953C10.7501 11.0391 10.7118 11.1075 10.6856 11.1999C10.6656 11.271 10.6556 11.4147 10.6556 11.6312V12.0533H10.4902V10.8055H10.6556V10.9878ZM9.64269 10.7733C9.83034 10.7733 9.98595 10.8411 10.1095 10.9769C10.2217 11.1007 10.2778 11.2472 10.2781 11.4167C10.2781 11.5868 10.2187 11.7353 10.1001 11.8625C9.9816 11.9896 9.82934 12.0533 9.64302 12.0533C9.45603 12.0533 9.3031 11.9896 9.18456 11.8625C9.06569 11.7353 9.00659 11.5868 9.00659 11.4167C9.00659 11.2482 9.06269 11.102 9.17488 10.9782C9.29809 10.8417 9.45403 10.7733 9.64269 10.7733ZM9.88152 11.8421C9.95431 11.8007 10.0121 11.742 10.0548 11.6659C10.0976 11.5899 10.1189 11.5081 10.1186 11.4197C10.1186 11.2832 10.0718 11.1667 9.97768 11.0706C9.88352 10.9742 9.77166 10.9262 9.6421 10.9262C9.51155 10.9262 9.39969 10.9745 9.30586 11.0706C9.21203 11.1667 9.16528 11.2832 9.16528 11.4197C9.16528 11.5078 9.18665 11.5899 9.22939 11.6659C9.27213 11.742 9.3299 11.8007 9.40269 11.8421C9.47548 11.8835 9.55529 11.9042 9.6421 11.9042C9.72892 11.9042 9.80872 11.8835 9.88152 11.8421ZM8.33623 12.0533H8.36592L8.90069 10.7733H8.73604L8.35108 11.7025L7.95565 10.7733H7.92698L7.53391 11.7025L7.15637 10.7733H6.99341L7.5194 12.0533H7.5491L7.9425 11.1368L8.33623 12.0533ZM6.88747 10.9474H6.63214V12.0533H6.4714V10.9474H6.25171V10.8089H6.4714V10.3467H6.63214V10.8089H6.88747V10.9474ZM5.83626 11.7904C5.79475 11.8261 5.74412 11.8548 5.68505 11.8761C5.62564 11.8978 5.56454 11.9085 5.5021 11.9085C5.37214 11.9085 5.26278 11.8635 5.17434 11.773C5.08591 11.6826 5.04034 11.5668 5.0373 11.4253H6.14579C6.1441 11.2592 6.09887 11.1204 6.00976 11.0092C5.8842 10.8517 5.71644 10.7733 5.50648 10.7733C5.30261 10.7733 5.13958 10.8501 5.01806 11.0039C4.9222 11.1247 4.87427 11.2622 4.87427 11.4167C4.87427 11.5808 4.93097 11.728 5.04439 11.8581C5.1578 11.9883 5.31476 12.0533 5.51594 12.0533C5.60674 12.0533 5.68775 12.0396 5.75964 12.0126C5.83154 11.9853 5.89601 11.9456 5.95339 11.8935C6.01077 11.8414 6.06141 11.7727 6.10529 11.6876L5.97162 11.6182C5.92301 11.697 5.87778 11.7547 5.83626 11.7904ZM5.19687 11.0383C5.28395 10.9615 5.38758 10.9232 5.50875 10.9232C5.582 10.9232 5.65187 10.9385 5.71837 10.9689C5.78486 10.9996 5.8382 11.0396 5.8787 11.0897C5.91921 11.1397 5.94992 11.2065 5.97119 11.2899H5.05273C5.08548 11.1781 5.13341 11.094 5.19687 11.0383ZM4.45639 10.8445C4.38617 10.7969 4.30564 10.7733 4.21478 10.7733C4.1256 10.7733 4.04273 10.7945 3.96686 10.8369C3.89098 10.8793 3.82176 10.9433 3.75919 11.0291V10.8055H3.60278V12.053H3.75919V11.5954C3.75919 11.4309 3.76652 11.3181 3.78149 11.2562C3.80545 11.1615 3.85471 11.0818 3.92958 11.0178C4.00446 10.9535 4.08899 10.9214 4.1835 10.9214C4.26603 10.9214 4.33326 10.9423 4.38451 10.984C4.43576 11.0257 4.4707 11.0876 4.48867 11.1704C4.50032 11.2186 4.50597 11.3143 4.50597 11.4583V12.0533H4.66239V11.4114C4.66239 11.2418 4.64575 11.1167 4.61213 11.0366C4.57852 10.9556 4.52661 10.8917 4.45639 10.8445ZM12.8146 8.39867V5.86667H13.669V8.41467C13.669 8.90067 13.6164 9.27267 13.3229 9.53167C13.0294 9.79067 12.5968 9.92 12.0253 9.92C11.4538 9.92 11.022 9.79034 10.7291 9.53167C10.4366 9.273 10.3843 8.90134 10.3843 8.41734V5.86667H11.2394V8.39867C11.2394 8.67167 11.2963 8.875 11.4098 9.00867C11.5233 9.14233 11.7705 9.20934 12.0257 9.20934C12.2772 9.20934 12.524 9.14534 12.6402 9.01667C12.7564 8.888 12.8146 8.682 12.8146 8.39867ZM9.02194 5.86667L8.01441 8.853L7.00721 5.86667H6.03979L7.53984 9.92H8.46061L9.96033 5.86667H9.02194ZM3.48276 6.554H2.2251V5.86667H5.61583V6.554H4.35817V9.92H3.48276V6.554Z" fill="#56AF31"/></svg></span>
    <span class="topbar-title" id="product-name">TVU MediaHub</span>
  </div>

  <!-- Requirement 4 — page-level navigation. MenuList --horizontal.
       Active state = text colour ONLY. No underline (Figma truth). -->
  <nav class="topbar-menu" aria-label="Primary">
    <ul class="menu-list menu-list--horizontal" id="nav"></ul>
  </nav>

  <div class="topbar-right">
    <span class="topbar-bell">
      <button class="icon-btn icon-btn--bare" type="button" id="bell-btn">
        <span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 2C7 1.44772 7.44772 1 8 1C8.55228 1 9 1.44772 9 2V2.08202C11.3333 2.48629 12.5 4.35426 12.5 7V9.1465C12.5 9.37726 12.5665 9.60314 12.6916 9.79705L13.903 11.6747C14.1606 12.074 13.874 12.6 13.3988 12.6H2.60111C2.12597 12.6 1.83934 12.074 2.09693 11.6747L3.30833 9.79705C3.43344 9.60314 3.49998 9.37726 3.49998 9.1465V7C3.49998 4.35424 4.66665 2.48626 7 2.08201V2ZM7.99998 3.2C6.78386 3.2 6.01816 3.59353 5.53608 4.15595C5.02977 4.74665 4.69998 5.6841 4.69998 7V9.1465C4.69998 9.60803 4.5669 10.0598 4.31669 10.4476L3.80173 11.2458C3.7588 11.3123 3.80657 11.4 3.88576 11.4H12.1142C12.1934 11.4 12.2412 11.3123 12.1982 11.2458L11.6833 10.4476C11.4331 10.0598 11.3 9.60803 11.3 9.1465V7C11.3 5.6841 10.9702 4.74665 10.4639 4.15595C9.98179 3.59353 9.21609 3.2 7.99998 3.2Z" fill="currentColor"/><path d="M6.52716 13.7988C6.66599 14.4837 7.27141 14.9992 7.99723 14.9992C8.72306 14.9992 9.32848 14.4837 9.46731 13.7988H6.52716Z" fill="currentColor"/></svg></span>
        <span class="sr-only" id="bell-label"></span>
      </button>
      <!-- Badge Type=Circle Color=Red Tag=Filled -->
      <span class="badge badge--circle badge--filled-red" aria-hidden="true">2</span>
    </span>
    <button class="btn btn--text-gray" type="button" id="user-btn"></button>
  </div>
</header>

<main class="page">
  <!-- Page-level strip: filter chips + search + refresh. Deliberately NOT a
       duplicate page-title strip (catalog `Top bar` §Don't use for). -->
  <div class="toolbar">
    <div class="tab-list" id="filters" role="tablist" aria-label="Filter by state"></div>
    <div class="toolbar__right">
      <label class="field">
        <span class="sr-only" id="search-label"></span>
        <span class="ico field__icon" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.6529 12.5025C10.5267 13.4389 9.07914 14.002 7.50006 14.002C3.91021 14.002 1.00006 11.0918 1.00006 7.50195C1.00006 3.9121 3.91021 1.00195 7.50006 1.00195C11.0899 1.00195 14.0001 3.9121 14.0001 7.50195C14.0001 9.08062 13.4373 10.5278 12.5014 11.6539L14.8227 13.9753C15.057 14.2096 15.057 14.5895 14.8227 14.8238C14.5884 15.0581 14.2085 15.0581 13.9742 14.8238L11.6529 12.5025ZM12.8001 7.50117C12.8001 4.57406 10.4272 2.20117 7.50007 2.20117C4.57296 2.20117 2.20007 4.57406 2.20007 7.50117C2.20007 10.4283 4.57296 12.8012 7.50007 12.8012C10.4272 12.8012 12.8001 10.4283 12.8001 7.50117Z" fill="currentColor"/></svg></span>
        <input class="tvu-input" type="search" id="search" autocomplete="off">
      </label>
      <button class="btn btn--ghost-gray btn--with-icon" type="button" id="refresh-btn">
        <span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.8367 1.60055C9.46757 0.993637 7.93986 0.84184 6.47805 1.16746C5.01623 1.49308 3.69739 2.27896 2.71528 3.40962C1.73317 4.54028 1.13958 5.95612 1.02172 7.44912C0.903855 8.94211 1.26794 10.4336 2.06047 11.7043C2.853 12.9751 4.0322 13.9581 5.42481 14.5091C6.81743 15.0601 8.35004 15.1499 9.79746 14.7653C11.2449 14.3807 12.5308 13.5421 13.4663 12.3726C13.6733 12.1138 13.6313 11.7362 13.3726 11.5292C13.1138 11.3222 12.7362 11.3642 12.5292 11.623C11.7541 12.592 10.6886 13.2869 9.48933 13.6055C8.29003 13.9242 7.02016 13.8498 5.86628 13.3933C4.71239 12.9367 3.73534 12.1222 3.07867 11.0693C2.422 10.0164 2.12034 8.78061 2.21799 7.54355C2.31565 6.3065 2.80748 5.13338 3.62123 4.19654C4.43498 3.25971 5.52773 2.60856 6.73895 2.33876C7.95017 2.06895 9.21599 2.19473 10.3504 2.6976C11.3659 3.14773 12.2282 3.87877 12.8379 4.80078H11.6C11.2686 4.80078 11 5.06941 11 5.40078C11 5.73215 11.2686 6.00078 11.6 6.00078H14.4C14.7314 6.00078 15 5.73215 15 5.40078L15 5.39692V2.6C15 2.26863 14.7314 2 14.4 2C14.0686 2 13.8 2.26863 13.8 2.6V4.08078C13.0666 2.99539 12.0409 2.13433 10.8367 1.60055Z" fill="currentColor"/></svg></span>
        <span id="refresh-label"></span>
      </button>
    </div>
  </div>

  <!-- M12 — an external/async event must never be a silent no-op: detect →
       notify → give a path forward. Notification form=alert type=warning
       (one-action, so the forward path is one click, not passive prose). -->
  <div class="notif notif--alert notif--type-warning" role="alert" id="degrade-alert">
    <div class="notif__main">
      <span class="ico ico--24 notif__icon" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 1C4.13438 1 1 4.13438 1 8C1 11.8656 4.13438 15 8 15C11.8656 15 15 11.8656 15 8C15 4.13438 11.8656 1 8 1ZM7.9999 4C8.33127 4 8.5999 4.26863 8.5999 4.6V9C8.5999 9.33137 8.33127 9.6 7.9999 9.6C7.66853 9.6 7.3999 9.33137 7.3999 9V4.6C7.3999 4.26863 7.66853 4 7.9999 4ZM7.9999 12C7.66853 12 7.3999 11.7314 7.3999 11.4C7.3999 11.0686 7.66853 10.8 7.9999 10.8C8.33127 10.8 8.5999 11.0686 8.5999 11.4C8.5999 11.7314 8.33127 12 7.9999 12Z" fill="currentColor"/></svg></span>
      <div class="notif__body">
        <div class="notif__header">
          <p class="notif__title" id="alert-title"></p>
          <button class="notif__close" type="button" id="alert-close">
            <span class="ico ico--12" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.5758 13.4247C12.8101 13.659 13.19 13.659 13.4243 13.4247C13.6586 13.1903 13.6586 12.8104 13.4243 12.5761L8.84898 8.00082L13.4248 3.42543C13.6591 3.19113 13.6591 2.81123 13.4248 2.5769C13.1905 2.34258 12.8106 2.34256 12.5763 2.57687L8.00045 7.15229L3.42429 2.57613C3.18997 2.34181 2.81007 2.34181 2.57576 2.57613C2.34145 2.81044 2.34145 3.19034 2.57576 3.42465L7.15188 8.00078L2.57596 12.5763C2.34163 12.8106 2.34161 13.1905 2.57592 13.4248C2.81022 13.6592 3.19012 13.6592 3.42445 13.4249L8.00041 8.84931L12.5758 13.4247Z" fill="currentColor"/></svg></span>
            <span class="sr-only" id="alert-close-label"></span>
          </button>
        </div>
        <p class="notif__desc" id="alert-desc"></p>
      </div>
    </div>
    <div class="notif__actions">
      <!-- type=warning → confirm fill is brand green per Notification.vue confirmColor -->
      <button class="btn btn--fill-green" type="button" id="alert-ok"></button>
    </div>
  </div>

  <div class="tbl-wrap">
    <table class="tbl tbl--striped" id="tbl">
      <caption class="sr-only" id="tbl-caption"></caption>
      <thead class="tbl-head"><tr id="thead-row"></tr></thead>
      <tbody class="tbl-body" id="tbody"></tbody>
    </table>
  </div>

  <p class="page-foot" id="foot" aria-live="polite"></p>
</main>

<!-- ===== Requirement 5 — destructive second confirmation =====
     Notification form=pop confirm, type=danger, theme=dark (catalog: pop confirm
     IS the design system's confirm dialog). Deliberately NOT Popup Box —
     catalog §Message "Don't use for" states plainly that using Popup Box for a
     plain confirmation is the wrong pick; Popup Box only carries large custom
     content that Notification's fixed structure cannot hold. -->
<div class="popup-overlay" id="confirm-overlay" hidden>
  <div class="notif notif--pop-confirm notif--type-danger" role="dialog" aria-modal="true"
       aria-labelledby="confirm-title" aria-describedby="confirm-desc" id="confirm-box" tabindex="-1">
    <div class="notif__main">
      <!-- pop confirm carries NO category icon (Notification.vue showsIcon) -->
      <div class="notif__body">
        <div class="notif__header">
          <p class="notif__title" id="confirm-title"></p>
          <button class="notif__close" type="button" id="confirm-close">
            <span class="ico ico--12" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.5758 13.4247C12.8101 13.659 13.19 13.659 13.4243 13.4247C13.6586 13.1903 13.6586 12.8104 13.4243 12.5761L8.84898 8.00082L13.4248 3.42543C13.6591 3.19113 13.6591 2.81123 13.4248 2.5769C13.1905 2.34258 12.8106 2.34256 12.5763 2.57687L8.00045 7.15229L3.42429 2.57613C3.18997 2.34181 2.81007 2.34181 2.57576 2.57613C2.34145 2.81044 2.34145 3.19034 2.57576 3.42465L7.15188 8.00078L2.57596 12.5763C2.34163 12.8106 2.34161 13.1905 2.57592 13.4248C2.81022 13.6592 3.19012 13.6592 3.42445 13.4249L8.00041 8.84931L12.5758 13.4247Z" fill="currentColor"/></svg></span>
            <span class="sr-only" id="confirm-close-label"></span>
          </button>
        </div>
        <p class="notif__desc" id="confirm-desc"></p>
      </div>
    </div>
    <div class="notif__actions">
      <button class="btn btn--ghost-gray" type="button" id="confirm-cancel"></button>
      <!-- type=danger → destructive confirm is filling/red (confirmColor) -->
      <button class="btn btn--fill-red btn--with-icon" type="button" id="confirm-go">
        <span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><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 8ZM2.20001 7.99922C2.20001 11.2025 4.79676 13.7992 8.00001 13.7992C11.2033 13.7992 13.8 11.2025 13.8 7.99922C13.8 4.79597 11.2033 2.19922 8.00001 2.19922C4.79676 2.19922 2.20001 4.79597 2.20001 7.99922ZM5.60004 4.99925C5.26867 4.99925 5.00004 5.26788 5.00004 5.59925V10.3993C5.00004 10.7306 5.26867 10.9993 5.60004 10.9993H10.4C10.7314 10.9993 11 10.7306 11 10.3993V5.59925C11 5.26788 10.7314 4.99925 10.4 4.99925H5.60004Z" fill="currentColor"/></svg></span>
        <span id="confirm-go-label"></span>
      </button>
    </div>
  </div>
</div>

<div class="toast-region" id="toast-region" role="status" aria-live="polite"></div>

<script>
/* ==========================================================================
   R16 / M33 — product UI strings live in ONE const map, single language.
   Product UI is English (TVU device/product convention); Chinese belongs to
   comments / handoff / annotation layers only, never to runtime strings.
   代码注释可双语，不进 runtime、不进翻译流。
   ========================================================================== */
const T = {
  productName: 'TVU MediaHub',
  nav: ['Sources', 'Destinations', 'Routing', 'Monitoring', 'Analytics'],
  navActive: 'Sources',
  notifications: 'Notifications, 2 unread',
  user: 'N. Zeng · NOC',
  searchLabel: 'Search sources',
  searchPlaceholder: 'Search name, URL or receiver',
  refresh: 'Refresh',
  close: 'Close',
  tableCaption: 'Live stream sources, their ingest URL, receiver, state and latency',
  columns: {
    source: 'Source',
    url: 'Ingest URL',
    receiver: 'Receiver',
    state: 'State',
    latency: 'Latency',
    actions: 'Actions',
  },
  state: { onair: 'On air', degraded: 'Degraded', offline: 'Offline' },
  filters: { all: 'All', onair: 'On air', degraded: 'Degraded', offline: 'Offline' },
  actions: {
    stop: 'Stop Streaming',
    start: 'Start Streaming',
    preview: 'Preview',
    setup: 'Set Source and Receiver',
    more: 'More actions',
    edit: 'Edit source',
    restart: 'Restart stream',
    remove: 'Remove source',
    copyUrl: 'Copy ingest URL',
  },
  alert: {
    title: 'Field-Van-A is degraded',
    desc: 'Packet loss on the LAX ingest path rose to 4.2% two minutes ago. The source is still on air; TVU never switches your feed automatically.',
    ok: 'Promote SRT-Backup-Van-A',
  },
  confirm: {
    title: 'Stop streaming?',
    // The impact sentence is the reason this dialog exists — it names the
    // receiver that goes dark and says the interruption is immediate.
    desc: (n, r) => n + ' is on air to ' + r + '. Stopping the stream interrupts the live transmission immediately and ' + r + ' will lose its feed.',
    cancel: 'Cancel',
    go: 'Stop Streaming',
  },
  toast: {
    stopped: (n) => n + ' stopped. The source is now offline.',
    copied: 'Ingest URL copied to clipboard.',
    refreshed: 'Source list refreshed.',
    promoted: 'SRT-Backup-Van-A promoted. Field-Van-A is now the standby path.',
  },
  never: 'Never started',
  emptyValue: '\u2014', // M29 Form 2 — em dash for a genuinely empty value
  footer: (t, a, d, o) => t + ' sources \u00B7 ' + a + ' on air \u00B7 ' + d + ' degraded \u00B7 ' + o + ' offline',
  noMatch: 'No sources match the current filter.',
};

/* ===== Library icons — every entry is verbatim from
   src/icons/catalog/generated/*.ts. The three registry icons whose Figma SVG
   export baked the Variable down to a hex (message/warning-1, message/success-2,
   indicator/live-1 — the registered `icon-variable-resolved-to-hex` divergence)
   are carried here with fill="currentColor" so the colour comes from a token at
   the call site instead of a literal. Geometry is untouched. ===== */
const ICON = {
  'input/primary-source': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.6465 9.64647C14.8417 9.84173 14.8417 10.1583 14.6465 10.3535L10.8535 14.1465L10.792 14.1992C10.4946 14.4169 10.0624 14.2382 10.0059 13.874L10 13.793V12.1C10 12.0448 9.95523 12 9.9 12H2.5L2.39941 11.9902C2.17147 11.9437 2.00003 11.7417 2 11.5V8.99979C2 8.88933 2.08954 8.79979 2.2 8.79979H3.0002C3.11065 8.79979 3.2002 8.88933 3.2002 8.99979V10.6998C3.2002 10.755 3.24497 10.7998 3.3002 10.7998H11.0002C11.1107 10.7998 11.2002 10.8893 11.2002 10.9998V11.8611C11.2002 11.9502 11.3079 11.9948 11.3709 11.9318L13.232 10.0707C13.2711 10.0316 13.2711 9.96833 13.232 9.92928L12.442 9.13929C12.364 9.06125 12.3639 8.93474 12.4419 8.85661L13.007 8.29017C13.0851 8.21191 13.2119 8.21184 13.29 8.29001L14.6465 9.64647ZM9 2.20799C9.00002 1.76256 9.53854 1.53952 9.85352 1.85447L13.6465 5.64744C13.8417 5.84271 13.8417 6.15924 13.6465 6.35448L9.85352 10.1474C9.53856 10.4623 9.00013 10.2392 9 9.79393V8.10096C9 8.04573 8.95523 8.00096 8.9 8.00096H1.5C1.22389 8.00096 1.00005 7.77706 1 7.50096V4.50096C1 4.22482 1.22386 4.00096 1.5 4.00096H8.9C8.95523 4.00096 9 3.95619 9 3.90096V2.20799Z" fill="currentColor"/></svg>',
  'input/receiver': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.0061 3.90039C11.1066 3.90039 11.2056 3.92568 11.2939 3.97393L14.6878 5.82933C14.8803 5.93456 15 6.13643 15 6.3558V11.4C15 11.7314 14.7314 12 14.4 12H1.6C1.26863 12 1 11.7314 1 11.4V6.3558C1 6.13643 1.11971 5.93456 1.31219 5.82933L4.70617 3.97392C4.79442 3.92568 4.89339 3.90039 4.99397 3.90039H11.0061ZM12.3499 5.90589L10.8848 5.12395C10.8558 5.10848 10.8235 5.10039 10.7906 5.10039H5.20938C5.17654 5.10039 5.14419 5.10848 5.11522 5.12395L3.65007 5.90589C3.60415 5.93039 3.62157 6 3.67361 6H12.3264C12.3785 6 12.3959 5.93039 12.3499 5.90589ZM2.4 7.2C2.28954 7.2 2.2 7.28954 2.2 7.4V10.6C2.2 10.7105 2.28954 10.8 2.4 10.8H13.6C13.7104 10.8 13.8 10.7105 13.8 10.6V7.4C13.8 7.28954 13.7104 7.2 13.6 7.2H2.4ZM6.14999 9.39922C6.61943 9.39922 6.99999 9.01866 6.99999 8.54922C6.99999 8.07978 6.61943 7.69922 6.14999 7.69922C5.68055 7.69922 5.29999 8.07978 5.29999 8.54922C5.29999 9.01866 5.68055 9.39922 6.14999 9.39922ZM10.7 8.54922C10.7 9.01866 10.3194 9.39922 9.85 9.39922C9.38056 9.39922 9 9.01866 9 8.54922C9 8.07978 9.38056 7.69922 9.85 7.69922C10.3194 7.69922 10.7 8.07978 10.7 8.54922ZM11.4 10C11.4552 10 11.5 10.0448 11.5 10.1V10.3C11.5 10.3552 11.4552 10.4 11.4 10.4H10.6C10.5448 10.4 10.5 10.3552 10.5 10.3V10.1C10.5 10.0448 10.5448 10 10.6 10H11.4ZM12.9 10C12.9552 10 13 10.0448 13 10.1V10.3C13 10.3552 12.9552 10.4 12.9 10.4H12.1C12.0448 10.4 12 10.3552 12 10.3V10.1C12 10.0448 12.0448 10 12.1 10H12.9Z" fill="currentColor"/></svg>',
  'edit/copy': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2.2 1C1.53726 1 1 1.53726 1 2.2V11.4C1 12.0627 1.53726 12.6 2.2 12.6H3.40002V13.8004C3.40002 14.4631 3.93728 15.0004 4.60002 15.0004H13.8C14.4628 15.0004 15 14.4631 15 13.8004V4.60039C15 3.93765 14.4628 3.40039 13.8 3.40039H12.6V2.2C12.6 1.53726 12.0627 1 11.4 1H2.2ZM11.4 3.40039H4.60002C3.93728 3.40039 3.40002 3.93765 3.40002 4.60039V11.3992H2.60001C2.3791 11.3992 2.20001 11.2201 2.20001 10.9992V2.59922C2.20001 2.3783 2.3791 2.19922 2.60001 2.19922H11C11.2209 2.19922 11.4 2.3783 11.4 2.59922V3.40039ZM4.60004 4.99961C4.60004 4.77869 4.77912 4.59961 5.00004 4.59961H13.4C13.621 4.59961 13.8 4.7787 13.8 4.99961V13.3996C13.8 13.6205 13.621 13.7996 13.4 13.7996H5.00004C4.77912 13.7996 4.60004 13.6205 4.60004 13.3996V4.99961Z" fill="currentColor"/></svg>',
  'video/record-stop': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><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 8ZM2.20001 7.99922C2.20001 11.2025 4.79676 13.7992 8.00001 13.7992C11.2033 13.7992 13.8 11.2025 13.8 7.99922C13.8 4.79597 11.2033 2.19922 8.00001 2.19922C4.79676 2.19922 2.20001 4.79597 2.20001 7.99922ZM5.60004 4.99925C5.26867 4.99925 5.00004 5.26788 5.00004 5.59925V10.3993C5.00004 10.7306 5.26867 10.9993 5.60004 10.9993H10.4C10.7314 10.9993 11 10.7306 11 10.3993V5.59925C11 5.26788 10.7314 4.99925 10.4 4.99925H5.60004Z" fill="currentColor"/></svg>',
  'video/play': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 3.54035C4 3.07815 4.50062 2.78952 4.90062 3.02109L12.6031 7.48043C13.0023 7.71153 13.0023 8.28784 12.6031 8.51894L4.90062 12.9783C4.50062 13.2098 4 12.9212 4 12.459V3.54035Z" fill="currentColor"/></svg>',
  'preview/main-window': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 3.2C1 2.53726 1.53726 2 2.2 2H13.8C14.4627 2 15 2.53726 15 3.2V9.6C15 10.2627 14.4627 10.8 13.8 10.8H2.2C1.53726 10.8 1 10.2627 1 9.6V3.2ZM2.19995 3.39922V9.4C2.19995 9.51046 2.28949 9.6 2.39995 9.6H13.6C13.7105 9.6 13.8 9.51046 13.8 9.4V3.39922C13.8 3.28876 13.7105 3.19922 13.6 3.19922H2.39995C2.28949 3.19922 2.19995 3.28876 2.19995 3.39922ZM1.6 12C1.26863 12 1 12.2686 1 12.6V13.4C1 13.7314 1.26863 14 1.6 14H4.2C4.53137 14 4.8 13.7314 4.8 13.4V12.6C4.8 12.2686 4.53137 12 4.2 12H1.6ZM6.09998 12.6C6.09998 12.2686 6.3686 12 6.69998 12H9.29998C9.63135 12 9.89997 12.2686 9.89997 12.6V13.4C9.89997 13.7314 9.63135 14 9.29997 14H6.69997C6.3686 14 6.09998 13.7314 6.09998 13.4V12.6ZM11.8 12C11.4686 12 11.2 12.2686 11.2 12.6V13.4C11.2 13.7314 11.4686 14 11.8 14H14.4C14.7314 14 15 13.7314 15 13.4V12.6C15 12.2686 14.7314 12 14.4 12H11.8Z" fill="currentColor"/></svg>',
  'menu/more': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.5 3.5C9.5 4.32843 8.82843 5 8 5C7.17157 5 6.5 4.32843 6.5 3.5C6.5 2.67157 7.17157 2 8 2C8.82843 2 9.5 2.67157 9.5 3.5ZM9.5 8C9.5 8.82843 8.82843 9.5 8 9.5C7.17157 9.5 6.5 8.82843 6.5 8C6.5 7.17157 7.17157 6.5 8 6.5C8.82843 6.5 9.5 7.17157 9.5 8ZM8 14C8.82843 14 9.5 13.3284 9.5 12.5C9.5 11.6716 8.82843 11 8 11C7.17157 11 6.5 11.6716 6.5 12.5C6.5 13.3284 7.17157 14 8 14Z" fill="currentColor"/></svg>',
  'message/warning-1': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 14.9991C6.14283 15.0015 4.36136 14.2634 3.05022 12.9482C1.73569 11.6374 0.997842 9.85676 1.00001 8.00044C0.99751 6.14337 1.73573 4.36197 3.05109 3.05097C4.36181 1.7362 6.14256 0.998074 7.99912 1.00001C9.85629 0.997607 11.6378 1.73576 12.9489 3.05097C14.264 4.36182 15.0022 6.14282 15 7.99956C15.0024 9.85661 14.2642 11.638 12.9489 12.949C11.6378 14.2642 9.85629 15.0024 7.99912 15L8 14.9991ZM8 2.00002C6.39755 2.00002 4.89143 2.62436 3.75746 3.75746C2.62854 4.88017 1.9957 6.40786 2.00002 8C2.00002 9.60245 2.62436 11.1094 3.75746 12.2425C4.88017 13.3715 6.40786 14.0043 8 14C9.60245 14 11.1094 13.3756 12.2425 12.2425C13.3715 11.1198 14.0043 9.59214 14 8C14 6.39755 13.3756 4.89143 12.2425 3.75746C11.1198 2.62854 9.59214 1.9957 8 2.00002ZM7.3999 4.6C7.3999 4.26863 7.66853 4 7.9999 4C8.33127 4 8.5999 4.26863 8.5999 4.6V9C8.5999 9.33137 8.33127 9.6 7.9999 9.6C7.66853 9.6 7.3999 9.33137 7.3999 9V4.6ZM8.5999 11.4C8.5999 11.7314 8.33127 12 7.9999 12C7.66853 12 7.3999 11.7314 7.3999 11.4C7.3999 11.0686 7.66853 10.8 7.9999 10.8C8.33127 10.8 8.5999 11.0686 8.5999 11.4Z" fill="currentColor"/></svg>',
  'message/success-2': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM5.22424 7.37564L7.03029 9.18159C7.10839 9.2597 7.23502 9.25969 7.31313 9.18159L10.6017 5.89303C10.836 5.65871 11.2159 5.65871 11.4502 5.89303C11.6846 6.12735 11.6846 6.50725 11.4502 6.74156L7.80811 10.3837C7.45664 10.7351 6.88681 10.7351 6.53533 10.3837L4.37573 8.22419C4.14141 7.98988 4.1414 7.60998 4.37571 7.37566C4.61002 7.14134 4.98991 7.14133 5.22424 7.37564Z" fill="currentColor"/></svg>',
  'arrow/down': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><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"/></svg>',
  'arrow/up': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><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"/></svg>',
  'edit/close': '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.5758 13.4247C12.8101 13.659 13.19 13.659 13.4243 13.4247C13.6586 13.1903 13.6586 12.8104 13.4243 12.5761L8.84898 8.00082L13.4248 3.42543C13.6591 3.19113 13.6591 2.81123 13.4248 2.5769C13.1905 2.34258 12.8106 2.34256 12.5763 2.57687L8.00045 7.15229L3.42429 2.57613C3.18997 2.34181 2.81007 2.34181 2.57576 2.57613C2.34145 2.81044 2.34145 3.19034 2.57576 3.42465L7.15188 8.00078L2.57596 12.5763C2.34163 12.8106 2.34161 13.1905 2.57592 13.4248C2.81022 13.6592 3.19012 13.6592 3.42445 13.4249L8.00041 8.84931L12.5758 13.4247Z" fill="currentColor"/></svg>',
};

const ico = (name, extraClass) =>
  '<span class="ico ' + (extraClass || '') + '" aria-hidden="true">' + ICON[name] + '</span>';

/* ==========================================================================
   Data model.
   M3 (domain-tvu) — broadcast ON AIR convention drives the colour, NOT the
   generic SaaS "green = healthy" habit:
     onair    → --red    (a transmitting source IS on air; red is the operator's
                          muscle memory in vMix / TriCaster / OBS and in TVU)
     degraded → --orange (Badge Color=Orange, a registered axis value)
     offline  → grey     (Badge Color=Neutral)
   M10 — the offline state MUST split into two sub-types, because "stopped, can
   restart" and "never configured, go configure" are different mental models:
     offlineKind: 'idle'         → sub-line "Last ran HH:MM → HH:MM · N min"
     offlineKind: 'neverStarted' → sub-line "Never started" + literal-verb CTA
   M11 — latency trend is a TWO-layer signal: arrow icon AND a coloured number.
     trend 'down'  (Δ ≤ -2 ms, converging)  → --brand
     trend 'up'    (Δ ≥ +2 ms, diverging)   → --orange
     trend 'flat'  (within ±2 ms)           → no icon, --text-body
   ========================================================================== */
let SOURCES = [
  {
    id: 'src-01', name: 'SRT-Stadium-Cam-01', protocol: 'SRT',
    url: 'srt://ingest-nyc.tvu.tv:9001?streamid=stadium-cam-01&latency=200&passphrase=set',
    receiver: 'NYC-Receiver-01 (TVU Grid)',
    state: 'onair', sub: 'On air 01:24:17 \u00B7 8.2 Mbps',
    latency: 84, trend: 'down', delta: '\u22126 ms / 30 s',
  },
  {
    id: 'src-02', name: 'SRT-Stadium-Cam-02', protocol: 'SRT',
    url: 'srt://ingest-nyc.tvu.tv:9001?streamid=stadium-cam-02&latency=200&passphrase=set',
    receiver: 'NYC-Receiver-02 (TVU Grid)',
    state: 'onair', sub: 'On air 01:24:05 \u00B7 7.9 Mbps',
    latency: 96, trend: 'flat', delta: '+1 ms / 30 s',
  },
  {
    id: 'src-03', name: 'RTMP-Field-Van-A', protocol: 'RTMP',
    url: 'rtmp://ingest-lax.tvu.tv/live/field-van-a',
    receiver: 'LAX-Receiver-03 (TVU MediaHub)',
    state: 'degraded', sub: 'Packet loss 4.2% \u00B7 bitrate throttled to 2.1 Mbps',
    latency: 320, trend: 'up', delta: '+38 ms / 30 s',
  },
  {
    // N:1 — this backup path targets the SAME receiver as src-03 and stays idle
    // until promoted. Shown to make the cardinality visible on the surface.
    id: 'src-04', name: 'SRT-Backup-Van-A', protocol: 'SRT',
    url: 'srt://ingest-lax.tvu.tv:9002?streamid=backup-van-a&latency=400',
    receiver: 'LAX-Receiver-03 (TVU MediaHub)',
    state: 'offline', offlineKind: 'idle', sub: 'Last ran 09:12 \u2192 11:48 \u00B7 156 min',
    latency: null, trend: null, delta: null,
  },
  {
    id: 'src-05', name: 'NDI-Studio-B-Program', protocol: 'NDI',
    url: 'ndi://studio-b.tvu.local/PROGRAM',
    receiver: 'Studio-B-Multiviewer',
    state: 'onair', sub: 'On air 06:02:33 \u00B7 12.0 Mbps',
    latency: 12, trend: 'down', delta: '\u22123 ms / 30 s',
  },
  {
    id: 'src-06', name: 'SRT-Remote-Kyiv-01', protocol: 'SRT',
    url: 'srt://ingest-fra.tvu.tv:9001?streamid=remote-kyiv-01&latency=800',
    receiver: 'EU-West-Frankfurt-2',
    state: 'offline', offlineKind: 'idle', sub: 'Last ran 04:31 \u2192 05:10 \u00B7 39 min',
    latency: null, trend: null, delta: null,
  },
  {
    id: 'src-07', name: 'RTMP-Newsroom-Spare', protocol: 'RTMP',
    url: null, receiver: null,
    state: 'offline', offlineKind: 'neverStarted', sub: T.never,
    latency: null, trend: null, delta: null,
  },
];

const COLUMNS = [
  { key: 'source',   title: T.columns.source,   width: 232 },
  { key: 'url',      title: T.columns.url,      width: 268 },
  { key: 'receiver', title: T.columns.receiver, width: 208 },
  { key: 'state',    title: T.columns.state,    width: 116 },
  { key: 'latency',  title: T.columns.latency,  width: 132 },
  { key: 'actions',  title: T.columns.actions,  width: 344, align: 'right' },
];

/* Badge — one component, one Tag, the Color axis carries the semantics
   (M49 §2: same semanticRole ⇒ same visual vocabulary).
   Tag=Line keeps the pill quiet per M7 ("never a solid saturated block that
   steals focus from the actions"); the text label — not the hue — is the
   primary carrier, so the three states stay distinguishable without colour. */
const BADGE_CLASS = {
  onair: 'badge--line-red',
  degraded: 'badge--line-orange',
  offline: 'badge--line-neutral',
};

const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

let activeFilter = 'all';
let query = '';
let pending = null;      // the row awaiting destructive confirmation
let lastFocused = null;  // focus restoration target for the modal

/* ---------- static chrome ---------- */
function renderChrome() {
  $('#product-name').textContent = T.productName;
  $('#bell-label').textContent = T.notifications;
  $('#user-btn').textContent = T.user;
  $('#search-label').textContent = T.searchLabel;
  $('#search').placeholder = T.searchPlaceholder;
  $('#refresh-label').textContent = T.refresh;
  $('#tbl-caption').textContent = T.tableCaption;
  $('#alert-title').textContent = T.alert.title;
  $('#alert-desc').textContent = T.alert.desc;
  $('#alert-ok').textContent = T.alert.ok;
  $('#alert-close-label').textContent = T.close;
  $('#confirm-cancel').textContent = T.confirm.cancel;
  $('#confirm-go-label').textContent = T.confirm.go;
  $('#confirm-close-label').textContent = T.close;

  $('#nav').innerHTML = T.nav.map((label) =>
    '<li><button type="button" role="link" class="menu-list__item' +
    (label === T.navActive ? ' is-active' : '') + '"' +
    (label === T.navActive ? ' aria-current="page"' : '') + '>' + esc(label) + '</button></li>'
  ).join('');

  $('#thead-row').innerHTML = COLUMNS.map((c) =>
    '<th scope="col" class="tbl-cell' + (c.align === 'right' ? ' tbl-cell--right' : '') +
    '" style="width:' + c.width + 'px">' +
    '<span class="tbl-cell__content">' + esc(c.title) + '</span></th>'
  ).join('');
}

/* ---------- filter chips (Tab/Item --filled) ---------- */
function renderFilters() {
  const n = counts();
  const defs = [
    { key: 'all',      label: T.filters.all,      count: n.total },
    { key: 'onair',    label: T.filters.onair,    count: n.onair },
    { key: 'degraded', label: T.filters.degraded, count: n.degraded },
    { key: 'offline',  label: T.filters.offline,  count: n.offline },
  ];
  $('#filters').innerHTML = defs.map((d) =>
    '<button type="button" role="tab" data-filter="' + d.key + '"' +
    ' aria-selected="' + (activeFilter === d.key) + '"' +
    ' class="tab-item tab-item--filled ' +
    (activeFilter === d.key ? 'tab-item--active' : 'tab-item--normal') + '">' +
    esc(d.label) + '<span class="tab-item__count">' + d.count + '</span></button>'
  ).join('');
}

function counts() {
  return {
    total: SOURCES.length,
    onair: SOURCES.filter((s) => s.state === 'onair').length,
    degraded: SOURCES.filter((s) => s.state === 'degraded').length,
    offline: SOURCES.filter((s) => s.state === 'offline').length,
  };
}

/* ---------- cells ---------- */
function urlCell(s) {
  // M29 Form 2 — genuinely empty value: em dash, placeholder colour, no copy icon.
  if (!s.url) return '<span class="cell-empty">' + T.emptyValue + '</span>';
  // M29 Form 1 (catalog Table §Extension: "URL 列") — text + trailing
  // icon/Edit/Copy. R23 adds tail ellipsis + on-demand tooltip on top, so the
  // field never loses its copy affordance just because it is long.
  return '<span class="tooltip-host">' +
           '<span class="cell-ellipsis js-truncate">' + esc(s.url) + '</span>' +
         '</span>' +
         '<button class="copy-btn js-copy" type="button" data-url="' + esc(s.url) + '">' +
           ico('edit/copy', 'ico--12') +
           '<span class="sr-only">' + esc(T.actions.copyUrl) + '</span>' +
         '</button>';
}

function latencyCell(s) {
  if (s.latency == null) return '<span class="cell-empty">' + T.emptyValue + '</span>';
  // M11 — icon AND number are both coloured; the arrow alone is too weak a
  // signal at 16px on a dark console.
  const map = {
    down: { icon: 'arrow/down', color: 'var(--brand)' },
    up:   { icon: 'arrow/up',   color: 'var(--orange)' },
    flat: { icon: null,         color: 'var(--text-body)' },
  }[s.trend];
  return '<span class="cell-stack">' +
           '<span class="tbl-cell__content" style="color:' + map.color + '">' +
             (map.icon ? '<span class="ico" style="color:' + map.color + '">' + ICON[map.icon] + '</span>' : '') +
             '<span class="cell-num">' + s.latency + ' ms</span>' +
           '</span>' +
           '<span class="cell-sub cell-num">' + esc(s.delta) + '</span>' +
         '</span>';
}

function actionsCell(s) {
  // M7 — actions are the heaviest layer: full-size M buttons, never XS chips.
  // M9 — every label is a literal verb phrase; icons accompany, never replace.
  // catalog Button/dark M §Extension — row-level actions are style=ghost, the
  // colour chosen by row state (red destructive / green start / gray neutral).
  let primary;
  if (s.state === 'offline' && s.offlineKind === 'neverStarted') {
    primary = '<button class="btn btn--ghost-green" type="button" data-act="setup" data-id="' + s.id + '">' +
              esc(T.actions.setup) + '</button>';
  } else if (s.state === 'offline') {
    primary = '<button class="btn btn--ghost-green btn--with-icon" type="button" data-act="start" data-id="' + s.id + '">' +
              ico('video/play') + esc(T.actions.start) + '</button>';
  } else {
    primary = '<button class="btn btn--ghost-red btn--with-icon" type="button" data-act="stop" data-id="' + s.id + '">' +
              ico('video/record-stop') + esc(T.actions.stop) + '</button>';
  }

  const canPreview = s.state === 'onair' || s.state === 'degraded';
  const secondary = '<button class="btn btn--ghost-gray btn--with-icon" type="button"' +
    ' data-act="preview" data-id="' + s.id + '"' + (canPreview ? '' : ' disabled') + '>' +
    ico('preview/main-window') + esc(T.actions.preview) + '</button>';

  // Overflow is a menu TRIGGER, not an action, so an icon-only affordance is
  // correct here; its items are literal verbs (M9). Bare icon button is the
  // sanctioned "not a Button instance" pattern (Figma icon/Edit/Close precedent).
  const more =
    '<span class="row-actions__more">' +
      '<button class="icon-btn js-more" type="button" aria-haspopup="menu" aria-expanded="false" data-id="' + s.id + '">' +
        ico('menu/more') + '<span class="sr-only">' + esc(T.actions.more) + '</span>' +
      '</button>' +
    '</span>';

  return '<span class="row-actions">' + primary + secondary + more + '</span>';
}

function rowHtml(s) {
  const subClass = s.state === 'degraded' ? 'cell-sub cell-sub--warn' : 'cell-sub';
  const subIcon = s.state === 'degraded' ? ico('message/warning-1', 'ico--12') : '';
  const receiver = s.receiver
    ? '<span class="tbl-cell__content">' + ico('input/receiver', 'tbl-cell__icon') +
      '<span class="tooltip-host"><span class="cell-ellipsis js-truncate">' + esc(s.receiver) + '</span></span></span>'
    : '<span class="cell-empty">' + T.emptyValue + '</span>';

  return '<tr class="tbl-row" data-id="' + s.id + '" data-state="' + s.state + '">' +
    '<td class="tbl-cell"><span class="tbl-cell__content">' +
      ico('input/primary-source', 'tbl-cell__icon') +
      '<span class="cell-stack">' +
        '<span class="cell-primary">' + esc(s.name) + '</span>' +
        '<span class="' + subClass + '">' + subIcon + esc(s.sub) + '</span>' +
      '</span></span></td>' +
    '<td class="tbl-cell"><span class="tbl-cell__content">' + urlCell(s) + '</span></td>' +
    '<td class="tbl-cell">' + receiver + '</td>' +
    '<td class="tbl-cell"><span class="tbl-cell__content">' +
      '<span class="badge ' + BADGE_CLASS[s.state] + '">' + esc(T.state[s.state]) + '</span>' +
    '</span></td>' +
    '<td class="tbl-cell"><span class="tbl-cell__content">' + latencyCell(s) + '</span></td>' +
    '<td class="tbl-cell tbl-cell--right">' + actionsCell(s) + '</td>' +
  '</tr>';
}

function visibleRows() {
  const q = query.trim().toLowerCase();
  return SOURCES.filter((s) => {
    const stateOk = activeFilter === 'all' || s.state === activeFilter;
    if (!stateOk) return false;
    if (!q) return true;
    return [s.name, s.url, s.receiver].filter(Boolean).join(' ').toLowerCase().includes(q);
  });
}

function render() {
  const rows = visibleRows();
  const body = $('#tbody');
  body.innerHTML = rows.length
    ? rows.map(rowHtml).join('')
    : '<tr class="tbl-row"><td class="tbl-cell" colspan="' + COLUMNS.length +
      '" style="text-align:center;padding:var(--sp-m) var(--sp-xs);color:var(--text-tips)">' +
      esc(T.noMatch) + '</td></tr>';

  // Zebra is applied as a class rather than :nth-child so filtering never
  // produces two adjacent striped rows.
  Array.prototype.forEach.call(body.querySelectorAll('.tbl-row'), (tr, i) => {
    if (i % 2 === 1) tr.classList.add('is-even');
  });

  const n = counts();
  $('#foot').textContent = T.footer(n.total, n.onair, n.degraded, n.offline);
  renderFilters();
  wireTruncation();
}

/* ==========================================================================
   R23 — truncation detection. Anti-pattern is hanging a tooltip on EVERY
   constrained cell: short values would pop a tooltip for nothing and screen
   readers would read the value twice. So the tooltip is attached only when
   scrollWidth > clientWidth, with BOTH channels wired:
     aria-describedby → the rich tooltip (JS path)
     title            → no-JS / touch fallback
   ResizeObserver re-evaluates when the column width changes.
   ========================================================================== */
let ro = null;
let tipSeq = 0;

function wireTruncation() {
  if (ro) ro.disconnect();
  ro = new ResizeObserver((entries) => entries.forEach((e) => evaluate(e.target)));
  Array.prototype.forEach.call(document.querySelectorAll('.js-truncate'), (el) => {
    evaluate(el);
    ro.observe(el);
  });
}

function evaluate(el) {
  const host = el.closest('.tooltip-host');
  if (!host) return;
  const truncated = el.scrollWidth > el.clientWidth;
  const existing = host.querySelector('.tooltip-box');

  if (!truncated) {
    if (existing) existing.remove();
    el.removeAttribute('title');
    el.removeAttribute('aria-describedby');
    return;
  }
  el.title = el.textContent;                       // fallback channel
  if (existing) return;
  const id = 'tip-' + (++tipSeq);
  el.setAttribute('aria-describedby', id);         // rich channel
  const tip = document.createElement('span');
  tip.className = 'tooltip-box';
  tip.id = id;
  tip.setAttribute('role', 'tooltip');
  tip.hidden = true;
  tip.textContent = el.textContent;
  tip.innerHTML += '<span class="tooltip-arrow"></span>';
  host.appendChild(tip);

  const show = () => { tip.hidden = false; };
  const hide = () => { tip.hidden = true; };
  host.addEventListener('mouseenter', show);
  host.addEventListener('mouseleave', hide);
  el.setAttribute('tabindex', '0');
  el.addEventListener('focus', show);   // keyboard reachable, not hover-only
  el.addEventListener('blur', hide);
}

/* ==========================================================================
   Feedback surfaces (M49 §3 Feedback UI Inventory)
   toast → Notification form=slide, 320px, no-action
   ========================================================================== */
function toast(message, kind) {
  const el = document.createElement('div');
  el.className = 'notif notif--slide notif--type-' + (kind || 'success');
  el.innerHTML =
    '<div class="notif__main">' +
      '<span class="ico ico--24 notif__icon" style="color:var(--brand)">' + ICON['message/success-2'] + '</span>' +
      '<div class="notif__body"><div class="notif__header">' +
        '<p class="notif__title" style="font:var(--text-style-body)">' + esc(message) + '</p>' +
        '<button class="notif__close" type="button">' + ico('edit/close', 'ico--12') +
          '<span class="sr-only">' + esc(T.close) + '</span></button>' +
      '</div></div>' +
    '</div>';
  el.querySelector('.notif__close').addEventListener('click', () => el.remove());
  $('#toast-region').appendChild(el);
  setTimeout(() => el.remove(), 6000);
}

/* ==========================================================================
   Destructive confirmation (requirement 5).
   Behaviour mirrored from PopupBox.vue / Notification.vue:
     · ESC closes · backdrop does NOT close (closeOnBackdrop defaults to false —
       a destructive dialog must not be dismissed by a stray click)
     · focus trap inside the dialog · focus restored to the trigger on close
     · body scroll locked while open
   ========================================================================== */
const FOCUSABLE = 'button:not([disabled]),[href],input:not([disabled]),[tabindex]:not([tabindex="-1"])';
let savedOverflow = '';

function openConfirm(src, trigger) {
  pending = src;
  lastFocused = trigger || null;
  $('#confirm-title').textContent = T.confirm.title;
  $('#confirm-desc').textContent = T.confirm.desc(src.name, src.receiver);
  $('#confirm-overlay').hidden = false;
  savedOverflow = document.body.style.overflow;
  document.body.style.overflow = 'hidden';
  window.addEventListener('keydown', trapKeys);
  requestAnimationFrame(() => {
    const first = $('#confirm-box').querySelectorAll(FOCUSABLE)[0];
    (first || $('#confirm-box')).focus();
  });
}

function closeConfirm() {
  $('#confirm-overlay').hidden = true;
  document.body.style.overflow = savedOverflow;
  window.removeEventListener('keydown', trapKeys);
  pending = null;
  if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
  lastFocused = null;
}

function trapKeys(e) {
  if (e.key === 'Escape') { e.preventDefault(); closeConfirm(); return; }
  if (e.key !== 'Tab') return;
  const els = Array.prototype.slice.call($('#confirm-box').querySelectorAll(FOCUSABLE));
  if (!els.length) { e.preventDefault(); $('#confirm-box').focus(); return; }
  const first = els[0], last = els[els.length - 1];
  if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
  else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}

/* ---------- overflow menu (MenuList --vertical) ---------- */
function closeMenus() {
  Array.prototype.forEach.call(document.querySelectorAll('.menu-panel'), (p) => p.remove());
  Array.prototype.forEach.call(document.querySelectorAll('.js-more'), (b) =>
    b.setAttribute('aria-expanded', 'false'));
}

function openMenu(btn) {
  const wasOpen = btn.getAttribute('aria-expanded') === 'true';
  closeMenus();
  if (wasOpen) return;
  btn.setAttribute('aria-expanded', 'true');
  const panel = document.createElement('div');
  panel.className = 'menu-panel';
  panel.innerHTML = '<ul class="menu-list menu-list--vertical" role="menu">' +
    [T.actions.edit, T.actions.restart, T.actions.remove].map((label) =>
      '<li role="none"><button role="menuitem" type="button" class="menu-list__item">' +
      esc(label) + '</button></li>').join('') + '</ul>';
  btn.parentNode.appendChild(panel);
  panel.querySelector('.menu-list__item').focus();
}

/* ---------- event wiring (single delegated listener) ---------- */
document.addEventListener('click', (e) => {
  const chip = e.target.closest('[data-filter]');
  if (chip) { activeFilter = chip.dataset.filter; render(); return; }

  const more = e.target.closest('.js-more');
  if (more) { openMenu(more); return; }

  const copy = e.target.closest('.js-copy');
  if (copy) {
    if (navigator.clipboard) navigator.clipboard.writeText(copy.dataset.url).catch(() => {});
    toast(T.toast.copied);
    return;
  }

  const act = e.target.closest('[data-act]');
  if (act) {
    const src = SOURCES.find((s) => s.id === act.dataset.id);
    if (!src) return;
    if (act.dataset.act === 'stop') openConfirm(src, act);
    return;
  }

  if (e.target.closest('#refresh-btn')) { render(); toast(T.toast.refreshed); return; }
  if (e.target.closest('#alert-ok')) { $('#degrade-alert').hidden = true; toast(T.toast.promoted); return; }
  if (e.target.closest('#alert-close')) { $('#degrade-alert').hidden = true; return; }
  if (e.target.closest('#confirm-cancel') || e.target.closest('#confirm-close')) { closeConfirm(); return; }

  if (e.target.closest('#confirm-go')) {
    const src = pending;
    closeConfirm();
    if (!src) return;
    // The stop transitions the row to Offline-Idle (M10 sub-type) — the row keeps
    // its history line so the operator can tell "stopped, can restart" apart from
    // "never configured". M12: the result is reported, never silently applied.
    src.state = 'offline';
    src.offlineKind = 'idle';
    src.sub = 'Last ran 11:02 \u2192 ' + nowHM() + ' \u00B7 stopped by operator';
    src.latency = null; src.trend = null; src.delta = null;
    render();
    toast(T.toast.stopped(src.name));
    return;
  }

  if (!e.target.closest('.menu-panel')) closeMenus();
});

// Backdrop click deliberately does nothing (closeOnBackdrop = false).
$('#confirm-overlay').addEventListener('click', (e) => { if (e.target.id === 'confirm-overlay') e.stopPropagation(); });

$('#search').addEventListener('input', (e) => { query = e.target.value; render(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenus(); });

function nowHM() {
  const d = new Date();
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}

renderChrome();
render();
</script>
</body>
</html>
```

---

## Rule checklist（R-DISCIPLINE Acceptance —— 无 TodoWrite 可用，改以文本形式落地）

`R0 ✅ · R2 ✅ · R3 ✅ · R13 ✅ · R14 ✅ · R15 ✅ · R16 ✅ · R19 n/a（无 selected 态，M5 readonly 控制台）· R23 ✅ · M3 ✅ · M4 ✅ · M5 ✅ · M7 ✅ · M9 ✅ · M10 ✅ · M11 ✅（用 --orange 代 doc 里不存在的 --text-warning）· M12 ✅ · M13 n/a（无中断-恢复流程）· M29 Form 1/2 ✅ · M49 lite ✅（正文，未跑 --check）· M-CARDINALITY ✅ · M-IA-FIRST ✅`

**Acceptance criteria（build 自审，三问）**
1. 自画 SVG icon **0 个** —— 17 个图标全部逐字取自 `src/icons/catalog/generated/{arrow,edit,input,logo,menu,message,preview,search,video}.ts`，搜索词与候选集见上方 R13 trace。
2. Hex literal：CSS 里 **0 处**自撰色值 —— token 块是从 `variables.css` 原样搬运的定义本身，其后所有规则只用 `var(--*)`。三个内联 SVG 的 baked hex 中，两个（warning-1 / success-2）已改 `currentColor` 由 token 上色；`logo/tvu` 的 `#56AF31` 是**库资产自带的品牌色、不是 design token**，逐字保留并加注禁止替换。
3. 复合组件 mirror 来源：Button ← `components/Button/Button.vue` 的 `SIZE_GEOMETRY.M` + `PALETTE_SPEC`；Table ← `components/Table/Table.vue`（含 INFRA-F129① 的 `.tbl td{border-top:0}`）；Notification ← `components/Notification/Notification.vue`（pop confirm 无 category icon、desc 用 `--text-body`、danger→红实心）；TopBar / MenuList / TabItem / Input / Tooltip / Badge 同法。API 差异只有一处：`.field` 给 Input 加了 `padding-inline-start: var(--sp-xl)` 以容纳 leading icon（Input.vue 无 icon slot），已就地注明。

## 三条应该进 backlog 的实测发现（不是本次交付的一部分）

1. **`--text-warning` 在文档里被引用、在 token 里不存在** —— `domain-tvu.md` M11 与 `--prompt-warning-text` 各说一套；判据是 `grep -n 'text-warning' src/tokens/variables.css` 为空。
2. **canonical Table 的 hover 在偶数行不可见** —— `.tbl-body .tbl-row:hover` 与 `.tbl--striped …:nth-child(even)` 同为 `--bg-layer3`。这条被 `Type=Tbody` 自动开 striped 放大（catalog 建议 operator console 就用 Tbody）。
3. **`Pill Status` 没有 warning 成员** —— 运营台三态里的「告警」在这套库里只能落到 `Badge` 的 Orange 上。如果 `Pill Status` 是运营台状态列的目标组件，那 Figma 侧缺一个成员；这属于 designer 裁定，不该由 code 自创 variant 绕过（硬规则 #3）。

**这份稿子我没跑过任何闸** —— `audit:no-hardcoded-design-tokens`、`audit:consumer-code`（R14/R19/R9 probe）、`design:kickoff --check` 都需要 Bash/Write。上面三条 Acceptance 的答案是我逐条对活源人工核的，不是机检结果。