@velkymx/vibeui 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/AGENTS.md +44 -0
  2. package/CLAUDE.md +48 -0
  3. package/README.md +222 -197
  4. package/dist/App.vue.d.ts +2 -1
  5. package/dist/components/HelloWorld.vue.d.ts +2 -1
  6. package/dist/components/VibeAccordion.vue.d.ts +39 -40
  7. package/dist/components/VibeAlert.vue.d.ts +21 -37
  8. package/dist/components/VibeAutocomplete.vue.d.ts +59 -31
  9. package/dist/components/VibeBadge.vue.d.ts +10 -18
  10. package/dist/components/VibeBreadcrumb.vue.d.ts +25 -21
  11. package/dist/components/VibeButton.vue.d.ts +13 -17
  12. package/dist/components/VibeButtonGroup.vue.d.ts +11 -19
  13. package/dist/components/VibeCard.vue.d.ts +51 -24
  14. package/dist/components/VibeCarousel.vue.d.ts +27 -49
  15. package/dist/components/VibeChartBar.vue.d.ts +61 -0
  16. package/dist/components/VibeChartLine.vue.d.ts +70 -0
  17. package/dist/components/VibeChartPie.vue.d.ts +34 -0
  18. package/dist/components/VibeCloseButton.vue.d.ts +9 -7
  19. package/dist/components/VibeCol.vue.d.ts +9 -17
  20. package/dist/components/VibeCollapse.vue.d.ts +24 -32
  21. package/dist/components/VibeContainer.vue.d.ts +9 -17
  22. package/dist/components/VibeDataTable.vue.d.ts +122 -63
  23. package/dist/components/VibeDatePicker.vue.d.ts +4 -6
  24. package/dist/components/VibeDraggable.vue.d.ts +9 -13
  25. package/dist/components/VibeDropdown.vue.d.ts +46 -43
  26. package/dist/components/VibeDroppable.vue.d.ts +9 -13
  27. package/dist/components/VibeFileInput.vue.d.ts +14 -20
  28. package/dist/components/VibeFormCheckbox.vue.d.ts +30 -22
  29. package/dist/components/VibeFormDatepicker.vue.d.ts +27 -30
  30. package/dist/components/VibeFormErrorSummary.vue.d.ts +17 -0
  31. package/dist/components/VibeFormGroup.vue.d.ts +10 -13
  32. package/dist/components/VibeFormInput.vue.d.ts +60 -25
  33. package/dist/components/VibeFormRadio.vue.d.ts +23 -24
  34. package/dist/components/VibeFormSelect.vue.d.ts +28 -34
  35. package/dist/components/VibeFormSpinbutton.vue.d.ts +31 -34
  36. package/dist/components/VibeFormSwitch.vue.d.ts +22 -23
  37. package/dist/components/VibeFormTextarea.vue.d.ts +24 -25
  38. package/dist/components/VibeFormWysiwyg.vue.d.ts +23 -22
  39. package/dist/components/VibeHero.vue.d.ts +117 -0
  40. package/dist/components/VibeIcon.vue.d.ts +27 -7
  41. package/dist/components/VibeInputGroup.vue.d.ts +12 -14
  42. package/dist/components/VibeLink.vue.d.ts +14 -18
  43. package/dist/components/VibeListGroup.vue.d.ts +26 -22
  44. package/dist/components/VibeModal.vue.d.ts +47 -45
  45. package/dist/components/VibeNav.vue.d.ts +40 -20
  46. package/dist/components/VibeNavbar.vue.d.ts +12 -19
  47. package/dist/components/VibeNavbarBrand.vue.d.ts +9 -17
  48. package/dist/components/VibeNavbarNav.vue.d.ts +52 -33
  49. package/dist/components/VibeNavbarToggle.vue.d.ts +7 -5
  50. package/dist/components/VibeOffcanvas.vue.d.ts +28 -43
  51. package/dist/components/VibePagination.vue.d.ts +38 -29
  52. package/dist/components/VibePlaceholder.vue.d.ts +9 -17
  53. package/dist/components/VibePopover.vue.d.ts +17 -40
  54. package/dist/components/VibeProgress.vue.d.ts +13 -21
  55. package/dist/components/VibeResizable.vue.d.ts +10 -14
  56. package/dist/components/VibeRow.vue.d.ts +9 -17
  57. package/dist/components/VibeScrollspy.vue.d.ts +37 -25
  58. package/dist/components/VibeSkeleton.vue.d.ts +2 -1
  59. package/dist/components/VibeSlider.vue.d.ts +4 -5
  60. package/dist/components/VibeSortable.vue.d.ts +37 -18
  61. package/dist/components/VibeSpinner.vue.d.ts +4 -7
  62. package/dist/components/VibeStepper.vue.d.ts +32 -31
  63. package/dist/components/VibeTab.vue.d.ts +7 -11
  64. package/dist/components/VibeTabContent.vue.d.ts +13 -31
  65. package/dist/components/VibeTabs.vue.d.ts +9 -13
  66. package/dist/components/VibeToast.vue.d.ts +31 -44
  67. package/dist/components/VibeToastHost.vue.d.ts +2 -1
  68. package/dist/components/VibeTooltip.vue.d.ts +16 -39
  69. package/dist/components/chart/chartColors.d.ts +3 -0
  70. package/dist/components/chart/chartResize.d.ts +2 -0
  71. package/dist/components/chart/chartTooltip.d.ts +2 -0
  72. package/dist/components/chart/chartTypes.d.ts +6 -0
  73. package/dist/components/chart/drawBar.d.ts +5 -0
  74. package/dist/components/chart/drawLine.d.ts +18 -0
  75. package/dist/components/chart/drawPie.d.ts +4 -0
  76. package/dist/components/dndStore.d.ts +1 -0
  77. package/dist/components/index.d.ts +6 -1
  78. package/dist/composables/useBreakpoints.d.ts +1 -0
  79. package/dist/composables/useColorMode.d.ts +1 -1
  80. package/dist/composables/useId.d.ts +1 -4
  81. package/dist/directives/vTooltip.d.ts +2 -3
  82. package/dist/injectionKeys.d.ts +34 -0
  83. package/dist/purify.es-eYghtX9j.mjs +735 -0
  84. package/dist/types.d.ts +23 -2
  85. package/dist/utils/routeKey.d.ts +11 -0
  86. package/dist/utils/safeCss.d.ts +16 -0
  87. package/dist/utils/safeHref.d.ts +7 -0
  88. package/dist/utils/sanitizeHtml.d.ts +13 -0
  89. package/dist/vibeui.css +2 -1
  90. package/dist/vibeui.es.js +8527 -5855
  91. package/dist/vibeui.umd.js +3 -1
  92. package/docs/README.md +225 -0
  93. package/docs/components/advanced/popover.md +125 -0
  94. package/docs/components/advanced/scrollspy.md +106 -0
  95. package/docs/components/advanced/tooltip.md +90 -0
  96. package/docs/components/card/card.md +223 -0
  97. package/docs/components/charts/chart-bar.md +129 -0
  98. package/docs/components/charts/chart-line.md +136 -0
  99. package/docs/components/charts/chart-pie.md +102 -0
  100. package/docs/components/core/alert.md +94 -0
  101. package/docs/components/core/badge.md +75 -0
  102. package/docs/components/core/button-group.md +99 -0
  103. package/docs/components/core/button.md +145 -0
  104. package/docs/components/core/close-button.md +82 -0
  105. package/docs/components/core/link.md +79 -0
  106. package/docs/components/core/placeholder.md +129 -0
  107. package/docs/components/core/skeleton.md +40 -0
  108. package/docs/components/core/spinner.md +103 -0
  109. package/docs/components/data/datatable.md +437 -0
  110. package/docs/components/hero/hero.md +65 -0
  111. package/docs/components/interactive/accordion.md +121 -0
  112. package/docs/components/interactive/carousel.md +112 -0
  113. package/docs/components/interactive/collapse.md +106 -0
  114. package/docs/components/interactive/date-picker.md +78 -0
  115. package/docs/components/interactive/draggable.md +91 -0
  116. package/docs/components/interactive/dropdown.md +129 -0
  117. package/docs/components/interactive/modal.md +151 -0
  118. package/docs/components/interactive/offcanvas.md +92 -0
  119. package/docs/components/interactive/resizable.md +73 -0
  120. package/docs/components/interactive/slider.md +57 -0
  121. package/docs/components/interactive/sortable.md +52 -0
  122. package/docs/components/interactive/stepper.md +83 -0
  123. package/docs/components/interactive/tabs.md +66 -0
  124. package/docs/components/interactive/toast.md +177 -0
  125. package/docs/components/layout/col.md +117 -0
  126. package/docs/components/layout/container.md +53 -0
  127. package/docs/components/layout/row.md +107 -0
  128. package/docs/components/list/list-group.md +225 -0
  129. package/docs/components/navigation/breadcrumb.md +120 -0
  130. package/docs/components/navigation/nav.md +151 -0
  131. package/docs/components/navigation/navbar.md +114 -0
  132. package/docs/components/navigation/pagination.md +163 -0
  133. package/docs/components/progress/progress.md +188 -0
  134. package/docs/composables/back-button.md +28 -0
  135. package/docs/composables/breakpoints.md +54 -0
  136. package/docs/composables/color-mode.md +179 -0
  137. package/docs/composables/use-form.md +78 -0
  138. package/docs/composables/use-position.md +68 -0
  139. package/docs/composables/use-toast.md +91 -0
  140. package/docs/directives/v-tooltip.md +58 -0
  141. package/docs/forms/README.md +87 -0
  142. package/docs/forms/autocomplete.md +96 -0
  143. package/docs/forms/file-input.md +97 -0
  144. package/docs/forms/form-checkbox.md +100 -0
  145. package/docs/forms/form-datepicker.md +80 -0
  146. package/docs/forms/form-error-summary.md +96 -0
  147. package/docs/forms/form-group.md +118 -0
  148. package/docs/forms/form-input.md +140 -0
  149. package/docs/forms/form-radio.md +82 -0
  150. package/docs/forms/form-select.md +98 -0
  151. package/docs/forms/form-spinbutton.md +94 -0
  152. package/docs/forms/form-switch.md +75 -0
  153. package/docs/forms/form-textarea.md +82 -0
  154. package/docs/forms/form-wysiwyg.md +99 -0
  155. package/docs/forms/input-group.md +70 -0
  156. package/docs/forms/validation.md +213 -0
  157. package/docs/getting-started/starter-template.md +136 -0
  158. package/docs/superpowers/plans/2026-05-12-vibe-charts.md +1999 -0
  159. package/docs/superpowers/plans/2026-05-29-e2e-browser-testing.md +615 -0
  160. package/docs/superpowers/specs/2026-05-12-charting-design.md +185 -0
  161. package/docs/superpowers/specs/2026-05-29-e2e-browser-testing-design.md +168 -0
  162. package/docs/utilities/position.md +82 -0
  163. package/docs/versioning.md +57 -0
  164. package/examples/README.md +201 -0
  165. package/examples/album.html +262 -0
  166. package/examples/blog.html +166 -0
  167. package/examples/carousel.html +114 -0
  168. package/examples/checkout.html +275 -0
  169. package/examples/cover.html +89 -0
  170. package/examples/dashboard.html +156 -0
  171. package/examples/index.html +129 -0
  172. package/examples/jumbotron.html +56 -0
  173. package/examples/mobile-dashboard.html +138 -0
  174. package/examples/pricing.html +172 -0
  175. package/examples/product.html +145 -0
  176. package/examples/sidebars.html +166 -0
  177. package/examples/sign-in.html +119 -0
  178. package/examples/starter.html +314 -0
  179. package/examples/sticky-footer-navbar.html +70 -0
  180. package/examples/sticky-footer.html +60 -0
  181. package/examples/test-simple.html +91 -0
  182. package/llms.txt +801 -0
  183. package/package.json +44 -14
  184. package/dist/types/index.d.ts +0 -6
@@ -0,0 +1,1999 @@
1
+ # VibeChart Components Implementation Plan
2
+
3
+ > **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.
4
+
5
+ **Goal:** Add `VibeChartLine`, `VibeChartBar`, `VibeChartPie` — canvas-based, Bootstrap-themed, responsive via ResizeObserver, no external dependencies.
6
+
7
+ **Architecture:** Three separate Vue 3 components each owning their own draw logic (drawLine/drawBar/drawPie), backed by shared internal utilities under `src/components/chart/` (colors, resize, tooltip). Internal utilities are never exported publicly.
8
+
9
+ **Tech Stack:** Vue 3, TypeScript strict, Canvas 2D API, ResizeObserver, vitest + @vue/test-utils + happy-dom.
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ ### Create
16
+ | File | Responsibility |
17
+ |------|---------------|
18
+ | `src/components/chart/chartTypes.ts` | Internal `TooltipHit` interface |
19
+ | `src/components/chart/chartColors.ts` | Bootstrap CSS var color resolver + slice variant |
20
+ | `src/components/chart/chartResize.ts` | ResizeObserver setup/teardown composable |
21
+ | `src/components/chart/chartTooltip.ts` | Canvas tooltip bind/cleanup |
22
+ | `src/components/chart/drawLine.ts` | `drawLine()` + `hitTestLine()` |
23
+ | `src/components/chart/drawBar.ts` | `drawBar()` + `hitTestBar()` |
24
+ | `src/components/chart/drawPie.ts` | `drawPie()` + `hitTestPie()` |
25
+ | `src/components/VibeChartLine.vue` | Line chart component |
26
+ | `src/components/VibeChartBar.vue` | Bar chart component |
27
+ | `src/components/VibeChartPie.vue` | Pie chart component |
28
+ | `tests/mocks/canvasMock.ts` | Shared canvas + ResizeObserver mocks |
29
+ | `tests/components/chart/chartColors.test.ts` | chartColors unit tests |
30
+ | `tests/components/chart/chartTooltip.test.ts` | chartTooltip unit tests |
31
+ | `tests/components/chart/drawLine.test.ts` | drawLine unit tests |
32
+ | `tests/components/chart/drawBar.test.ts` | drawBar unit tests |
33
+ | `tests/components/chart/drawPie.test.ts` | drawPie unit tests |
34
+ | `tests/components/VibeChartLine.test.ts` | VibeChartLine component tests |
35
+ | `tests/components/VibeChartBar.test.ts` | VibeChartBar component tests |
36
+ | `tests/components/VibeChartPie.test.ts` | VibeChartPie component tests |
37
+
38
+ ### Modify
39
+ | File | Change |
40
+ |------|--------|
41
+ | `src/types.ts` | Add `ChartDataset`, `ChartData`, `ChartLegendPosition` |
42
+ | `src/components/index.ts` | Import, export, and register all three chart components |
43
+ | `src/index.ts` | Re-export three components and chart types |
44
+
45
+ ---
46
+
47
+ ## Task 1: Public types + internal chartTypes
48
+
49
+ **Files:**
50
+ - Modify: `src/types.ts`
51
+ - Create: `src/components/chart/chartTypes.ts`
52
+
53
+ No test — pure type declarations.
54
+
55
+ - [ ] **Step 1: Add to `src/types.ts`** (append after existing types)
56
+
57
+ ```ts
58
+ // Chart types
59
+ export interface ChartDataset {
60
+ label: string
61
+ data: number[]
62
+ color?: string
63
+ }
64
+
65
+ export interface ChartData {
66
+ labels: string[]
67
+ datasets: ChartDataset[]
68
+ }
69
+
70
+ export type ChartLegendPosition = 'top' | 'bottom' | 'none'
71
+ ```
72
+
73
+ - [ ] **Step 2: Create `src/components/chart/chartTypes.ts`**
74
+
75
+ ```ts
76
+ export interface TooltipHit {
77
+ datasetIndex: number
78
+ pointIndex: number
79
+ value: number
80
+ label: string
81
+ }
82
+ ```
83
+
84
+ - [ ] **Step 3: Verify TypeScript compiles**
85
+
86
+ ```bash
87
+ npx vue-tsc --noEmit
88
+ ```
89
+ Expected: no errors.
90
+
91
+ - [ ] **Step 4: Commit**
92
+
93
+ ```bash
94
+ git add src/types.ts src/components/chart/chartTypes.ts
95
+ git commit -m "feat(charts): add ChartData, ChartDataset, ChartLegendPosition types and internal TooltipHit"
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Task 2: Canvas mock helper
101
+
102
+ **Files:**
103
+ - Create: `tests/mocks/canvasMock.ts`
104
+
105
+ No failing test to write — this is test infrastructure used by all subsequent tasks.
106
+
107
+ - [ ] **Step 1: Create `tests/mocks/canvasMock.ts`**
108
+
109
+ ```ts
110
+ import { vi } from 'vitest'
111
+
112
+ export function createMockCtx() {
113
+ return {
114
+ clearRect: vi.fn(),
115
+ beginPath: vi.fn(),
116
+ closePath: vi.fn(),
117
+ moveTo: vi.fn(),
118
+ lineTo: vi.fn(),
119
+ bezierCurveTo: vi.fn(),
120
+ arc: vi.fn(),
121
+ stroke: vi.fn(),
122
+ fill: vi.fn(),
123
+ fillRect: vi.fn(),
124
+ roundRect: vi.fn(),
125
+ fillText: vi.fn(),
126
+ measureText: vi.fn(() => ({ width: 50 })),
127
+ save: vi.fn(),
128
+ restore: vi.fn(),
129
+ scale: vi.fn(),
130
+ setLineDash: vi.fn(),
131
+ strokeStyle: '' as string,
132
+ fillStyle: '' as string,
133
+ lineWidth: 1,
134
+ font: '',
135
+ textAlign: 'start' as CanvasTextAlign,
136
+ }
137
+ }
138
+
139
+ export function mockCanvas(overrides = createMockCtx()) {
140
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
141
+ overrides as unknown as CanvasRenderingContext2D
142
+ )
143
+ return overrides
144
+ }
145
+
146
+ export function mockResizeObserver() {
147
+ let callback: ResizeObserverCallback | null = null
148
+ const observe = vi.fn()
149
+ const disconnect = vi.fn()
150
+ vi.stubGlobal(
151
+ 'ResizeObserver',
152
+ vi.fn((cb: ResizeObserverCallback) => {
153
+ callback = cb
154
+ return { observe, disconnect }
155
+ })
156
+ )
157
+ return {
158
+ observe,
159
+ disconnect,
160
+ trigger: (w: number, h: number) =>
161
+ callback?.(
162
+ [{ contentRect: { width: w, height: h } } as ResizeObserverEntry],
163
+ {} as ResizeObserver
164
+ ),
165
+ }
166
+ }
167
+ ```
168
+
169
+ - [ ] **Step 2: Commit**
170
+
171
+ ```bash
172
+ git add tests/mocks/canvasMock.ts
173
+ git commit -m "test(charts): add canvas and ResizeObserver mock helpers"
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Task 3: chartColors
179
+
180
+ **Files:**
181
+ - Create: `src/components/chart/chartColors.ts`
182
+ - Create: `tests/components/chart/chartColors.test.ts`
183
+
184
+ - [ ] **Step 1: Write failing test**
185
+
186
+ ```ts
187
+ // tests/components/chart/chartColors.test.ts
188
+ import { describe, it, expect, vi, afterEach } from 'vitest'
189
+ import { resolveColors, resolveSliceColors } from '../../../src/components/chart/chartColors'
190
+ import type { ChartDataset } from '../../../src/types'
191
+
192
+ afterEach(() => {
193
+ vi.restoreAllMocks()
194
+ })
195
+
196
+ describe('resolveColors', () => {
197
+ it('uses dataset color when provided', () => {
198
+ const el = document.createElement('div')
199
+ const datasets: ChartDataset[] = [{ label: 'A', data: [1], color: '#ff0000' }]
200
+ expect(resolveColors(datasets, el)[0]).toBe('#ff0000')
201
+ })
202
+
203
+ it('reads trimmed BS var when no color provided', () => {
204
+ const el = document.createElement('div')
205
+ vi.spyOn(window, 'getComputedStyle').mockReturnValue({
206
+ getPropertyValue: (p: string) => (p === '--bs-primary' ? ' #0d6efd ' : ''),
207
+ } as unknown as CSSStyleDeclaration)
208
+ const datasets: ChartDataset[] = [{ label: 'A', data: [1] }]
209
+ expect(resolveColors(datasets, el)[0]).toBe('#0d6efd')
210
+ })
211
+
212
+ it('cycles vars for 9+ datasets', () => {
213
+ const el = document.createElement('div')
214
+ vi.spyOn(window, 'getComputedStyle').mockReturnValue({
215
+ getPropertyValue: (p: string) => p,
216
+ } as unknown as CSSStyleDeclaration)
217
+ const datasets: ChartDataset[] = Array.from({ length: 9 }, (_, i) => ({
218
+ label: String(i),
219
+ data: [i],
220
+ }))
221
+ const colors = resolveColors(datasets, el)
222
+ expect(colors[8]).toBe(colors[0])
223
+ })
224
+
225
+ it('mixes explicit and auto colors', () => {
226
+ const el = document.createElement('div')
227
+ vi.spyOn(window, 'getComputedStyle').mockReturnValue({
228
+ getPropertyValue: () => 'auto',
229
+ } as unknown as CSSStyleDeclaration)
230
+ const datasets: ChartDataset[] = [
231
+ { label: 'A', data: [1], color: 'red' },
232
+ { label: 'B', data: [2] },
233
+ ]
234
+ const colors = resolveColors(datasets, el)
235
+ expect(colors[0]).toBe('red')
236
+ expect(colors[1]).toBe('auto')
237
+ })
238
+ })
239
+
240
+ describe('resolveSliceColors', () => {
241
+ it('returns n colors cycling BS vars', () => {
242
+ const el = document.createElement('div')
243
+ vi.spyOn(window, 'getComputedStyle').mockReturnValue({
244
+ getPropertyValue: (p: string) => p,
245
+ } as unknown as CSSStyleDeclaration)
246
+ const colors = resolveSliceColors(3, el)
247
+ expect(colors).toHaveLength(3)
248
+ expect(colors[0]).not.toBe(colors[1])
249
+ })
250
+ })
251
+ ```
252
+
253
+ - [ ] **Step 2: Run to verify FAIL**
254
+
255
+ ```bash
256
+ npx vitest run tests/components/chart/chartColors.test.ts
257
+ ```
258
+ Expected: `Cannot find module '../../../src/components/chart/chartColors'`
259
+
260
+ - [ ] **Step 3: Create `src/components/chart/chartColors.ts`**
261
+
262
+ ```ts
263
+ import type { ChartDataset } from '../../types'
264
+
265
+ const BS_VARS = [
266
+ '--bs-primary',
267
+ '--bs-success',
268
+ '--bs-danger',
269
+ '--bs-warning',
270
+ '--bs-info',
271
+ '--bs-secondary',
272
+ '--bs-dark',
273
+ '--bs-light',
274
+ ]
275
+
276
+ export function resolveColors(datasets: ChartDataset[], el: HTMLElement): string[] {
277
+ const style = getComputedStyle(el)
278
+ return datasets.map(
279
+ (ds, i) => ds.color ?? style.getPropertyValue(BS_VARS[i % BS_VARS.length]).trim()
280
+ )
281
+ }
282
+
283
+ export function resolveSliceColors(count: number, el: HTMLElement): string[] {
284
+ const style = getComputedStyle(el)
285
+ return Array.from({ length: count }, (_, i) =>
286
+ style.getPropertyValue(BS_VARS[i % BS_VARS.length]).trim()
287
+ )
288
+ }
289
+ ```
290
+
291
+ - [ ] **Step 4: Run to verify PASS**
292
+
293
+ ```bash
294
+ npx vitest run tests/components/chart/chartColors.test.ts
295
+ ```
296
+ Expected: all 5 tests pass.
297
+
298
+ - [ ] **Step 5: Commit**
299
+
300
+ ```bash
301
+ git add src/components/chart/chartColors.ts tests/components/chart/chartColors.test.ts
302
+ git commit -m "feat(charts): add chartColors with BS var cycling and slice variant"
303
+ ```
304
+
305
+ ---
306
+
307
+ ## Task 4: chartResize
308
+
309
+ **Files:**
310
+ - Create: `src/components/chart/chartResize.ts`
311
+
312
+ Tested via component mounts in later tasks. No standalone unit test.
313
+
314
+ - [ ] **Step 1: Create `src/components/chart/chartResize.ts`**
315
+
316
+ ```ts
317
+ import { type Ref, onMounted, onUnmounted } from 'vue'
318
+
319
+ export function useChartResize(
320
+ container: Ref<HTMLElement | null>,
321
+ canvas: Ref<HTMLCanvasElement | null>,
322
+ onResize: (w: number, h: number) => void
323
+ ): void {
324
+ let observer: ResizeObserver | null = null
325
+
326
+ onMounted(() => {
327
+ observer = new ResizeObserver((entries) => {
328
+ const entry = entries[0]
329
+ if (!entry) return
330
+ const { width, height } = entry.contentRect
331
+ onResize(width, height)
332
+ })
333
+ observer.observe(container.value!)
334
+ })
335
+
336
+ onUnmounted(() => {
337
+ observer?.disconnect()
338
+ })
339
+ }
340
+ ```
341
+
342
+ - [ ] **Step 2: Verify TypeScript**
343
+
344
+ ```bash
345
+ npx vue-tsc --noEmit
346
+ ```
347
+ Expected: no errors.
348
+
349
+ - [ ] **Step 3: Commit**
350
+
351
+ ```bash
352
+ git add src/components/chart/chartResize.ts
353
+ git commit -m "feat(charts): add useChartResize composable with ResizeObserver"
354
+ ```
355
+
356
+ ---
357
+
358
+ ## Task 5: chartTooltip
359
+
360
+ **Files:**
361
+ - Create: `src/components/chart/chartTooltip.ts`
362
+ - Create: `tests/components/chart/chartTooltip.test.ts`
363
+
364
+ - [ ] **Step 1: Write failing test**
365
+
366
+ ```ts
367
+ // tests/components/chart/chartTooltip.test.ts
368
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
369
+ import { bindTooltip } from '../../../src/components/chart/chartTooltip'
370
+ import { createMockCtx } from '../../mocks/canvasMock'
371
+ import type { TooltipHit } from '../../../src/components/chart/chartTypes'
372
+
373
+ let canvas: HTMLCanvasElement
374
+ let ctx: ReturnType<typeof createMockCtx>
375
+
376
+ beforeEach(() => {
377
+ canvas = document.createElement('canvas')
378
+ canvas.width = 400
379
+ canvas.height = 300
380
+ canvas.getBoundingClientRect = vi.fn(
381
+ () =>
382
+ ({
383
+ left: 0,
384
+ top: 0,
385
+ width: 400,
386
+ height: 300,
387
+ right: 400,
388
+ bottom: 300,
389
+ x: 0,
390
+ y: 0,
391
+ toJSON: vi.fn(),
392
+ }) as DOMRect
393
+ )
394
+ ctx = createMockCtx()
395
+ vi.spyOn(canvas, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
396
+ })
397
+
398
+ afterEach(() => {
399
+ vi.restoreAllMocks()
400
+ })
401
+
402
+ describe('bindTooltip', () => {
403
+ it('returns cleanup function', () => {
404
+ const cleanup = bindTooltip(canvas, () => null, vi.fn())
405
+ expect(typeof cleanup).toBe('function')
406
+ cleanup()
407
+ })
408
+
409
+ it('calls redraw on mousemove', () => {
410
+ const redraw = vi.fn()
411
+ bindTooltip(canvas, () => null, redraw)
412
+ canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 50 }))
413
+ expect(redraw).toHaveBeenCalled()
414
+ })
415
+
416
+ it('calls hitTest on mousemove', () => {
417
+ const hitTest = vi.fn(() => null)
418
+ bindTooltip(canvas, hitTest, vi.fn())
419
+ canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 50 }))
420
+ expect(hitTest).toHaveBeenCalledWith(50, 50)
421
+ })
422
+
423
+ it('calls redraw on mouseleave', () => {
424
+ const redraw = vi.fn()
425
+ bindTooltip(canvas, () => null, redraw)
426
+ canvas.dispatchEvent(new MouseEvent('mouseleave'))
427
+ expect(redraw).toHaveBeenCalled()
428
+ })
429
+
430
+ it('cleanup removes all listeners', () => {
431
+ const hitTest = vi.fn(() => null)
432
+ const redraw = vi.fn()
433
+ const cleanup = bindTooltip(canvas, hitTest, redraw)
434
+ cleanup()
435
+ redraw.mockClear()
436
+ hitTest.mockClear()
437
+ canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 50 }))
438
+ canvas.dispatchEvent(new MouseEvent('mouseleave'))
439
+ expect(hitTest).not.toHaveBeenCalled()
440
+ expect(redraw).not.toHaveBeenCalled()
441
+ })
442
+
443
+ it('draws tooltip text when hitTest returns a hit', () => {
444
+ const hit: TooltipHit = { datasetIndex: 0, pointIndex: 1, value: 42, label: 'Jan' }
445
+ bindTooltip(canvas, () => hit, vi.fn())
446
+ canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }))
447
+ expect(ctx.fillText).toHaveBeenCalledWith(
448
+ expect.stringContaining('Jan'),
449
+ expect.any(Number),
450
+ expect.any(Number)
451
+ )
452
+ })
453
+ })
454
+ ```
455
+
456
+ - [ ] **Step 2: Run to verify FAIL**
457
+
458
+ ```bash
459
+ npx vitest run tests/components/chart/chartTooltip.test.ts
460
+ ```
461
+ Expected: `Cannot find module '../../../src/components/chart/chartTooltip'`
462
+
463
+ - [ ] **Step 3: Create `src/components/chart/chartTooltip.ts`**
464
+
465
+ ```ts
466
+ import type { TooltipHit } from './chartTypes'
467
+
468
+ export function bindTooltip(
469
+ canvas: HTMLCanvasElement,
470
+ hitTest: (x: number, y: number) => TooltipHit | null,
471
+ redraw: () => void
472
+ ): () => void {
473
+ const onMove = (e: MouseEvent) => {
474
+ const rect = canvas.getBoundingClientRect()
475
+ const x = e.clientX - rect.left
476
+ const y = e.clientY - rect.top
477
+
478
+ redraw()
479
+
480
+ const hit = hitTest(x, y)
481
+ if (!hit) return
482
+
483
+ const ctx = canvas.getContext('2d')!
484
+ const text = `${hit.label}: ${hit.value}`
485
+ ctx.save()
486
+ ctx.font = '12px sans-serif'
487
+ const tw = ctx.measureText(text).width + 12
488
+ const th = 22
489
+ let tx = x + 12
490
+ let ty = y - th - 6
491
+ if (tx + tw > rect.width) tx = rect.width - tw - 4
492
+ if (ty < 0) ty = y + 14
493
+
494
+ ctx.fillStyle = 'rgba(0,0,0,0.75)'
495
+ ctx.beginPath()
496
+ ctx.roundRect(tx, ty, tw, th, 4)
497
+ ctx.fill()
498
+ ctx.fillStyle = '#fff'
499
+ ctx.fillText(text, tx + 6, ty + 15)
500
+ ctx.restore()
501
+ }
502
+
503
+ const onLeave = () => redraw()
504
+
505
+ canvas.addEventListener('mousemove', onMove)
506
+ canvas.addEventListener('mouseleave', onLeave)
507
+
508
+ return () => {
509
+ canvas.removeEventListener('mousemove', onMove)
510
+ canvas.removeEventListener('mouseleave', onLeave)
511
+ }
512
+ }
513
+ ```
514
+
515
+ - [ ] **Step 4: Run to verify PASS**
516
+
517
+ ```bash
518
+ npx vitest run tests/components/chart/chartTooltip.test.ts
519
+ ```
520
+ Expected: all 6 tests pass.
521
+
522
+ - [ ] **Step 5: Commit**
523
+
524
+ ```bash
525
+ git add src/components/chart/chartTooltip.ts tests/components/chart/chartTooltip.test.ts
526
+ git commit -m "feat(charts): add chartTooltip with canvas overlay and cleanup"
527
+ ```
528
+
529
+ ---
530
+
531
+ ## Task 6: drawLine
532
+
533
+ **Files:**
534
+ - Create: `src/components/chart/drawLine.ts`
535
+ - Create: `tests/components/chart/drawLine.test.ts`
536
+
537
+ - [ ] **Step 1: Write failing test**
538
+
539
+ ```ts
540
+ // tests/components/chart/drawLine.test.ts
541
+ import { describe, it, expect } from 'vitest'
542
+ import { drawLine, hitTestLine } from '../../../src/components/chart/drawLine'
543
+ import { createMockCtx } from '../../mocks/canvasMock'
544
+ import type { ChartData } from '../../../src/types'
545
+
546
+ const DATA: ChartData = {
547
+ labels: ['Jan', 'Feb', 'Mar'],
548
+ datasets: [{ label: 'Sales', data: [10, 20, 15] }],
549
+ }
550
+
551
+ const MULTI: ChartData = {
552
+ labels: ['A', 'B', 'C'],
553
+ datasets: [
554
+ { label: 'S1', data: [10, 20, 15] },
555
+ { label: 'S2', data: [5, 8, 12] },
556
+ ],
557
+ }
558
+
559
+ describe('drawLine', () => {
560
+ it('calls clearRect with full dimensions', () => {
561
+ const ctx = createMockCtx()
562
+ drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], true, true, false, false)
563
+ expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 400, 300)
564
+ })
565
+
566
+ it('applies dashed style for grid lines when showGrid=true', () => {
567
+ const ctx = createMockCtx()
568
+ drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], true, true, false, false)
569
+ expect(ctx.setLineDash).toHaveBeenCalledWith([4, 4])
570
+ })
571
+
572
+ it('skips grid lines when showGrid=false', () => {
573
+ const ctx = createMockCtx()
574
+ drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], true, false, false, false)
575
+ expect(ctx.setLineDash).not.toHaveBeenCalledWith([4, 4])
576
+ })
577
+
578
+ it('strokes one line per dataset', () => {
579
+ const ctx = createMockCtx()
580
+ drawLine(ctx as unknown as CanvasRenderingContext2D, MULTI, 400, 300, ['blue', 'red'], false, false, false, false)
581
+ // At minimum 2 strokes (one per dataset line); axes off so no extra axis strokes
582
+ expect(ctx.stroke.mock.calls.length).toBeGreaterThanOrEqual(2)
583
+ })
584
+
585
+ it('draws points with arc for each data value', () => {
586
+ const ctx = createMockCtx()
587
+ drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], false, false, false, false)
588
+ // 3 points = 3 arc calls
589
+ expect(ctx.arc.mock.calls.length).toBeGreaterThanOrEqual(3)
590
+ })
591
+
592
+ it('fills area under line when fill=true', () => {
593
+ const ctx = createMockCtx()
594
+ drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], false, false, false, true)
595
+ expect(ctx.fill).toHaveBeenCalled()
596
+ })
597
+
598
+ it('uses bezierCurveTo when smooth=true', () => {
599
+ const ctx = createMockCtx()
600
+ drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], false, false, true, false)
601
+ expect(ctx.bezierCurveTo).toHaveBeenCalled()
602
+ })
603
+ })
604
+
605
+ describe('hitTestLine', () => {
606
+ it('returns null when coords are far from all points', () => {
607
+ expect(hitTestLine(999, 999, DATA, 400, 300, true)).toBeNull()
608
+ })
609
+
610
+ it('returns hit when coords are within threshold of a point', () => {
611
+ // With showAxes=true: PAD_LEFT=50, PAD_RIGHT=10, PAD_TOP=10, PAD_BOTTOM=30
612
+ // chartW=340, chartH=260, n=3, xStep=170
613
+ // point[0] x=50, values [10,20,15], min=0,max=20,range=20
614
+ // point[0] y = 10 + 260 - (10/20)*260 = 10 + 260 - 130 = 140
615
+ const hit = hitTestLine(50, 140, DATA, 400, 300, true)
616
+ expect(hit).not.toBeNull()
617
+ expect(hit!.pointIndex).toBe(0)
618
+ expect(hit!.value).toBe(10)
619
+ expect(hit!.label).toBe('Jan')
620
+ })
621
+
622
+ it('returns correct datasetIndex for second dataset', () => {
623
+ // S2 value at index 0 is 5; with range 0-20, y = 10 + 260 - (5/20)*260 = 205
624
+ const hit = hitTestLine(50, 205, MULTI, 400, 300, true)
625
+ expect(hit?.datasetIndex).toBe(1)
626
+ })
627
+ })
628
+ ```
629
+
630
+ - [ ] **Step 2: Run to verify FAIL**
631
+
632
+ ```bash
633
+ npx vitest run tests/components/chart/drawLine.test.ts
634
+ ```
635
+ Expected: `Cannot find module '../../../src/components/chart/drawLine'`
636
+
637
+ - [ ] **Step 3: Create `src/components/chart/drawLine.ts`**
638
+
639
+ ```ts
640
+ import type { ChartData } from '../../types'
641
+ import type { TooltipHit } from './chartTypes'
642
+
643
+ const GRID_LINES = 5
644
+ const POINT_RADIUS = 4
645
+ const HIT_THRESHOLD = 12
646
+
647
+ function getPad(showAxes: boolean) {
648
+ return showAxes
649
+ ? { left: 50, right: 10, top: 10, bottom: 30 }
650
+ : { left: 10, right: 10, top: 10, bottom: 10 }
651
+ }
652
+
653
+ export function drawLine(
654
+ ctx: CanvasRenderingContext2D,
655
+ data: ChartData,
656
+ w: number,
657
+ h: number,
658
+ colors: string[],
659
+ showAxes: boolean,
660
+ showGrid: boolean,
661
+ smooth: boolean,
662
+ fill: boolean
663
+ ): void {
664
+ const pad = getPad(showAxes)
665
+ const chartW = w - pad.left - pad.right
666
+ const chartH = h - pad.top - pad.bottom
667
+
668
+ const allValues = data.datasets.flatMap((ds) => ds.data)
669
+ const minVal = Math.min(0, ...allValues)
670
+ const maxVal = Math.max(0, ...allValues)
671
+ const range = maxVal - minVal || 1
672
+ const n = data.labels.length
673
+ const xStep = chartW / Math.max(n - 1, 1)
674
+
675
+ const toX = (i: number) => pad.left + i * xStep
676
+ const toY = (v: number) => pad.top + chartH - ((v - minVal) / range) * chartH
677
+
678
+ ctx.clearRect(0, 0, w, h)
679
+
680
+ if (showGrid) {
681
+ ctx.strokeStyle = 'rgba(0,0,0,0.1)'
682
+ ctx.lineWidth = 1
683
+ ctx.setLineDash([4, 4])
684
+ for (let g = 0; g <= GRID_LINES; g++) {
685
+ const gy = pad.top + (chartH / GRID_LINES) * g
686
+ ctx.beginPath()
687
+ ctx.moveTo(pad.left, gy)
688
+ ctx.lineTo(pad.left + chartW, gy)
689
+ ctx.stroke()
690
+ }
691
+ ctx.setLineDash([])
692
+ }
693
+
694
+ if (showAxes) {
695
+ ctx.save()
696
+ ctx.strokeStyle = '#666'
697
+ ctx.lineWidth = 1
698
+ ctx.beginPath()
699
+ ctx.moveTo(pad.left, pad.top)
700
+ ctx.lineTo(pad.left, pad.top + chartH)
701
+ ctx.stroke()
702
+ ctx.beginPath()
703
+ ctx.moveTo(pad.left, pad.top + chartH)
704
+ ctx.lineTo(pad.left + chartW, pad.top + chartH)
705
+ ctx.stroke()
706
+
707
+ ctx.fillStyle = '#666'
708
+ ctx.font = '11px sans-serif'
709
+ ctx.textAlign = 'center'
710
+ data.labels.forEach((label, i) => ctx.fillText(String(label), toX(i), pad.top + chartH + 18))
711
+
712
+ ctx.textAlign = 'right'
713
+ for (let g = 0; g <= GRID_LINES; g++) {
714
+ const v = minVal + (range / GRID_LINES) * (GRID_LINES - g)
715
+ const gy = pad.top + (chartH / GRID_LINES) * g
716
+ ctx.fillText(v.toFixed(0), pad.left - 5, gy + 4)
717
+ }
718
+ ctx.restore()
719
+ }
720
+
721
+ for (const [di, ds] of data.datasets.entries()) {
722
+ const color = colors[di]
723
+
724
+ const tracePath = (withMove: boolean) => {
725
+ if (smooth && ds.data.length > 2) {
726
+ ds.data.forEach((v, i) => {
727
+ const x = toX(i)
728
+ const y = toY(v)
729
+ if (i === 0) {
730
+ if (withMove) ctx.moveTo(x, y)
731
+ } else {
732
+ const cpX = (toX(i - 1) + x) / 2
733
+ ctx.bezierCurveTo(cpX, toY(ds.data[i - 1]), cpX, y, x, y)
734
+ }
735
+ })
736
+ } else {
737
+ ds.data.forEach((v, i) => {
738
+ i === 0 && withMove ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v))
739
+ })
740
+ }
741
+ }
742
+
743
+ if (fill && ds.data.length > 0) {
744
+ ctx.beginPath()
745
+ tracePath(true)
746
+ ctx.lineTo(toX(ds.data.length - 1), pad.top + chartH)
747
+ ctx.lineTo(toX(0), pad.top + chartH)
748
+ ctx.closePath()
749
+ ctx.fillStyle = `${color}33`
750
+ ctx.fill()
751
+ }
752
+
753
+ ctx.strokeStyle = color
754
+ ctx.lineWidth = 2
755
+ ctx.beginPath()
756
+ tracePath(true)
757
+ ctx.stroke()
758
+
759
+ ctx.fillStyle = color
760
+ ds.data.forEach((v, i) => {
761
+ ctx.beginPath()
762
+ ctx.arc(toX(i), toY(v), POINT_RADIUS, 0, Math.PI * 2)
763
+ ctx.fill()
764
+ })
765
+ }
766
+ }
767
+
768
+ export function hitTestLine(
769
+ x: number,
770
+ y: number,
771
+ data: ChartData,
772
+ w: number,
773
+ h: number,
774
+ showAxes: boolean
775
+ ): TooltipHit | null {
776
+ const pad = getPad(showAxes)
777
+ const chartW = w - pad.left - pad.right
778
+ const chartH = h - pad.top - pad.bottom
779
+
780
+ const allValues = data.datasets.flatMap((ds) => ds.data)
781
+ const minVal = Math.min(0, ...allValues)
782
+ const maxVal = Math.max(0, ...allValues)
783
+ const range = maxVal - minVal || 1
784
+ const n = data.labels.length
785
+ const xStep = chartW / Math.max(n - 1, 1)
786
+
787
+ const toX = (i: number) => pad.left + i * xStep
788
+ const toY = (v: number) => pad.top + chartH - ((v - minVal) / range) * chartH
789
+
790
+ for (const [di, ds] of data.datasets.entries()) {
791
+ for (const [pi, v] of ds.data.entries()) {
792
+ if (Math.hypot(x - toX(pi), y - toY(v)) <= HIT_THRESHOLD) {
793
+ return { datasetIndex: di, pointIndex: pi, value: v, label: data.labels[pi] }
794
+ }
795
+ }
796
+ }
797
+ return null
798
+ }
799
+ ```
800
+
801
+ - [ ] **Step 4: Run to verify PASS**
802
+
803
+ ```bash
804
+ npx vitest run tests/components/chart/drawLine.test.ts
805
+ ```
806
+ Expected: all 10 tests pass.
807
+
808
+ - [ ] **Step 5: Commit**
809
+
810
+ ```bash
811
+ git add src/components/chart/drawLine.ts tests/components/chart/drawLine.test.ts
812
+ git commit -m "feat(charts): add drawLine and hitTestLine with smooth, fill, axes, grid support"
813
+ ```
814
+
815
+ ---
816
+
817
+ ## Task 7: VibeChartLine component
818
+
819
+ **Files:**
820
+ - Create: `src/components/VibeChartLine.vue`
821
+ - Create: `tests/components/VibeChartLine.test.ts`
822
+
823
+ - [ ] **Step 1: Write failing test**
824
+
825
+ ```ts
826
+ // tests/components/VibeChartLine.test.ts
827
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
828
+ import { mount } from '@vue/test-utils'
829
+ import VibeChartLine from '../../src/components/VibeChartLine.vue'
830
+ import { mockCanvas, mockResizeObserver } from '../mocks/canvasMock'
831
+ import type { ChartData } from '../../src/types'
832
+
833
+ const DATA: ChartData = {
834
+ labels: ['Jan', 'Feb', 'Mar'],
835
+ datasets: [
836
+ { label: 'Revenue', data: [10, 20, 15] },
837
+ { label: 'Costs', data: [5, 8, 12] },
838
+ ],
839
+ }
840
+
841
+ beforeEach(() => {
842
+ mockCanvas()
843
+ mockResizeObserver()
844
+ })
845
+
846
+ afterEach(() => {
847
+ vi.restoreAllMocks()
848
+ vi.unstubAllGlobals()
849
+ })
850
+
851
+ describe('VibeChartLine', () => {
852
+ it('renders a canvas element', () => {
853
+ const wrapper = mount(VibeChartLine, { props: { data: DATA } })
854
+ expect(wrapper.find('canvas').exists()).toBe(true)
855
+ })
856
+
857
+ it('renders legend at bottom by default', () => {
858
+ const wrapper = mount(VibeChartLine, { props: { data: DATA } })
859
+ expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(true)
860
+ expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(false)
861
+ })
862
+
863
+ it('renders legend at top when legend="top"', () => {
864
+ const wrapper = mount(VibeChartLine, { props: { data: DATA, legend: 'top' } })
865
+ expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(true)
866
+ expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(false)
867
+ })
868
+
869
+ it('hides legend when legend="none"', () => {
870
+ const wrapper = mount(VibeChartLine, { props: { data: DATA, legend: 'none' } })
871
+ expect(wrapper.find('.vibe-chart-legend').exists()).toBe(false)
872
+ })
873
+
874
+ it('shows all dataset labels in legend', () => {
875
+ const wrapper = mount(VibeChartLine, { props: { data: DATA } })
876
+ const items = wrapper.findAll('.vibe-chart-legend-item')
877
+ expect(items).toHaveLength(2)
878
+ expect(items[0].text()).toContain('Revenue')
879
+ expect(items[1].text()).toContain('Costs')
880
+ })
881
+
882
+ it('canvas container uses aspect-ratio style by default', () => {
883
+ const wrapper = mount(VibeChartLine, { props: { data: DATA } })
884
+ const container = wrapper.find('.vibe-chart-canvas-container')
885
+ expect(container.attributes('style')).toContain('aspect-ratio')
886
+ })
887
+
888
+ it('canvas container uses explicit height when height prop given', () => {
889
+ const wrapper = mount(VibeChartLine, { props: { data: DATA, height: 300 } })
890
+ const container = wrapper.find('.vibe-chart-canvas-container')
891
+ expect(container.attributes('style')).toContain('300px')
892
+ })
893
+
894
+ it('calls clearRect when ResizeObserver triggers', () => {
895
+ const ctx = mockCanvas()
896
+ const ro = mockResizeObserver()
897
+ mount(VibeChartLine, { props: { data: DATA } })
898
+ ro.trigger(400, 225)
899
+ expect(ctx.clearRect).toHaveBeenCalled()
900
+ })
901
+ })
902
+ ```
903
+
904
+ - [ ] **Step 2: Run to verify FAIL**
905
+
906
+ ```bash
907
+ npx vitest run tests/components/VibeChartLine.test.ts
908
+ ```
909
+ Expected: `Cannot find module '../../src/components/VibeChartLine.vue'`
910
+
911
+ - [ ] **Step 3: Create `src/components/VibeChartLine.vue`**
912
+
913
+ ```vue
914
+ <script setup lang="ts">
915
+ import { ref, computed, watch, onMounted, onUnmounted, type PropType } from 'vue'
916
+ import type { ChartData, ChartLegendPosition } from '../types'
917
+ import { resolveColors } from './chart/chartColors'
918
+ import { useChartResize } from './chart/chartResize'
919
+ import { bindTooltip } from './chart/chartTooltip'
920
+ import { drawLine, hitTestLine } from './chart/drawLine'
921
+
922
+ const props = defineProps({
923
+ data: { type: Object as PropType<ChartData>, required: true },
924
+ legend: { type: String as PropType<ChartLegendPosition>, default: 'bottom' },
925
+ height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
926
+ showAxes: { type: Boolean, default: true },
927
+ showGrid: { type: Boolean, default: true },
928
+ smooth: { type: Boolean, default: false },
929
+ fill: { type: Boolean, default: false },
930
+ })
931
+
932
+ const containerEl = ref<HTMLElement | null>(null)
933
+ const canvasEl = ref<HTMLCanvasElement | null>(null)
934
+ let cleanupTooltip: (() => void) | null = null
935
+ let currentW = 0
936
+ let currentH = 0
937
+
938
+ const canvasContainerStyle = computed(() => {
939
+ if (props.height === 'auto') return { width: '100%', aspectRatio: '16/9' }
940
+ const h = typeof props.height === 'number' ? `${props.height}px` : (props.height as string)
941
+ return { width: '100%', height: h }
942
+ })
943
+
944
+ const resolvedColors = computed(() =>
945
+ containerEl.value ? resolveColors(props.data.datasets, containerEl.value) : []
946
+ )
947
+
948
+ function redraw() {
949
+ const canvas = canvasEl.value!
950
+ const ctx = canvas.getContext('2d')!
951
+ const dpr = window.devicePixelRatio || 1
952
+ canvas.width = currentW * dpr
953
+ canvas.height = currentH * dpr
954
+ ctx.scale(dpr, dpr)
955
+ drawLine(
956
+ ctx,
957
+ props.data,
958
+ currentW,
959
+ currentH,
960
+ resolvedColors.value,
961
+ props.showAxes,
962
+ props.showGrid,
963
+ props.smooth,
964
+ props.fill
965
+ )
966
+ }
967
+
968
+ useChartResize(containerEl, canvasEl, (w, h) => {
969
+ currentW = w
970
+ currentH = h
971
+ redraw()
972
+ })
973
+
974
+ onMounted(() => {
975
+ cleanupTooltip = bindTooltip(
976
+ canvasEl.value!,
977
+ (x, y) => hitTestLine(x, y, props.data, currentW, currentH, props.showAxes),
978
+ redraw
979
+ )
980
+ })
981
+
982
+ watch(() => props.data, redraw, { deep: true })
983
+
984
+ onUnmounted(() => {
985
+ cleanupTooltip?.()
986
+ })
987
+ </script>
988
+
989
+ <template>
990
+ <div class="vibe-chart">
991
+ <div v-if="legend === 'top'" class="vibe-chart-legend vibe-chart-legend--top">
992
+ <span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
993
+ <span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
994
+ {{ ds.label }}
995
+ </span>
996
+ </div>
997
+ <div ref="containerEl" class="vibe-chart-canvas-container" :style="canvasContainerStyle">
998
+ <canvas ref="canvasEl" style="display: block; width: 100%; height: 100%;" />
999
+ </div>
1000
+ <div v-if="legend === 'bottom'" class="vibe-chart-legend vibe-chart-legend--bottom">
1001
+ <span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
1002
+ <span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
1003
+ {{ ds.label }}
1004
+ </span>
1005
+ </div>
1006
+ </div>
1007
+ </template>
1008
+
1009
+ <style scoped>
1010
+ .vibe-chart {
1011
+ width: 100%;
1012
+ }
1013
+ .vibe-chart-legend {
1014
+ display: flex;
1015
+ flex-wrap: wrap;
1016
+ gap: 0.5rem;
1017
+ padding: 0.25rem 0;
1018
+ }
1019
+ .vibe-chart-legend--top {
1020
+ margin-bottom: 0.25rem;
1021
+ }
1022
+ .vibe-chart-legend--bottom {
1023
+ margin-top: 0.25rem;
1024
+ }
1025
+ .vibe-chart-legend-item {
1026
+ display: flex;
1027
+ align-items: center;
1028
+ gap: 0.25rem;
1029
+ font-size: 0.875rem;
1030
+ }
1031
+ .vibe-chart-legend-swatch {
1032
+ display: inline-block;
1033
+ width: 12px;
1034
+ height: 12px;
1035
+ border-radius: 2px;
1036
+ flex-shrink: 0;
1037
+ }
1038
+ </style>
1039
+ ```
1040
+
1041
+ - [ ] **Step 4: Run to verify PASS**
1042
+
1043
+ ```bash
1044
+ npx vitest run tests/components/VibeChartLine.test.ts
1045
+ ```
1046
+ Expected: all 8 tests pass.
1047
+
1048
+ - [ ] **Step 5: Commit**
1049
+
1050
+ ```bash
1051
+ git add src/components/VibeChartLine.vue tests/components/VibeChartLine.test.ts
1052
+ git commit -m "feat(charts): add VibeChartLine component"
1053
+ ```
1054
+
1055
+ ---
1056
+
1057
+ ## Task 8: drawBar
1058
+
1059
+ **Files:**
1060
+ - Create: `src/components/chart/drawBar.ts`
1061
+ - Create: `tests/components/chart/drawBar.test.ts`
1062
+
1063
+ - [ ] **Step 1: Write failing test**
1064
+
1065
+ ```ts
1066
+ // tests/components/chart/drawBar.test.ts
1067
+ import { describe, it, expect } from 'vitest'
1068
+ import { drawBar, hitTestBar } from '../../../src/components/chart/drawBar'
1069
+ import { createMockCtx } from '../../mocks/canvasMock'
1070
+ import type { ChartData } from '../../../src/types'
1071
+
1072
+ const DATA: ChartData = {
1073
+ labels: ['Q1', 'Q2', 'Q3'],
1074
+ datasets: [
1075
+ { label: 'A', data: [10, 20, 15] },
1076
+ { label: 'B', data: [5, 8, 12] },
1077
+ ],
1078
+ }
1079
+
1080
+ describe('drawBar', () => {
1081
+ it('calls clearRect with full dimensions', () => {
1082
+ const ctx = createMockCtx()
1083
+ drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], true, true, false)
1084
+ expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 400, 300)
1085
+ })
1086
+
1087
+ it('calls fillRect for each bar in grouped mode', () => {
1088
+ const ctx = createMockCtx()
1089
+ drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], false, false, false)
1090
+ // 3 labels × 2 datasets = 6 bars
1091
+ expect(ctx.fillRect.mock.calls.length).toBe(6)
1092
+ })
1093
+
1094
+ it('calls fillRect for each bar in stacked mode', () => {
1095
+ const ctx = createMockCtx()
1096
+ drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], false, false, true)
1097
+ // 3 labels × 2 datasets = 6 bars (same count, different positions)
1098
+ expect(ctx.fillRect.mock.calls.length).toBe(6)
1099
+ })
1100
+
1101
+ it('draws grid lines when showGrid=true', () => {
1102
+ const ctx = createMockCtx()
1103
+ drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], true, true, false)
1104
+ expect(ctx.setLineDash).toHaveBeenCalledWith([4, 4])
1105
+ })
1106
+
1107
+ it('skips grid when showGrid=false', () => {
1108
+ const ctx = createMockCtx()
1109
+ drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], true, false, false)
1110
+ expect(ctx.setLineDash).not.toHaveBeenCalledWith([4, 4])
1111
+ })
1112
+ })
1113
+
1114
+ describe('hitTestBar', () => {
1115
+ it('returns null outside chart area', () => {
1116
+ expect(hitTestBar(0, 0, DATA, 400, 300, true, false)).toBeNull()
1117
+ })
1118
+
1119
+ it('returns null below all bars', () => {
1120
+ // y=299 is at the very bottom (x-axis baseline), no bar there
1121
+ expect(hitTestBar(200, 299, DATA, 400, 300, false, false)).toBeNull()
1122
+ })
1123
+
1124
+ it('returns a hit inside a bar', () => {
1125
+ // showAxes=false: PAD=10 all sides, chartW=380, chartH=280
1126
+ // n=3, groupW=380/3≈126.7, GROUP_PADDING=0.2
1127
+ // barW=(126.7*(1-0.2))/2≈50.7
1128
+ // Group 0 startX=10+0+126.7*0.1=22.7
1129
+ // Dataset 0 bar x=22.7+0=22.7, width=50.7
1130
+ // Dataset 0 value=10, max=20, barH=(10/20)*280=140
1131
+ // Bar rect: x=22.7, y=10+280-140=150, w=50.7, h=140
1132
+ const hit = hitTestBar(30, 200, DATA, 400, 300, false, false)
1133
+ expect(hit).not.toBeNull()
1134
+ expect(hit!.pointIndex).toBe(0)
1135
+ })
1136
+ })
1137
+ ```
1138
+
1139
+ - [ ] **Step 2: Run to verify FAIL**
1140
+
1141
+ ```bash
1142
+ npx vitest run tests/components/chart/drawBar.test.ts
1143
+ ```
1144
+ Expected: `Cannot find module '../../../src/components/chart/drawBar'`
1145
+
1146
+ - [ ] **Step 3: Create `src/components/chart/drawBar.ts`**
1147
+
1148
+ ```ts
1149
+ import type { ChartData } from '../../types'
1150
+ import type { TooltipHit } from './chartTypes'
1151
+
1152
+ const GRID_LINES = 5
1153
+ const GROUP_PADDING = 0.2
1154
+
1155
+ function getPad(showAxes: boolean) {
1156
+ return showAxes
1157
+ ? { left: 50, right: 10, top: 10, bottom: 30 }
1158
+ : { left: 10, right: 10, top: 10, bottom: 10 }
1159
+ }
1160
+
1161
+ function getMaxVal(data: ChartData, stacked: boolean): number {
1162
+ if (stacked) {
1163
+ const colSums = data.labels.map((_, i) =>
1164
+ data.datasets.reduce((s, ds) => s + (ds.data[i] ?? 0), 0)
1165
+ )
1166
+ return Math.max(...colSums, 1)
1167
+ }
1168
+ return Math.max(...data.datasets.flatMap((ds) => ds.data), 1)
1169
+ }
1170
+
1171
+ export function drawBar(
1172
+ ctx: CanvasRenderingContext2D,
1173
+ data: ChartData,
1174
+ w: number,
1175
+ h: number,
1176
+ colors: string[],
1177
+ showAxes: boolean,
1178
+ showGrid: boolean,
1179
+ stacked: boolean
1180
+ ): void {
1181
+ const pad = getPad(showAxes)
1182
+ const chartW = w - pad.left - pad.right
1183
+ const chartH = h - pad.top - pad.bottom
1184
+ const n = data.labels.length
1185
+ const numDatasets = data.datasets.length
1186
+ const maxVal = getMaxVal(data, stacked)
1187
+ const groupW = chartW / n
1188
+ const barW = stacked
1189
+ ? groupW * (1 - GROUP_PADDING)
1190
+ : (groupW * (1 - GROUP_PADDING)) / numDatasets
1191
+
1192
+ ctx.clearRect(0, 0, w, h)
1193
+
1194
+ if (showGrid) {
1195
+ ctx.strokeStyle = 'rgba(0,0,0,0.1)'
1196
+ ctx.lineWidth = 1
1197
+ ctx.setLineDash([4, 4])
1198
+ for (let g = 1; g <= GRID_LINES; g++) {
1199
+ const gy = pad.top + (chartH / GRID_LINES) * g
1200
+ ctx.beginPath()
1201
+ ctx.moveTo(pad.left, gy)
1202
+ ctx.lineTo(pad.left + chartW, gy)
1203
+ ctx.stroke()
1204
+ }
1205
+ ctx.setLineDash([])
1206
+ }
1207
+
1208
+ if (showAxes) {
1209
+ ctx.save()
1210
+ ctx.strokeStyle = '#666'
1211
+ ctx.lineWidth = 1
1212
+ ctx.beginPath()
1213
+ ctx.moveTo(pad.left, pad.top)
1214
+ ctx.lineTo(pad.left, pad.top + chartH)
1215
+ ctx.stroke()
1216
+ ctx.beginPath()
1217
+ ctx.moveTo(pad.left, pad.top + chartH)
1218
+ ctx.lineTo(pad.left + chartW, pad.top + chartH)
1219
+ ctx.stroke()
1220
+
1221
+ ctx.fillStyle = '#666'
1222
+ ctx.font = '11px sans-serif'
1223
+ ctx.textAlign = 'center'
1224
+ data.labels.forEach((label, i) => {
1225
+ ctx.fillText(String(label), pad.left + i * groupW + groupW / 2, pad.top + chartH + 18)
1226
+ })
1227
+
1228
+ ctx.textAlign = 'right'
1229
+ for (let g = 0; g <= GRID_LINES; g++) {
1230
+ const v = (maxVal / GRID_LINES) * (GRID_LINES - g)
1231
+ const gy = pad.top + (chartH / GRID_LINES) * g
1232
+ ctx.fillText(v.toFixed(0), pad.left - 5, gy + 4)
1233
+ }
1234
+ ctx.restore()
1235
+ }
1236
+
1237
+ if (stacked) {
1238
+ for (let i = 0; i < n; i++) {
1239
+ let yOffset = pad.top + chartH
1240
+ const bx = pad.left + i * groupW + (groupW * GROUP_PADDING) / 2
1241
+ for (const [di, ds] of data.datasets.entries()) {
1242
+ const v = ds.data[i] ?? 0
1243
+ const bh = (v / maxVal) * chartH
1244
+ ctx.fillStyle = colors[di]
1245
+ ctx.fillRect(bx, yOffset - bh, barW, bh)
1246
+ yOffset -= bh
1247
+ }
1248
+ }
1249
+ } else {
1250
+ for (const [di, ds] of data.datasets.entries()) {
1251
+ ctx.fillStyle = colors[di]
1252
+ for (let i = 0; i < n; i++) {
1253
+ const v = ds.data[i] ?? 0
1254
+ const bh = (v / maxVal) * chartH
1255
+ const bx = pad.left + i * groupW + (groupW * GROUP_PADDING) / 2 + di * barW
1256
+ ctx.fillRect(bx, pad.top + chartH - bh, barW, bh)
1257
+ }
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ export function hitTestBar(
1263
+ x: number,
1264
+ y: number,
1265
+ data: ChartData,
1266
+ w: number,
1267
+ h: number,
1268
+ showAxes: boolean,
1269
+ stacked: boolean
1270
+ ): TooltipHit | null {
1271
+ const pad = getPad(showAxes)
1272
+ const chartW = w - pad.left - pad.right
1273
+ const chartH = h - pad.top - pad.bottom
1274
+ const n = data.labels.length
1275
+ const numDatasets = data.datasets.length
1276
+ const maxVal = getMaxVal(data, stacked)
1277
+ const groupW = chartW / n
1278
+ const barW = stacked
1279
+ ? groupW * (1 - GROUP_PADDING)
1280
+ : (groupW * (1 - GROUP_PADDING)) / numDatasets
1281
+
1282
+ const groupI = Math.floor((x - pad.left) / groupW)
1283
+ if (groupI < 0 || groupI >= n) return null
1284
+
1285
+ const groupStartX = pad.left + groupI * groupW + (groupW * GROUP_PADDING) / 2
1286
+
1287
+ if (stacked) {
1288
+ if (x < groupStartX || x > groupStartX + barW) return null
1289
+ let yOffset = pad.top + chartH
1290
+ for (const [di, ds] of data.datasets.entries()) {
1291
+ const v = ds.data[groupI] ?? 0
1292
+ const bh = (v / maxVal) * chartH
1293
+ const barTop = yOffset - bh
1294
+ if (y >= barTop && y <= yOffset) {
1295
+ return { datasetIndex: di, pointIndex: groupI, value: v, label: data.labels[groupI] }
1296
+ }
1297
+ yOffset -= bh
1298
+ }
1299
+ return null
1300
+ }
1301
+
1302
+ for (let di = 0; di < numDatasets; di++) {
1303
+ const bx = groupStartX + di * barW
1304
+ if (x < bx || x > bx + barW) continue
1305
+ const v = data.datasets[di].data[groupI] ?? 0
1306
+ const bh = (v / maxVal) * chartH
1307
+ const barTop = pad.top + chartH - bh
1308
+ if (y >= barTop && y <= pad.top + chartH) {
1309
+ return {
1310
+ datasetIndex: di,
1311
+ pointIndex: groupI,
1312
+ value: v,
1313
+ label: data.labels[groupI],
1314
+ }
1315
+ }
1316
+ }
1317
+ return null
1318
+ }
1319
+ ```
1320
+
1321
+ - [ ] **Step 4: Run to verify PASS**
1322
+
1323
+ ```bash
1324
+ npx vitest run tests/components/chart/drawBar.test.ts
1325
+ ```
1326
+ Expected: all 7 tests pass.
1327
+
1328
+ - [ ] **Step 5: Commit**
1329
+
1330
+ ```bash
1331
+ git add src/components/chart/drawBar.ts tests/components/chart/drawBar.test.ts
1332
+ git commit -m "feat(charts): add drawBar and hitTestBar with grouped and stacked support"
1333
+ ```
1334
+
1335
+ ---
1336
+
1337
+ ## Task 9: VibeChartBar component
1338
+
1339
+ **Files:**
1340
+ - Create: `src/components/VibeChartBar.vue`
1341
+ - Create: `tests/components/VibeChartBar.test.ts`
1342
+
1343
+ - [ ] **Step 1: Write failing test**
1344
+
1345
+ ```ts
1346
+ // tests/components/VibeChartBar.test.ts
1347
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
1348
+ import { mount } from '@vue/test-utils'
1349
+ import VibeChartBar from '../../src/components/VibeChartBar.vue'
1350
+ import { mockCanvas, mockResizeObserver } from '../mocks/canvasMock'
1351
+ import type { ChartData } from '../../src/types'
1352
+
1353
+ const DATA: ChartData = {
1354
+ labels: ['Q1', 'Q2', 'Q3'],
1355
+ datasets: [
1356
+ { label: 'Revenue', data: [10, 20, 15] },
1357
+ { label: 'Costs', data: [5, 8, 12] },
1358
+ ],
1359
+ }
1360
+
1361
+ beforeEach(() => {
1362
+ mockCanvas()
1363
+ mockResizeObserver()
1364
+ })
1365
+
1366
+ afterEach(() => {
1367
+ vi.restoreAllMocks()
1368
+ vi.unstubAllGlobals()
1369
+ })
1370
+
1371
+ describe('VibeChartBar', () => {
1372
+ it('renders a canvas element', () => {
1373
+ const wrapper = mount(VibeChartBar, { props: { data: DATA } })
1374
+ expect(wrapper.find('canvas').exists()).toBe(true)
1375
+ })
1376
+
1377
+ it('renders legend at bottom by default', () => {
1378
+ const wrapper = mount(VibeChartBar, { props: { data: DATA } })
1379
+ expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(true)
1380
+ })
1381
+
1382
+ it('renders legend at top when legend="top"', () => {
1383
+ const wrapper = mount(VibeChartBar, { props: { data: DATA, legend: 'top' } })
1384
+ expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(true)
1385
+ })
1386
+
1387
+ it('hides legend when legend="none"', () => {
1388
+ const wrapper = mount(VibeChartBar, { props: { data: DATA, legend: 'none' } })
1389
+ expect(wrapper.find('.vibe-chart-legend').exists()).toBe(false)
1390
+ })
1391
+
1392
+ it('shows dataset labels in legend', () => {
1393
+ const wrapper = mount(VibeChartBar, { props: { data: DATA } })
1394
+ const items = wrapper.findAll('.vibe-chart-legend-item')
1395
+ expect(items[0].text()).toContain('Revenue')
1396
+ expect(items[1].text()).toContain('Costs')
1397
+ })
1398
+
1399
+ it('calls clearRect when ResizeObserver triggers', () => {
1400
+ const ctx = mockCanvas()
1401
+ const ro = mockResizeObserver()
1402
+ mount(VibeChartBar, { props: { data: DATA } })
1403
+ ro.trigger(400, 225)
1404
+ expect(ctx.clearRect).toHaveBeenCalled()
1405
+ })
1406
+
1407
+ it('applies explicit height when given', () => {
1408
+ const wrapper = mount(VibeChartBar, { props: { data: DATA, height: 400 } })
1409
+ expect(wrapper.find('.vibe-chart-canvas-container').attributes('style')).toContain('400px')
1410
+ })
1411
+ })
1412
+ ```
1413
+
1414
+ - [ ] **Step 2: Run to verify FAIL**
1415
+
1416
+ ```bash
1417
+ npx vitest run tests/components/VibeChartBar.test.ts
1418
+ ```
1419
+ Expected: `Cannot find module '../../src/components/VibeChartBar.vue'`
1420
+
1421
+ - [ ] **Step 3: Create `src/components/VibeChartBar.vue`**
1422
+
1423
+ ```vue
1424
+ <script setup lang="ts">
1425
+ import { ref, computed, watch, onMounted, onUnmounted, type PropType } from 'vue'
1426
+ import type { ChartData, ChartLegendPosition } from '../types'
1427
+ import { resolveColors } from './chart/chartColors'
1428
+ import { useChartResize } from './chart/chartResize'
1429
+ import { bindTooltip } from './chart/chartTooltip'
1430
+ import { drawBar, hitTestBar } from './chart/drawBar'
1431
+
1432
+ const props = defineProps({
1433
+ data: { type: Object as PropType<ChartData>, required: true },
1434
+ legend: { type: String as PropType<ChartLegendPosition>, default: 'bottom' },
1435
+ height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
1436
+ showAxes: { type: Boolean, default: true },
1437
+ showGrid: { type: Boolean, default: true },
1438
+ stacked: { type: Boolean, default: false },
1439
+ })
1440
+
1441
+ const containerEl = ref<HTMLElement | null>(null)
1442
+ const canvasEl = ref<HTMLCanvasElement | null>(null)
1443
+ let cleanupTooltip: (() => void) | null = null
1444
+ let currentW = 0
1445
+ let currentH = 0
1446
+
1447
+ const canvasContainerStyle = computed(() => {
1448
+ if (props.height === 'auto') return { width: '100%', aspectRatio: '16/9' }
1449
+ const h = typeof props.height === 'number' ? `${props.height}px` : (props.height as string)
1450
+ return { width: '100%', height: h }
1451
+ })
1452
+
1453
+ const resolvedColors = computed(() =>
1454
+ containerEl.value ? resolveColors(props.data.datasets, containerEl.value) : []
1455
+ )
1456
+
1457
+ function redraw() {
1458
+ const canvas = canvasEl.value!
1459
+ const ctx = canvas.getContext('2d')!
1460
+ const dpr = window.devicePixelRatio || 1
1461
+ canvas.width = currentW * dpr
1462
+ canvas.height = currentH * dpr
1463
+ ctx.scale(dpr, dpr)
1464
+ drawBar(ctx, props.data, currentW, currentH, resolvedColors.value, props.showAxes, props.showGrid, props.stacked)
1465
+ }
1466
+
1467
+ useChartResize(containerEl, canvasEl, (w, h) => {
1468
+ currentW = w
1469
+ currentH = h
1470
+ redraw()
1471
+ })
1472
+
1473
+ onMounted(() => {
1474
+ cleanupTooltip = bindTooltip(
1475
+ canvasEl.value!,
1476
+ (x, y) => hitTestBar(x, y, props.data, currentW, currentH, props.showAxes, props.stacked),
1477
+ redraw
1478
+ )
1479
+ })
1480
+
1481
+ watch(() => props.data, redraw, { deep: true })
1482
+
1483
+ onUnmounted(() => {
1484
+ cleanupTooltip?.()
1485
+ })
1486
+ </script>
1487
+
1488
+ <template>
1489
+ <div class="vibe-chart">
1490
+ <div v-if="legend === 'top'" class="vibe-chart-legend vibe-chart-legend--top">
1491
+ <span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
1492
+ <span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
1493
+ {{ ds.label }}
1494
+ </span>
1495
+ </div>
1496
+ <div ref="containerEl" class="vibe-chart-canvas-container" :style="canvasContainerStyle">
1497
+ <canvas ref="canvasEl" style="display: block; width: 100%; height: 100%;" />
1498
+ </div>
1499
+ <div v-if="legend === 'bottom'" class="vibe-chart-legend vibe-chart-legend--bottom">
1500
+ <span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
1501
+ <span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
1502
+ {{ ds.label }}
1503
+ </span>
1504
+ </div>
1505
+ </div>
1506
+ </template>
1507
+
1508
+ <style scoped>
1509
+ .vibe-chart { width: 100%; }
1510
+ .vibe-chart-legend {
1511
+ display: flex;
1512
+ flex-wrap: wrap;
1513
+ gap: 0.5rem;
1514
+ padding: 0.25rem 0;
1515
+ }
1516
+ .vibe-chart-legend--top { margin-bottom: 0.25rem; }
1517
+ .vibe-chart-legend--bottom { margin-top: 0.25rem; }
1518
+ .vibe-chart-legend-item {
1519
+ display: flex;
1520
+ align-items: center;
1521
+ gap: 0.25rem;
1522
+ font-size: 0.875rem;
1523
+ }
1524
+ .vibe-chart-legend-swatch {
1525
+ display: inline-block;
1526
+ width: 12px;
1527
+ height: 12px;
1528
+ border-radius: 2px;
1529
+ flex-shrink: 0;
1530
+ }
1531
+ </style>
1532
+ ```
1533
+
1534
+ - [ ] **Step 4: Run to verify PASS**
1535
+
1536
+ ```bash
1537
+ npx vitest run tests/components/VibeChartBar.test.ts
1538
+ ```
1539
+ Expected: all 7 tests pass.
1540
+
1541
+ - [ ] **Step 5: Commit**
1542
+
1543
+ ```bash
1544
+ git add src/components/VibeChartBar.vue tests/components/VibeChartBar.test.ts
1545
+ git commit -m "feat(charts): add VibeChartBar component"
1546
+ ```
1547
+
1548
+ ---
1549
+
1550
+ ## Task 10: drawPie
1551
+
1552
+ **Files:**
1553
+ - Create: `src/components/chart/drawPie.ts`
1554
+ - Create: `tests/components/chart/drawPie.test.ts`
1555
+
1556
+ - [ ] **Step 1: Write failing test**
1557
+
1558
+ ```ts
1559
+ // tests/components/chart/drawPie.test.ts
1560
+ import { describe, it, expect } from 'vitest'
1561
+ import { drawPie, hitTestPie } from '../../../src/components/chart/drawPie'
1562
+ import { createMockCtx } from '../../mocks/canvasMock'
1563
+ import type { ChartData } from '../../../src/types'
1564
+
1565
+ const DATA: ChartData = {
1566
+ labels: ['A', 'B', 'C'],
1567
+ datasets: [{ label: 'Market share', data: [50, 30, 20] }],
1568
+ }
1569
+
1570
+ describe('drawPie', () => {
1571
+ it('calls clearRect with full dimensions', () => {
1572
+ const ctx = createMockCtx()
1573
+ drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
1574
+ expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 400, 300)
1575
+ })
1576
+
1577
+ it('draws one arc per slice', () => {
1578
+ const ctx = createMockCtx()
1579
+ drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
1580
+ // 3 slices = 3 arc calls (each sector uses arc after moveTo)
1581
+ expect(ctx.arc.mock.calls.length).toBe(3)
1582
+ })
1583
+
1584
+ it('fills each sector', () => {
1585
+ const ctx = createMockCtx()
1586
+ drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
1587
+ expect(ctx.fill.mock.calls.length).toBe(3)
1588
+ })
1589
+
1590
+ it('strokes each sector boundary', () => {
1591
+ const ctx = createMockCtx()
1592
+ drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
1593
+ expect(ctx.stroke.mock.calls.length).toBe(3)
1594
+ })
1595
+ })
1596
+
1597
+ describe('hitTestPie', () => {
1598
+ it('returns null outside pie radius', () => {
1599
+ // Center is 200,150, radius = min(400,300)*0.4 = 120
1600
+ // Point at 0,0 is far outside
1601
+ expect(hitTestPie(0, 0, DATA, 400, 300)).toBeNull()
1602
+ })
1603
+
1604
+ it('returns hit for point inside pie', () => {
1605
+ // A point near center should hit something
1606
+ const hit = hitTestPie(200, 148, DATA, 400, 300)
1607
+ expect(hit).not.toBeNull()
1608
+ expect(hit!.datasetIndex).toBe(0)
1609
+ })
1610
+
1611
+ it('returns correct slice for "A" (50% = top half area)', () => {
1612
+ // Slice A occupies from -PI/2 to PI/2 (top half going clockwise, 50%)
1613
+ // Point at (200, 80) is directly above center, inside pie — should be slice A
1614
+ const hit = hitTestPie(200, 80, DATA, 400, 300)
1615
+ expect(hit?.pointIndex).toBe(0)
1616
+ expect(hit?.label).toBe('A')
1617
+ })
1618
+ })
1619
+ ```
1620
+
1621
+ - [ ] **Step 2: Run to verify FAIL**
1622
+
1623
+ ```bash
1624
+ npx vitest run tests/components/chart/drawPie.test.ts
1625
+ ```
1626
+ Expected: `Cannot find module '../../../src/components/chart/drawPie'`
1627
+
1628
+ - [ ] **Step 3: Create `src/components/chart/drawPie.ts`**
1629
+
1630
+ ```ts
1631
+ import type { ChartData } from '../../types'
1632
+ import type { TooltipHit } from './chartTypes'
1633
+
1634
+ export function drawPie(
1635
+ ctx: CanvasRenderingContext2D,
1636
+ data: ChartData,
1637
+ w: number,
1638
+ h: number,
1639
+ colors: string[]
1640
+ ): void {
1641
+ ctx.clearRect(0, 0, w, h)
1642
+
1643
+ const values = data.datasets[0]?.data ?? []
1644
+ const total = values.reduce((s, v) => s + v, 0) || 1
1645
+ const cx = w / 2
1646
+ const cy = h / 2
1647
+ const r = Math.min(w, h) * 0.4
1648
+
1649
+ let angle = -Math.PI / 2
1650
+
1651
+ for (const [i, v] of values.entries()) {
1652
+ const slice = (v / total) * Math.PI * 2
1653
+ ctx.beginPath()
1654
+ ctx.moveTo(cx, cy)
1655
+ ctx.arc(cx, cy, r, angle, angle + slice)
1656
+ ctx.closePath()
1657
+ ctx.fillStyle = colors[i]
1658
+ ctx.fill()
1659
+ ctx.strokeStyle = '#fff'
1660
+ ctx.lineWidth = 2
1661
+ ctx.stroke()
1662
+ angle += slice
1663
+ }
1664
+ }
1665
+
1666
+ export function hitTestPie(
1667
+ x: number,
1668
+ y: number,
1669
+ data: ChartData,
1670
+ w: number,
1671
+ h: number
1672
+ ): TooltipHit | null {
1673
+ const cx = w / 2
1674
+ const cy = h / 2
1675
+ const r = Math.min(w, h) * 0.4
1676
+
1677
+ const dx = x - cx
1678
+ const dy = y - cy
1679
+ if (Math.hypot(dx, dy) > r) return null
1680
+
1681
+ const values = data.datasets[0]?.data ?? []
1682
+ const total = values.reduce((s, v) => s + v, 0) || 1
1683
+
1684
+ // Normalize angle so 0 = top (-PI/2), going clockwise
1685
+ let angle = (Math.atan2(dy, dx) + Math.PI * 2.5) % (Math.PI * 2)
1686
+
1687
+ let cumAngle = 0
1688
+ for (const [i, v] of values.entries()) {
1689
+ const slice = (v / total) * Math.PI * 2
1690
+ if (angle >= cumAngle && angle < cumAngle + slice) {
1691
+ return { datasetIndex: 0, pointIndex: i, value: v, label: data.labels[i] }
1692
+ }
1693
+ cumAngle += slice
1694
+ }
1695
+ return null
1696
+ }
1697
+ ```
1698
+
1699
+ - [ ] **Step 4: Run to verify PASS**
1700
+
1701
+ ```bash
1702
+ npx vitest run tests/components/chart/drawPie.test.ts
1703
+ ```
1704
+ Expected: all 7 tests pass.
1705
+
1706
+ - [ ] **Step 5: Commit**
1707
+
1708
+ ```bash
1709
+ git add src/components/chart/drawPie.ts tests/components/chart/drawPie.test.ts
1710
+ git commit -m "feat(charts): add drawPie and hitTestPie"
1711
+ ```
1712
+
1713
+ ---
1714
+
1715
+ ## Task 11: VibeChartPie component
1716
+
1717
+ **Files:**
1718
+ - Create: `src/components/VibeChartPie.vue`
1719
+ - Create: `tests/components/VibeChartPie.test.ts`
1720
+
1721
+ - [ ] **Step 1: Write failing test**
1722
+
1723
+ ```ts
1724
+ // tests/components/VibeChartPie.test.ts
1725
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
1726
+ import { mount } from '@vue/test-utils'
1727
+ import VibeChartPie from '../../src/components/VibeChartPie.vue'
1728
+ import { mockCanvas, mockResizeObserver } from '../mocks/canvasMock'
1729
+ import type { ChartData } from '../../src/types'
1730
+
1731
+ const DATA: ChartData = {
1732
+ labels: ['A', 'B', 'C'],
1733
+ datasets: [{ label: 'Market share', data: [50, 30, 20] }],
1734
+ }
1735
+
1736
+ beforeEach(() => {
1737
+ mockCanvas()
1738
+ mockResizeObserver()
1739
+ })
1740
+
1741
+ afterEach(() => {
1742
+ vi.restoreAllMocks()
1743
+ vi.unstubAllGlobals()
1744
+ })
1745
+
1746
+ describe('VibeChartPie', () => {
1747
+ it('renders a canvas element', () => {
1748
+ const wrapper = mount(VibeChartPie, { props: { data: DATA } })
1749
+ expect(wrapper.find('canvas').exists()).toBe(true)
1750
+ })
1751
+
1752
+ it('renders legend at bottom by default', () => {
1753
+ const wrapper = mount(VibeChartPie, { props: { data: DATA } })
1754
+ expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(true)
1755
+ })
1756
+
1757
+ it('renders legend at top when legend="top"', () => {
1758
+ const wrapper = mount(VibeChartPie, { props: { data: DATA, legend: 'top' } })
1759
+ expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(true)
1760
+ })
1761
+
1762
+ it('hides legend when legend="none"', () => {
1763
+ const wrapper = mount(VibeChartPie, { props: { data: DATA, legend: 'none' } })
1764
+ expect(wrapper.find('.vibe-chart-legend').exists()).toBe(false)
1765
+ })
1766
+
1767
+ it('shows slice labels in legend (not dataset labels)', () => {
1768
+ const wrapper = mount(VibeChartPie, { props: { data: DATA } })
1769
+ const items = wrapper.findAll('.vibe-chart-legend-item')
1770
+ // Legend items = labels (A, B, C), not dataset label (Market share)
1771
+ expect(items).toHaveLength(3)
1772
+ expect(items[0].text()).toContain('A')
1773
+ expect(items[1].text()).toContain('B')
1774
+ expect(items[2].text()).toContain('C')
1775
+ })
1776
+
1777
+ it('calls clearRect when ResizeObserver triggers', () => {
1778
+ const ctx = mockCanvas()
1779
+ const ro = mockResizeObserver()
1780
+ mount(VibeChartPie, { props: { data: DATA } })
1781
+ ro.trigger(400, 400)
1782
+ expect(ctx.clearRect).toHaveBeenCalled()
1783
+ })
1784
+
1785
+ it('does not render axes or grid props', () => {
1786
+ // VibeChartPie has no showAxes/showGrid props — verify component mounts without them
1787
+ const wrapper = mount(VibeChartPie, { props: { data: DATA } })
1788
+ expect(wrapper.exists()).toBe(true)
1789
+ })
1790
+ })
1791
+ ```
1792
+
1793
+ - [ ] **Step 2: Run to verify FAIL**
1794
+
1795
+ ```bash
1796
+ npx vitest run tests/components/VibeChartPie.test.ts
1797
+ ```
1798
+ Expected: `Cannot find module '../../src/components/VibeChartPie.vue'`
1799
+
1800
+ - [ ] **Step 3: Create `src/components/VibeChartPie.vue`**
1801
+
1802
+ ```vue
1803
+ <script setup lang="ts">
1804
+ import { ref, computed, watch, onMounted, onUnmounted, type PropType } from 'vue'
1805
+ import type { ChartData, ChartLegendPosition } from '../types'
1806
+ import { resolveSliceColors } from './chart/chartColors'
1807
+ import { useChartResize } from './chart/chartResize'
1808
+ import { bindTooltip } from './chart/chartTooltip'
1809
+ import { drawPie, hitTestPie } from './chart/drawPie'
1810
+
1811
+ const props = defineProps({
1812
+ data: { type: Object as PropType<ChartData>, required: true },
1813
+ legend: { type: String as PropType<ChartLegendPosition>, default: 'bottom' },
1814
+ height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
1815
+ })
1816
+
1817
+ const containerEl = ref<HTMLElement | null>(null)
1818
+ const canvasEl = ref<HTMLCanvasElement | null>(null)
1819
+ let cleanupTooltip: (() => void) | null = null
1820
+ let currentW = 0
1821
+ let currentH = 0
1822
+
1823
+ const canvasContainerStyle = computed(() => {
1824
+ if (props.height === 'auto') return { width: '100%', aspectRatio: '1/1' }
1825
+ const h = typeof props.height === 'number' ? `${props.height}px` : (props.height as string)
1826
+ return { width: '100%', height: h }
1827
+ })
1828
+
1829
+ const resolvedColors = computed(() =>
1830
+ containerEl.value ? resolveSliceColors(props.data.labels.length, containerEl.value) : []
1831
+ )
1832
+
1833
+ function redraw() {
1834
+ const canvas = canvasEl.value!
1835
+ const ctx = canvas.getContext('2d')!
1836
+ const dpr = window.devicePixelRatio || 1
1837
+ canvas.width = currentW * dpr
1838
+ canvas.height = currentH * dpr
1839
+ ctx.scale(dpr, dpr)
1840
+ drawPie(ctx, props.data, currentW, currentH, resolvedColors.value)
1841
+ }
1842
+
1843
+ useChartResize(containerEl, canvasEl, (w, h) => {
1844
+ currentW = w
1845
+ currentH = h
1846
+ redraw()
1847
+ })
1848
+
1849
+ onMounted(() => {
1850
+ cleanupTooltip = bindTooltip(
1851
+ canvasEl.value!,
1852
+ (x, y) => hitTestPie(x, y, props.data, currentW, currentH),
1853
+ redraw
1854
+ )
1855
+ })
1856
+
1857
+ watch(() => props.data, redraw, { deep: true })
1858
+
1859
+ onUnmounted(() => {
1860
+ cleanupTooltip?.()
1861
+ })
1862
+ </script>
1863
+
1864
+ <template>
1865
+ <div class="vibe-chart">
1866
+ <div v-if="legend === 'top'" class="vibe-chart-legend vibe-chart-legend--top">
1867
+ <span v-for="(label, i) in data.labels" :key="i" class="vibe-chart-legend-item">
1868
+ <span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
1869
+ {{ label }}
1870
+ </span>
1871
+ </div>
1872
+ <div ref="containerEl" class="vibe-chart-canvas-container" :style="canvasContainerStyle">
1873
+ <canvas ref="canvasEl" style="display: block; width: 100%; height: 100%;" />
1874
+ </div>
1875
+ <div v-if="legend === 'bottom'" class="vibe-chart-legend vibe-chart-legend--bottom">
1876
+ <span v-for="(label, i) in data.labels" :key="i" class="vibe-chart-legend-item">
1877
+ <span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
1878
+ {{ label }}
1879
+ </span>
1880
+ </div>
1881
+ </div>
1882
+ </template>
1883
+
1884
+ <style scoped>
1885
+ .vibe-chart { width: 100%; }
1886
+ .vibe-chart-legend {
1887
+ display: flex;
1888
+ flex-wrap: wrap;
1889
+ gap: 0.5rem;
1890
+ padding: 0.25rem 0;
1891
+ }
1892
+ .vibe-chart-legend--top { margin-bottom: 0.25rem; }
1893
+ .vibe-chart-legend--bottom { margin-top: 0.25rem; }
1894
+ .vibe-chart-legend-item {
1895
+ display: flex;
1896
+ align-items: center;
1897
+ gap: 0.25rem;
1898
+ font-size: 0.875rem;
1899
+ }
1900
+ .vibe-chart-legend-swatch {
1901
+ display: inline-block;
1902
+ width: 12px;
1903
+ height: 12px;
1904
+ border-radius: 2px;
1905
+ flex-shrink: 0;
1906
+ }
1907
+ </style>
1908
+ ```
1909
+
1910
+ - [ ] **Step 4: Run to verify PASS**
1911
+
1912
+ ```bash
1913
+ npx vitest run tests/components/VibeChartPie.test.ts
1914
+ ```
1915
+ Expected: all 7 tests pass.
1916
+
1917
+ - [ ] **Step 5: Commit**
1918
+
1919
+ ```bash
1920
+ git add src/components/VibeChartPie.vue tests/components/VibeChartPie.test.ts
1921
+ git commit -m "feat(charts): add VibeChartPie component with slice-based legend and colors"
1922
+ ```
1923
+
1924
+ ---
1925
+
1926
+ ## Task 12: Wire exports and full test run
1927
+
1928
+ **Files:**
1929
+ - Modify: `src/types.ts` (already done in Task 1 — verify only)
1930
+ - Modify: `src/components/index.ts`
1931
+ - Modify: `src/index.ts`
1932
+
1933
+ - [ ] **Step 1: Add imports, exports, and plugin registration to `src/components/index.ts`**
1934
+
1935
+ Add after existing imports (around line with `// Export all components`):
1936
+
1937
+ ```ts
1938
+ // Chart Components
1939
+ import VibeChartLine from './VibeChartLine.vue'
1940
+ import VibeChartBar from './VibeChartBar.vue'
1941
+ import VibeChartPie from './VibeChartPie.vue'
1942
+ ```
1943
+
1944
+ Add to the `export { ... }` block:
1945
+ ```ts
1946
+ // Chart
1947
+ VibeChartLine,
1948
+ VibeChartBar,
1949
+ VibeChartPie,
1950
+ ```
1951
+
1952
+ Add to the `install(app)` function (after `// Data` section):
1953
+ ```ts
1954
+ // Chart
1955
+ app.component('VibeChartLine', VibeChartLine)
1956
+ app.component('VibeChartBar', VibeChartBar)
1957
+ app.component('VibeChartPie', VibeChartPie)
1958
+ ```
1959
+
1960
+ - [ ] **Step 2: Add re-exports to `src/index.ts`**
1961
+
1962
+ Add after existing component exports:
1963
+ ```ts
1964
+ export { default as VibeChartLine } from './components/VibeChartLine.vue'
1965
+ export { default as VibeChartBar } from './components/VibeChartBar.vue'
1966
+ export { default as VibeChartPie } from './components/VibeChartPie.vue'
1967
+ export type { ChartData, ChartDataset, ChartLegendPosition } from './types'
1968
+ ```
1969
+
1970
+ - [ ] **Step 3: Run full test suite**
1971
+
1972
+ ```bash
1973
+ npx vitest run
1974
+ ```
1975
+ Expected: all tests pass, no failures.
1976
+
1977
+ - [ ] **Step 4: TypeScript check**
1978
+
1979
+ ```bash
1980
+ npx vue-tsc --noEmit
1981
+ ```
1982
+ Expected: no errors.
1983
+
1984
+ - [ ] **Step 5: Commit**
1985
+
1986
+ ```bash
1987
+ git add src/components/index.ts src/index.ts
1988
+ git commit -m "feat(charts): register and export VibeChartLine, VibeChartBar, VibeChartPie"
1989
+ ```
1990
+
1991
+ ---
1992
+
1993
+ ## Self-Review Notes
1994
+
1995
+ - **Spec coverage:** All props covered (legend, height, showAxes, showGrid, smooth, fill, stacked). Color cycling via BS vars ✓. ResizeObserver ✓. Tooltip ✓. No fallbacks ✓. No external deps ✓.
1996
+ - **Type consistency:** `TooltipHit` defined once in `chartTypes.ts`, imported by drawLine/drawBar/drawPie and chartTooltip. `ChartData`/`ChartDataset`/`ChartLegendPosition` defined once in `src/types.ts`.
1997
+ - **Pie legend:** Iterates `data.labels` (slice names), not `data.datasets` (dataset names) — matches spec intent.
1998
+ - **Pie aspect ratio:** Default is `1/1` (square) rather than `16/9` — more appropriate for a pie chart.
1999
+ - **No placeholders:** All steps contain complete code.