# ECharts 迁移 + 双框架 demo 重做 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 把 Chart 组件内部引擎从 chart.js 迁到 ECharts(按需 + SVG),并把双框架 demo 展示页重做成零硬编码 + 全变体 + Vue/React 对等。

**Architecture:** Chart.vue 内部用 `echarts/core` 手动 init(显式尺寸,治当前 canvas 溢出 bug),props→option 抽成纯函数 `buildChartOption`;对外 API/类型不变 → WC 注册、React wrapper、桥全部零改动。Demo 页全部改用设计系统 CSS token。

**Tech Stack:** Vue 3 SFC · echarts(core 按需 + SVGRenderer) · vitest · Playwright · Web Components(defineCustomElement) · React 19 wrapper。

## Global Constraints

- 对外 API 不变:Chart props `type / datasets / labels / width / height`,类型 `ChartType`(`'pie'|'donut'|'line'|'bar'|'bar-horizontal'|'line-bar'`)/ `ChartDatasetInput`(`{ label: string; data: number[]; type?: 'line'|'bar'; color?: string }`) 原样。
- 不改:`src/canonical/Chart.vue` · `src/web-components/components.config.ts` 的 Chart 条目 · `react-pilot/src/wrappers/Chart.tsx` · React 组件代码。
- 零字面 hex(AGENTS 硬规则 #4):组件与 demo 里禁写死颜色;hex fallback 只在 `src/components/Chart/use-chart-tokens.ts`。
- 配色沿用现有 12 色 token 板 `--chart-color-1..12`(theme-aware),不引入新色。
- echarts 只按需子路径 import,禁 `import * from 'echarts'` 全量。
- owner 直 commit master + push origin(origin 同时推 Gitea + GitHub);non-breaking → 无需 major changeset。

---

## 文件结构

- `src/components/Chart/build-chart-option.ts`(新建):纯函数 `buildChartOption` + 类型 `ChartType`/`ChartDatasetInput`(从 Chart.vue 移出为单一真源)。
- `src/components/Chart/Chart.vue`(重写):echarts/core init/resize/theme/dispose,消费 `buildChartOption`。
- `src/components/Chart/use-chart-tokens.ts`(不变,复用)。
- `tests/Chart.test.ts`(新建):`buildChartOption` 纯函数单测。
- `package.json`(改):移除 chart.js/vue-chartjs,加 echarts。
- `playground/docs/pages/ChartPage.vue`(改):token 化 + 6 type 全展示。
- `react-pilot/src/App.tsx`(重写):token 化 + 全变体 + 修 Badge 用法 + 对等。

---

## Task 1: 依赖切换 + 按需 registerCharts

**Files:**
- Modify: `package.json:145,147,159,169`(移除 chart.js/vue-chartjs 全部条目;新增 `"echarts": "^5.5.0"` 到 dependencies)

**Interfaces:**
- Produces: `echarts` 可用;`echarts/core`、`echarts/charts`、`echarts/components`、`echarts/renderers` 子路径可 import。

- [ ] **Step 1: 改依赖**

在 `package.json` 删除所有 `"chart.js": "^4.4.0"` 与 `"vue-chartjs": "^5.3.0"` 行(dependencies + devDependencies 两处),在 dependencies 加:
```json
"echarts": "^5.5.0",
```

- [ ] **Step 2: 安装**

Run: `pnpm install`
Expected: 成功;`node_modules/echarts` 存在;`pnpm ls chart.js vue-chartjs` 显示无。

- [ ] **Step 3: 全仓无残留 import 核验**

Run: `grep -rnE "from 'chart\.js'|from 'vue-chartjs'|'chart\.js'|vue-chartjs" src/ playground/ react-pilot/`
Expected: 只剩 `src/components/Chart/Chart.vue` 里旧 import(下一 task 删),无其它文件残留。

- [ ] **Step 4: Commit**

```bash
git add package.json pnpm-lock.yaml
git commit -m "chore(chart): swap chart.js/vue-chartjs → echarts dependency"
```

---

## Task 2: buildChartOption 纯函数 + 单测(TDD)

**Files:**
- Create: `src/components/Chart/build-chart-option.ts`
- Create: `tests/Chart.test.ts`

**Interfaces:**
- Produces:
  - `type ChartType = 'pie' | 'donut' | 'line' | 'bar' | 'bar-horizontal' | 'line-bar'`
  - `interface ChartDatasetInput { label: string; data: number[]; type?: 'line' | 'bar'; color?: string }`
  - `interface BuildOptionCtx { palette: string[]; legendColor: string; fontFamily: string }`
  - `function buildChartOption(props: { type: ChartType; datasets: ChartDatasetInput[]; labels: string[] }, ctx: BuildOptionCtx): Record<string, any>`

- [ ] **Step 1: 写失败测试**

`tests/Chart.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import { buildChartOption } from '../src/components/Chart/build-chart-option'

const ctx = { palette: ['#c1', '#c2', '#c3'], legendColor: '#leg', fontFamily: 'Roboto' }
const ds = [{ label: 'A', data: [1, 2, 3] }]

describe('buildChartOption', () => {
  it('pie → single pie series, data mapped to {value,name} via labels', () => {
    const o: any = buildChartOption({ type: 'pie', datasets: ds, labels: ['x', 'y', 'z'] }, ctx)
    expect(o.series[0].type).toBe('pie')
    expect(o.series[0].radius).toBe('70%')
    expect(o.series[0].data).toEqual([{ value: 1, name: 'x' }, { value: 2, name: 'y' }, { value: 3, name: 'z' }])
    expect(o.color).toBe(ctx.palette)
  })

  it('donut → pie series with ring radius', () => {
    const o: any = buildChartOption({ type: 'donut', datasets: ds, labels: ['x', 'y', 'z'] }, ctx)
    expect(o.series[0].type).toBe('pie')
    expect(o.series[0].radius).toEqual(['40%', '70%'])
  })

  it('bar → category xAxis + value yAxis + bar series', () => {
    const o: any = buildChartOption({ type: 'bar', datasets: ds, labels: ['x', 'y', 'z'] }, ctx)
    expect(o.xAxis.type).toBe('category')
    expect(o.xAxis.data).toEqual(['x', 'y', 'z'])
    expect(o.yAxis.type).toBe('value')
    expect(o.series[0].type).toBe('bar')
    expect(o.series[0].data).toEqual([1, 2, 3])
  })

  it('bar-horizontal → axes swapped (value x / category y)', () => {
    const o: any = buildChartOption({ type: 'bar-horizontal', datasets: ds, labels: ['x', 'y', 'z'] }, ctx)
    expect(o.xAxis.type).toBe('value')
    expect(o.yAxis.type).toBe('category')
    expect(o.series[0].type).toBe('bar')
  })

  it('line → line series on category axis', () => {
    const o: any = buildChartOption({ type: 'line', datasets: ds, labels: ['x'] }, ctx)
    expect(o.series[0].type).toBe('line')
  })

  it('line-bar → per-dataset type decides line vs bar', () => {
    const mixed = [{ label: 'L', data: [1], type: 'line' as const }, { label: 'B', data: [2], type: 'bar' as const }]
    const o: any = buildChartOption({ type: 'line-bar', datasets: mixed, labels: ['x'] }, ctx)
    expect(o.series[0].type).toBe('line')
    expect(o.series[1].type).toBe('bar')
  })

  it('legend uses circle icon 8px + token colour + font', () => {
    const o: any = buildChartOption({ type: 'bar', datasets: ds, labels: ['x'] }, ctx)
    expect(o.legend.icon).toBe('circle')
    expect(o.legend.itemWidth).toBe(8)
    expect(o.legend.textStyle.color).toBe('#leg')
    expect(o.legend.textStyle.fontFamily).toBe('Roboto')
    expect(o.legend.textStyle.fontSize).toBe(12)
  })

  it('dataset.color overrides palette for that series', () => {
    const o: any = buildChartOption({ type: 'bar', datasets: [{ label: 'A', data: [1], color: '#zzz' }], labels: ['x'] }, ctx)
    expect(o.series[0].itemStyle.color).toBe('#zzz')
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/Chart.test.ts`
Expected: FAIL(`buildChartOption` 未定义 / 模块不存在)。

- [ ] **Step 3: 写实现**

`src/components/Chart/build-chart-option.ts`:
```ts
export type ChartType = 'pie' | 'donut' | 'line' | 'bar' | 'bar-horizontal' | 'line-bar'

export interface ChartDatasetInput {
  label: string
  data: number[]
  type?: 'line' | 'bar'
  color?: string
}

export interface BuildOptionCtx {
  palette: string[]
  legendColor: string
  fontFamily: string
}

interface BuildProps {
  type: ChartType
  datasets: ChartDatasetInput[]
  labels: string[]
}

export function buildChartOption(props: BuildProps, ctx: BuildOptionCtx): Record<string, any> {
  const legend = {
    bottom: 0,
    icon: 'circle',
    itemWidth: 8,
    itemHeight: 8,
    textStyle: { color: ctx.legendColor, fontFamily: ctx.fontFamily, fontSize: 12 },
  }

  if (props.type === 'pie' || props.type === 'donut') {
    const first = props.datasets[0]
    return {
      color: ctx.palette,
      legend,
      tooltip: { trigger: 'item' },
      series: [{
        type: 'pie',
        radius: props.type === 'donut' ? ['40%', '70%'] : '70%',
        data: (first?.data ?? []).map((value, i) => ({ value, name: props.labels[i] ?? String(i) })),
      }],
    }
  }

  const isHorizontal = props.type === 'bar-horizontal'
  const categoryAxis = { type: 'category', data: props.labels }
  const valueAxis = { type: 'value' }

  const series = props.datasets.map((d) => {
    const seriesType =
      props.type === 'line' ? 'line'
      : props.type === 'bar' || props.type === 'bar-horizontal' ? 'bar'
      : (d.type ?? 'bar')
    return {
      name: d.label,
      type: seriesType,
      data: d.data,
      ...(d.color ? { itemStyle: { color: d.color } } : {}),
    }
  })

  return {
    color: ctx.palette,
    legend,
    tooltip: { trigger: 'axis' },
    xAxis: isHorizontal ? valueAxis : categoryAxis,
    yAxis: isHorizontal ? categoryAxis : valueAxis,
    series,
  }
}
```

- [ ] **Step 4: 跑测试确认通过**

Run: `pnpm vitest run tests/Chart.test.ts`
Expected: PASS(9 tests)。

- [ ] **Step 5: Commit**

```bash
git add src/components/Chart/build-chart-option.ts tests/Chart.test.ts
git commit -m "feat(chart): buildChartOption pure mapper + unit tests (6 types)"
```

---

## Task 3: Chart.vue 重写(echarts/core + SVG + shadow-safe init/resize/theme)

**Files:**
- Modify: `src/components/Chart/Chart.vue`(整体重写 `<script setup>` + template + style)

**Interfaces:**
- Consumes: `buildChartOption`, `ChartType`, `ChartDatasetInput`(Task 2);`resolveChartPalette`/`resolveCssVar`/`resolveLegendColor`(use-chart-tokens,已存在)。
- Produces: `<Chart>` 组件,props 签名不变;内部 SVG 渲染,尺寸精确 = props.width×height。

- [ ] **Step 1: 重写 Chart.vue**

关键点:内层 chart 容器精确 `width×height`;外层 `.tvu-chart` **不设固定 width/height**(去掉旧的 `:style` 尺寸绑定),只保留 padding/radius/bg,由内层撑开 → 彻底不溢出(治当前 bug)。

`src/components/Chart/Chart.vue`:
```vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts/core'
import { PieChart, BarChart, LineChart } from 'echarts/charts'
import { LegendComponent, TooltipComponent, GridComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import { resolveChartPalette, resolveCssVar, resolveLegendColor } from './use-chart-tokens'
import { buildChartOption, type ChartType, type ChartDatasetInput } from './build-chart-option'

echarts.use([PieChart, BarChart, LineChart, LegendComponent, TooltipComponent, GridComponent, SVGRenderer])

export type { ChartType, ChartDatasetInput }

const props = withDefaults(defineProps<{
  type: ChartType
  datasets: ChartDatasetInput[]
  labels?: string[]
  height?: number
  width?: number
}>(), {
  labels: () => [],
  height: 200,
  width: 320,
})

const chartRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
let observer: MutationObserver | null = null

function currentCtx() {
  return {
    palette: resolveChartPalette(),
    legendColor: resolveLegendColor(),
    fontFamily: resolveCssVar('--font-family-base', 'Roboto, sans-serif'),
  }
}

function render() {
  if (!chart) return
  chart.setOption(
    buildChartOption(
      { type: props.type, datasets: props.datasets, labels: props.labels },
      currentCtx(),
    ),
    true,
  )
}

onMounted(() => {
  if (!chartRef.value) return
  chart = echarts.init(chartRef.value, null, {
    renderer: 'svg',
    width: props.width,
    height: props.height,
  })
  render()
  observer = new MutationObserver(() => render())
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
})

watch(() => [props.type, props.datasets, props.labels], render, { deep: true })
watch(() => [props.width, props.height], () => {
  chart?.resize({ width: props.width, height: props.height })
})

onUnmounted(() => {
  chart?.dispose()
  chart = null
  observer?.disconnect()
})
</script>

<template>
  <div
    class="tvu-chart"
    role="img"
    :aria-label="($attrs['aria-label'] as string | undefined) ?? `${type} chart`"
  >
    <div ref="chartRef" class="tvu-chart__view" :style="{ width: `${width}px`, height: `${height}px` }" />
  </div>
</template>

<style scoped>
.tvu-chart {
  position: relative;
  display: inline-block;
  box-sizing: border-box;
  padding: var(--sp-m);
  border-radius: var(--r-m);
  background: var(--bg-layer3);
}
.tvu-chart__view {
  display: block;
}
</style>
```

- [ ] **Step 2: 类型检查**

Run: `pnpm exec vue-tsc --noEmit`
Expected: 0 error。

- [ ] **Step 3: 跑既有单测(确保 buildChartOption 仍绿 + 无破坏)**

Run: `pnpm vitest run`
Expected: 全绿(含 Task 2 的 9 条 Chart.test)。

- [ ] **Step 4: Commit**

```bash
git add src/components/Chart/Chart.vue
git commit -m "feat(chart): rewrite Chart.vue on echarts/core (SVG, explicit-size init, shadow-safe theme)"
```

---

## Task 4: WC build + 双框架渲染验证(6 type 不溢出 + cross-gate 零回归)

**Files:**
- 无源码改动(验证 task);若发现问题回到 Task 3。

**Interfaces:**
- Consumes: 重写后的 Chart.vue(Task 3)。

- [ ] **Step 1: 重建 WC 产物**

Run: `pnpm build`(生成 `dist-wc/tvu-web-components.js`——react-pilot 经 vite alias 消费它)
Expected: 构建成功,Chart chunk 重新生成。

- [ ] **Step 2: render-verification + drift-gate(Vue 侧 wrapper gate 零回归)**

Run: `pnpm test:render-verification && pnpm audit:render-drift-gate`
Expected: PASS,A_TRUE_DRIFT=0(Chart wrapper 的 box/padding/radius/bg 仍达标)。

- [ ] **Step 3: React cross-gate 零回归**

Run: `pnpm test:render-verification-react`
Expected: PASS(API 未变,Chart 条目应与 Vue 侧逐条等价)。

- [ ] **Step 4: 双框架 playwright 截图实测(人工核验不溢出)**

起 Vue docs(`pnpm dev`)与 react-pilot(`cd react-pilot && pnpm dev`,注意端口错开 `--port` 避免 5173 race),playwright 截 ChartPage 6 type + react-pilot Data section Chart。
Expected: 6 种图全部**在 `.tvu-chart` 容器内、不溢出、图例显示**;React 侧 pie 不再撑破卡片。将截图存 scratchpad 供 review。

- [ ] **Step 5: 全 audit 绿**

Run: `pnpm audit:self-audit-phase2 && pnpm audit:binding-config-parity`
Expected: 全 PASS。

- [ ] **Step 6: Commit(若 build 产物纳入版本管理则带上)**

```bash
git add -A
git commit -m "test(chart): verify echarts migration — dual-framework no-overflow + cross-gate zero regression"
```

---

## Task 5: Vue demo(ChartPage)token 化 + 6 type 全展示

**Files:**
- Modify: `playground/docs/pages/ChartPage.vue`

**Interfaces:**
- Consumes: 迁移后的 Chart(Task 3)。

- [ ] **Step 1: 硬编码 → token 替换**

通读 `ChartPage.vue`,把内联/style 里的硬编码值按下表换成 token(无 fallback hex):

| 硬编码类别 | 换成 |
|---|---|
| 背景色(#1c1c1c/#141414 等) | `var(--bg-layer1/2/3)` |
| 边框/分隔线色 | `var(--line-border)` / `var(--line-deep)` |
| 文本色 | `var(--text-body)` / `var(--text-2)` |
| 间距(px 字面量) | 最近的 `var(--sp-xxs..xxxl)` |
| 圆角(px 字面量) | `var(--r-xs..xxl)` |
| 字体 | `var(--font-family-base)` |
| 页面级布局值(无组件语义) | 套最近 `--sp-*`,实在无则注释 `/* demo layout, not a component token */` |

- [ ] **Step 2: 补全 6 type 展示**

确保 `pie / donut / line / bar / bar-horizontal / line-bar` 六种各有一个 `<Chart>` 实例 + 说明,数据用示例集。

- [ ] **Step 3: 零硬编码自查**

Run: `grep -nE "#[0-9a-fA-F]{3,6}|: ?[0-9]+px" playground/docs/pages/ChartPage.vue`
Expected: 仅剩带 `demo layout` 注释的极少数布局值;无颜色 hex。

- [ ] **Step 4: 页面渲染核验**

`pnpm dev` + playwright 截 ChartPage,确认 6 type 正常、配色随主题切换。

- [ ] **Step 5: Commit**

```bash
git add playground/docs/pages/ChartPage.vue
git commit -m "feat(demo): tokenize ChartPage + show all 6 chart types"
```

---

## Task 6: React demo(App.tsx)重写 — token 化 + 全变体 + 修 Badge + 对等

**Files:**
- Modify: `react-pilot/src/App.tsx`(重写当前 dirty 版本)

**Interfaces:**
- Consumes: 29 个 wrapper(已入库)+ 迁移后 Chart。

- [ ] **Step 1: Section/Card 脚手架 token 化**

把 Section/Card 里 inline style 的硬编码换 token(去 fallback hex)。示例:
```tsx
function Card({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-s)', padding: 'var(--sp-m)', borderRadius: 'var(--r-m)', border: '1px solid var(--line-border)', background: 'var(--bg-layer2)', minWidth: 'var(--sp-xxxl)' /* demo layout */ }}>
      <span style={{ fontSize: 11, opacity: 0.55, fontFamily: 'var(--font-family-base)' }}>{label}</span>
      <div style={{ display: 'flex', alignItems: 'center', minHeight: 32, flexWrap: 'wrap', gap: 'var(--sp-s)' }}>{children}</div>
    </div>
  )
}
```
页面根、header 同理:`background: 'var(--bg-layer1)'`、`color: 'var(--text-body)'`、`borderBottom: '1px solid var(--line-border)'`,均去掉 fallback hex。

- [ ] **Step 2: 修 Badge 用法(不传 children)**

Badge 内容组件内部固定生成,**不传文字**;铺 color × tag × type 变体:
```tsx
<Card label="Badge — Circle"><Badge color="Green" tag="Filled" type="Circle" /><Badge color="Red" tag="Filled" type="Circle" /><Badge color="Orange" tag="Line" type="Circle" /></Card>
<Card label="Badge — Rectangle"><Badge color="Blue" tag="Filled" type="Rectangle" /><Badge color="Neutral" tag="Line" type="Rectangle" /></Card>
```

- [ ] **Step 3: 补全关键组件变体矩阵**

- Message:`success / info / error / warning`(size L)各一;
- Button:含 `status="loading"` + 各 color/variant/radius 代表组合;
- 其余组件按其 axis 铺有代表性的变体(Switch on/off、CheckBox/Radio on/off、Progress 不同 value 等)。

- [ ] **Step 4: 零硬编码自查**

Run: `grep -nE "#[0-9a-fA-F]{3,6}" react-pilot/src/App.tsx`
Expected: 无颜色 hex(布局 px 允许但需 `demo layout` 注释)。

- [ ] **Step 5: 渲染核验**

`cd react-pilot && pnpm dev` + playwright 截全页,确认 Badge 不溢出、Chart 正常、变体齐全、theme toggle 生效。

- [ ] **Step 6: Commit**

```bash
git add react-pilot/src/App.tsx
git commit -m "feat(demo): rewrite react-pilot showcase — tokenized, full variants, Badge usage fix"
```

---

## Task 7: 双框架对等收口 + 入库验证

**Files:** 无源码改动(验证 + 收口)。

- [ ] **Step 1: 双侧截图对等核验**

Vue docs + react-pilot 各截图,人工核对同组件变体两侧一致(尤其 Chart 6 type、Badge、Message 四态)。

- [ ] **Step 2: 全 gate 终检**

Run: `pnpm exec vue-tsc --noEmit && pnpm vitest run && pnpm test:render-verification && pnpm audit:render-drift-gate && pnpm test:render-verification-react && pnpm audit:self-audit-phase2`
Expected: 全绿。

- [ ] **Step 3: chart.js 彻底清零核验**

Run: `grep -rnE "chart\.js|vue-chartjs" src/ playground/ react-pilot/ package.json`
Expected: 无任何残留。

- [ ] **Step 4: owner ack 后推双 remote**

```bash
git push origin HEAD
```
Expected: Gitea + GitHub 均更新。

- [ ] **Step 5: 更新 STATUS.md**

在 `docs/STATUS.md` 顶部记本次 arc(ECharts 迁移 + demo 重做 + Chart bug 根因 + gate 结果),"Last updated" 改当天;commit + push。

---

## Self-Review(plan 作者自查)

- **Spec 覆盖**:WS-A(依赖 T1 / 纯函数 T2 / Chart.vue T3 / 验证 T4)+ WS-B(Vue demo T5 / React demo T6 / 对等收口 T7)全覆盖;SVG renderer(T3 echarts.use SVGRenderer)、shadow init/resize/theme(T3)、token 色板复用(T2/T3 走 use-chart-tokens)、零 hex(T2/T3/T5/T6 自查 grep)、cross-gate 零回归(T4)、canvas gate 缺口(SVG 使 T4 可验)、Badge 修正(T6)均有落点。
- **Placeholder 扫描**:无 TBD/TODO;所有 code step 附完整代码;grep/命令附预期。
- **类型一致性**:`ChartType`/`ChartDatasetInput`/`BuildOptionCtx`/`buildChartOption` 签名在 T2 定义、T3 消费,名称一致;`buildChartOption(props, ctx)` 两参签名前后统一。
- **spec §5 新增 gate(SVG root 尺寸 ≤ 容器断言)**:归入 T4 Step 4 人工截图核验;若要脚本化断言可作为后续增强(未阻塞主线,标注于此)。
