@flesler/cursor-canvas-web 1.4.0 → 1.5.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 (42) hide show
  1. package/dist/cursor-canvas-web.css +1 -0
  2. package/dist/cursor-canvas.cjs +1 -1
  3. package/dist/cursor-canvas.js +530 -565
  4. package/dist/pie-donut-DNbA0GeH.cjs +1 -0
  5. package/dist/pie-donut-qRBFP0tT.js +63 -0
  6. package/package.json +6 -2
  7. package/src/runtime/index.tsx +64 -0
  8. package/src/shim/__tests__/barChart.test.ts +61 -0
  9. package/src/shim/__tests__/canvas.smoke.test.tsx +209 -0
  10. package/src/shim/__tests__/category-theme.test.tsx +58 -0
  11. package/src/shim/__tests__/dag.test.ts +49 -0
  12. package/src/shim/__tests__/host-tokens.test.ts +106 -0
  13. package/src/shim/__tests__/lineChart.test.ts +28 -0
  14. package/src/shim/__tests__/useCanvasState.test.tsx +35 -0
  15. package/src/shim/__tests__/useHostTheme.test.tsx +43 -0
  16. package/src/shim/__tests__/valueAxis.test.ts +39 -0
  17. package/src/shim/bar-chart.tsx +77 -0
  18. package/src/shim/canvas-hooks.tsx +57 -0
  19. package/src/shim/canvas-mantine-root.tsx +129 -0
  20. package/src/shim/chart-shared.tsx +252 -0
  21. package/src/shim/cursor-canvas.tsx +136 -0
  22. package/src/shim/dag.ts +179 -0
  23. package/src/shim/diff.tsx +161 -0
  24. package/src/shim/host-tokens.ts +614 -0
  25. package/src/shim/line-chart.tsx +62 -0
  26. package/src/shim/pie-chart.tsx +127 -0
  27. package/src/shim/pie-donut.tsx +79 -0
  28. package/src/shim/theme.ts +77 -0
  29. package/src/shim/todo.tsx +184 -0
  30. package/src/shim/ui/actions.tsx +58 -0
  31. package/src/shim/ui/card-context.tsx +11 -0
  32. package/src/shim/ui/card.tsx +79 -0
  33. package/src/shim/ui/chevron.tsx +23 -0
  34. package/src/shim/ui/display.tsx +83 -0
  35. package/src/shim/ui/form.tsx +102 -0
  36. package/src/shim/ui/layout.tsx +67 -0
  37. package/src/shim/ui/rich.tsx +136 -0
  38. package/src/shim/ui/table.tsx +71 -0
  39. package/src/shim/ui/typography.tsx +129 -0
  40. package/src/shim/use-tokens.ts +13 -0
  41. package/src/superset/chart-extra.d.ts +11 -11
  42. package/src/superset/index.d.ts +4 -4
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const o=require("react/jsx-runtime"),u=require("@mantine/charts"),h=require("./cursor-canvas.cjs"),m=180,p=540;function x(t){return Math.max(28,Math.round(t*.22))}function y({total:t,family:r,primary:i,subtitle:e}){return o.jsxs("text",{x:"50%",y:"50%",textAnchor:"middle",dominantBaseline:"central",fontFamily:r,children:[o.jsx("tspan",{x:"50%",dy:"-0.4em",fontSize:20,fontWeight:600,fill:i,children:t}),o.jsx("tspan",{x:"50%",dy:"1.55em",fontSize:16,fontWeight:500,fill:e,children:"Total"})]})}function d({data:t,size:r,highlighted:i}){const e=h.useHostTheme(),s=t.reduce((n,c)=>n+c.value,0),l={startAngle:m,endAngle:p},a=n=>({opacity:i===null||i===n?1:.35});return o.jsx(u.DonutChart,{data:t,size:r,thickness:x(r),withTooltip:!0,cellProps:n=>a(n.name),...l,children:o.jsx(y,{total:s,family:e.typography.fontFamily,primary:e.text.primary,subtitle:e.text.secondary})})}exports.PieDonutChart=d;
@@ -0,0 +1,63 @@
1
+ import { jsx as i, jsxs as m } from "react/jsx-runtime";
2
+ import { DonutChart as p } from "@mantine/charts";
3
+ import { useHostTheme as u } from "./cursor-canvas.js";
4
+ const f = 180, h = 540;
5
+ function y(t) {
6
+ return Math.max(28, Math.round(t * 0.22));
7
+ }
8
+ function d({
9
+ total: t,
10
+ family: o,
11
+ primary: r,
12
+ subtitle: e
13
+ }) {
14
+ return /* @__PURE__ */ m(
15
+ "text",
16
+ {
17
+ x: "50%",
18
+ y: "50%",
19
+ textAnchor: "middle",
20
+ dominantBaseline: "central",
21
+ fontFamily: o,
22
+ children: [
23
+ /* @__PURE__ */ i("tspan", { x: "50%", dy: "-0.4em", fontSize: 20, fontWeight: 600, fill: r, children: t }),
24
+ /* @__PURE__ */ i("tspan", { x: "50%", dy: "1.55em", fontSize: 16, fontWeight: 500, fill: e, children: "Total" })
25
+ ]
26
+ }
27
+ );
28
+ }
29
+ function D({
30
+ data: t,
31
+ size: o,
32
+ highlighted: r
33
+ }) {
34
+ const e = u(), l = t.reduce((n, c) => n + c.value, 0), s = {
35
+ startAngle: f,
36
+ endAngle: h
37
+ }, a = (n) => ({
38
+ opacity: r === null || r === n ? 1 : 0.35
39
+ });
40
+ return /* @__PURE__ */ i(
41
+ p,
42
+ {
43
+ data: t,
44
+ size: o,
45
+ thickness: y(o),
46
+ withTooltip: !0,
47
+ cellProps: (n) => a(n.name),
48
+ ...s,
49
+ children: /* @__PURE__ */ i(
50
+ d,
51
+ {
52
+ total: l,
53
+ family: e.typography.fontFamily,
54
+ primary: e.text.primary,
55
+ subtitle: e.text.secondary
56
+ }
57
+ )
58
+ }
59
+ );
60
+ }
61
+ export {
62
+ D as PieDonutChart
63
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flesler/cursor-canvas-web",
3
3
  "private": false,
4
- "version": "1.4.0",
4
+ "version": "1.5.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/flesler/cursor-canvas-web.git"
@@ -20,9 +20,13 @@
20
20
  "node": ">=22.12.0",
21
21
  "npm": ">=10.0.0"
22
22
  },
23
- "sideEffects": false,
23
+ "sideEffects": [
24
+ "**/*.css"
25
+ ],
24
26
  "files": [
25
27
  "dist",
28
+ "src/shim",
29
+ "src/runtime",
26
30
  "src/vendor",
27
31
  "src/superset"
28
32
  ],
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Web host runtime for canvases.
3
+ *
4
+ * This is intentionally a SEPARATE entry point from the `cursor/canvas` shim:
5
+ * the shim must mirror the real SDK and invent no extra exports, whereas these
6
+ * helpers are web-only glue (a Mantine provider + a mount helper) that replace
7
+ * the boilerplate a consuming app would otherwise copy from `main.tsx`.
8
+ *
9
+ * Consumers must still import Mantine's core stylesheet once in their app:
10
+ * import '@mantine/core/styles.css';
11
+ * Chart canvases: `@mantine/charts/styles.css` is imported by bar/line/pie shim modules.
12
+ */
13
+ import { type MantineColorScheme } from '@mantine/core'
14
+ import { StrictMode, type ReactNode } from 'react'
15
+ import { createRoot, type Root } from 'react-dom/client'
16
+ import { CanvasMantineRoot } from '../shim/canvas-mantine-root'
17
+
18
+ export interface CanvasRootProps {
19
+ children: ReactNode;
20
+ /** Initial color scheme; defaults to following the OS preference. */
21
+ defaultColorScheme?: MantineColorScheme
22
+ }
23
+
24
+ /** @deprecated Use `CanvasMantineRoot` — same behavior. */
25
+ export const CanvasRoot = CanvasMantineRoot
26
+
27
+ export interface MountCanvasOptions {
28
+ defaultColorScheme?: MantineColorScheme;
29
+ /** Skip `StrictMode` (e.g. for libraries that double-render badly). */
30
+ strict?: boolean
31
+ }
32
+
33
+ /**
34
+ * One-call entry for a standalone page: resolves the container (element or id),
35
+ * mounts the canvas inside `CanvasMantineRoot`, and returns the React root so callers
36
+ * can `unmount()` later.
37
+ */
38
+ export function mountCanvas(
39
+ container: HTMLElement | string,
40
+ canvas: ReactNode,
41
+ options: MountCanvasOptions = {},
42
+ ): Root {
43
+ const { defaultColorScheme = 'auto', strict = true } = options
44
+ const element =
45
+ typeof container === 'string'
46
+ ? document.getElementById(container)
47
+ : container
48
+ if (!element) {
49
+ throw new Error(
50
+ `mountCanvas: container ${
51
+ typeof container === 'string' ? `#${container}` : ''
52
+ } not found`,
53
+ )
54
+ }
55
+
56
+ const root = createRoot(element)
57
+ const tree = (
58
+ <CanvasMantineRoot defaultColorScheme={defaultColorScheme}>
59
+ {canvas}
60
+ </CanvasMantineRoot>
61
+ )
62
+ root.render(strict ? <StrictMode>{tree}</StrictMode> : tree)
63
+ return root
64
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { horizontalBarProps, withPerCategoryBarColors } from '../chart-shared'
3
+
4
+ const seriesColor = (tone: string | undefined, index: number) =>
5
+ tone ?? `c${index}`
6
+
7
+ describe('withPerCategoryBarColors', () => {
8
+ const rows = [
9
+ { category: 'a', n: 1 },
10
+ { category: 'b', n: 2 },
11
+ ]
12
+
13
+ it('colors each category of a single series', () => {
14
+ expect(
15
+ withPerCategoryBarColors(
16
+ rows,
17
+ [{ name: 'n', data: [1, 2] }],
18
+ seriesColor,
19
+ ),
20
+ ).toEqual([
21
+ { category: 'a', n: 1, color: 'c0' },
22
+ { category: 'b', n: 2, color: 'c1' },
23
+ ])
24
+ })
25
+
26
+ it('keeps series colors for a toned or multi-series chart', () => {
27
+ expect(
28
+ withPerCategoryBarColors(
29
+ rows,
30
+ [{ name: 'n', data: [1, 2], tone: 'info' }],
31
+ seriesColor,
32
+ ),
33
+ ).toBe(rows)
34
+ expect(
35
+ withPerCategoryBarColors(
36
+ rows,
37
+ [
38
+ { name: 'n', data: [1, 2] },
39
+ { name: 'm', data: [3, 4] },
40
+ ],
41
+ seriesColor,
42
+ ),
43
+ ).toBe(rows)
44
+ })
45
+ })
46
+
47
+ describe('horizontalBarProps', () => {
48
+ it('draws only the value-axis grid', () => {
49
+ expect(horizontalBarProps().gridAxis).toBe('y')
50
+ })
51
+
52
+ it('widens the category axis with extra.categoryAxis.width', () => {
53
+ expect(horizontalBarProps().yAxisProps.width).toBe(96)
54
+ expect(horizontalBarProps(200).yAxisProps.width).toBe(200)
55
+ })
56
+
57
+ it('leaves room for value labels past the bar end', () => {
58
+ expect(horizontalBarProps(96, false).barChartProps.margin.right).toBe(32)
59
+ expect(horizontalBarProps(96, true).barChartProps.margin.right).toBe(48)
60
+ })
61
+ })
@@ -0,0 +1,209 @@
1
+ import { fireEvent, screen } from '@testing-library/react'
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+ import DemoCanvas from '../../../.cursor/canvases/demo.canvas'
4
+ import { renderWithProvider } from '../../test/render'
5
+
6
+ // useCanvasState persists the active tab to localStorage; clear it so each test
7
+ // starts from the canvas's default tab ('charts').
8
+ beforeEach(() => localStorage.clear())
9
+
10
+ describe('DemoCanvas showcase', () => {
11
+ it('renders the header theme toggle and repo link', () => {
12
+ renderWithProvider(<DemoCanvas />)
13
+
14
+ expect(
15
+ screen.getByRole('heading', { name: 'Cursor Canvas SDK showcase' }),
16
+ ).toBeInTheDocument()
17
+ expect(
18
+ screen.getByRole('link', { name: /^(Dark mode|Light mode)$/ }),
19
+ ).toBeInTheDocument()
20
+ expect(screen.getByRole('link', { name: 'open-canvas' })).toBeInTheDocument()
21
+ })
22
+
23
+ it('renders every section heading', () => {
24
+ renderWithProvider(<DemoCanvas />)
25
+
26
+ for (const title of [
27
+ 'How it works',
28
+ 'Charts, tables & context',
29
+ 'Chart guides & axes',
30
+ 'Graph layout (computeDAGLayout)',
31
+ 'Diff',
32
+ 'Disclosure & tasks',
33
+ 'Typography & theme',
34
+ 'Host brand (Cursor vs Grok)',
35
+ 'Forms (state persists via useCanvasState)',
36
+ 'Data imports (JSON, YAML, TSV, CSV, JSONL)',
37
+ ]) {
38
+ expect(screen.getByRole('heading', { name: title })).toBeInTheDocument()
39
+ }
40
+ })
41
+
42
+ it('renders the "How it works" steps and the Stat cards', () => {
43
+ renderWithProvider(<DemoCanvas />)
44
+
45
+ // Step titles ('Deploy' also names the form button), so match each unique
46
+ // step description instead.
47
+ for (const step of [
48
+ 'Write a .canvas.tsx that imports only cursor/canvas.',
49
+ 'Vite + tsconfig redirect cursor/canvas at build time.',
50
+ 'Mantine implements the same API, props, and tones.',
51
+ ]) {
52
+ expect(screen.getByText(step)).toBeInTheDocument()
53
+ }
54
+ for (const stat of [
55
+ 'Components',
56
+ 'Hooks',
57
+ 'Source of truth',
58
+ ]) {
59
+ expect(screen.getByText(stat)).toBeInTheDocument()
60
+ }
61
+ })
62
+
63
+ it('shows charts and callouts on the default tab', () => {
64
+ renderWithProvider(<DemoCanvas />)
65
+
66
+ // Chart bodies mount in jsdom once ResizeObserver reports layout size.
67
+ expect(screen.getByText('Latency (ms)')).toBeInTheDocument()
68
+ expect(screen.getByText('Requests by source')).toBeInTheDocument()
69
+ expect(screen.getByText('Test outcomes (pie)')).toBeInTheDocument()
70
+ expect(screen.getByText('Test outcomes (donut)')).toBeInTheDocument()
71
+ expect(screen.getAllByText(/75%/).length).toBeGreaterThanOrEqual(1)
72
+ expect(screen.getAllByText(/9\.9%/).length).toBeGreaterThanOrEqual(1)
73
+ expect(screen.getAllByText('Total').length).toBeGreaterThanOrEqual(1)
74
+ // Callout tones.
75
+ expect(screen.getByText('Deployed')).toBeInTheDocument()
76
+ expect(screen.getByText('1 service degraded.')).toBeInTheDocument()
77
+ expect(
78
+ screen.getByText('Contract tests track the IDE SDK snapshot.'),
79
+ ).toBeInTheDocument()
80
+ expect(
81
+ screen.getByText('Neutral callout for secondary notices.'),
82
+ ).toBeInTheDocument()
83
+ })
84
+
85
+ it('renders chart guide section headers (axis, SLO, normalized, horizontal)', () => {
86
+ renderWithProvider(<DemoCanvas />)
87
+
88
+ expect(
89
+ screen.getByText('Uptime % (SLO line, fill, tight axis)'),
90
+ ).toBeInTheDocument()
91
+ expect(screen.getByText('Traffic share (normalized)')).toBeInTheDocument()
92
+ expect(
93
+ screen.getByText('Lane depth (horizontal, long labels)'),
94
+ ).toBeInTheDocument()
95
+ expect(screen.getByText('Grouped bars + budget line')).toBeInTheDocument()
96
+ expect(screen.getByText('Dual series, filled area')).toBeInTheDocument()
97
+ expect(screen.getByText('Weekly volume')).toBeInTheDocument()
98
+ // Reference-line labels render in real layout (Playwright), not jsdom charts.
99
+ })
100
+
101
+ it('reveals the data Table when the "table" tab is selected', () => {
102
+ renderWithProvider(<DemoCanvas />)
103
+ fireEvent.click(screen.getByText('table'))
104
+
105
+ for (const header of ['Service', 'Status', 'RPS']) {
106
+ expect(screen.getByText(header)).toBeInTheDocument()
107
+ }
108
+ for (const service of ['api-gateway', 'workers', 'billing']) {
109
+ expect(screen.getByText(service)).toBeInTheDocument()
110
+ }
111
+ })
112
+
113
+ it('reveals the UsageBar and Swatch legend on the "context" tab', () => {
114
+ renderWithProvider(<DemoCanvas />)
115
+ fireEvent.click(screen.getByText('context'))
116
+
117
+ expect(screen.getByText('64% full')).toBeInTheDocument()
118
+ expect(screen.getByText('76.8K / 120K tokens')).toBeInTheDocument()
119
+ for (const legend of ['tools', 'rules', 'conversation', 'skills']) {
120
+ expect(screen.getByText(legend)).toBeInTheDocument()
121
+ }
122
+ })
123
+
124
+ it('renders the DAG graph from computeDAGLayout', () => {
125
+ renderWithProvider(<DemoCanvas />)
126
+
127
+ expect(
128
+ screen.getByRole('img', { name: 'Agent loop graph' }),
129
+ ).toBeInTheDocument()
130
+ })
131
+
132
+ it('renders the DiffView body and DiffStats', () => {
133
+ renderWithProvider(<DemoCanvas />)
134
+
135
+ expect(
136
+ screen.getByText('export default function Showcase() {'),
137
+ ).toBeInTheDocument()
138
+ expect(
139
+ screen.getByText('export default function Demo() {'),
140
+ ).toBeInTheDocument()
141
+ expect(screen.getByText('+1')).toBeInTheDocument()
142
+ expect(screen.getByText('-1')).toBeInTheDocument()
143
+ })
144
+
145
+ it('renders the CollapsibleSections and both todo lists', () => {
146
+ renderWithProvider(<DemoCanvas />)
147
+
148
+ expect(screen.getByText('Tools')).toBeInTheDocument()
149
+ expect(screen.getByText('Grep')).toBeInTheDocument()
150
+ // The same todos feed both TodoListCard and the selectable TodoList.
151
+ expect(
152
+ screen.getAllByText('Scaffold Vite + shim alias').length,
153
+ ).toBeGreaterThanOrEqual(2)
154
+ expect(screen.getByText('Selected: 3')).toBeInTheDocument()
155
+ })
156
+
157
+ it('renders the typography samples', () => {
158
+ renderWithProvider(<DemoCanvas />)
159
+
160
+ expect(
161
+ screen.getByRole('heading', { name: 'Heading level 3' }),
162
+ ).toBeInTheDocument()
163
+ expect(screen.getByText('Semibold emphasis.')).toBeInTheDocument()
164
+ expect(screen.getByText('Italic remark.')).toBeInTheDocument()
165
+ expect(screen.getByText('deleted')).toBeInTheDocument()
166
+ expect(screen.getByText('renamed')).toBeInTheDocument()
167
+ expect(
168
+ screen.getByText('Quaternary — footnotes and source lines.'),
169
+ ).toBeInTheDocument()
170
+ })
171
+
172
+ it('renders the form controls wired to useCanvasState', () => {
173
+ renderWithProvider(<DemoCanvas />)
174
+
175
+ expect(screen.getByText('Environment snapshot')).toBeInTheDocument()
176
+ for (const field of ['Environment', 'Filter', 'Release note']) {
177
+ expect(screen.getByText(field)).toBeInTheDocument()
178
+ }
179
+ expect(screen.getByPlaceholderText('service name…')).toBeInTheDocument()
180
+ expect(screen.getByPlaceholderText('What changed…')).toBeInTheDocument()
181
+ expect(screen.getByLabelText('Verbose logging')).toBeInTheDocument()
182
+ expect(screen.getByRole('switch')).toBeInTheDocument()
183
+ for (const button of ['Reset', 'Preview', 'Deploy']) {
184
+ expect(screen.getByRole('button', { name: button })).toBeInTheDocument()
185
+ }
186
+ })
187
+
188
+ it('renders the theme tokens and footer link', () => {
189
+ renderWithProvider(<DemoCanvas />)
190
+
191
+ expect(screen.getByText('Theme tokens (useHostTheme)')).toBeInTheDocument()
192
+ expect(screen.getByText(/brand: cursor/)).toBeInTheDocument()
193
+ for (const key of [
194
+ 'text.primary',
195
+ 'text.secondary',
196
+ 'text.tertiary',
197
+ 'text.quaternary',
198
+ 'chart.accent',
199
+ 'chart.link',
200
+ 'chart.strokePrimary',
201
+ 'category.orange',
202
+ ]) {
203
+ expect(screen.getByText(key)).toBeInTheDocument()
204
+ }
205
+ expect(
206
+ screen.getByRole('link', { name: 'open-canvas' }),
207
+ ).toBeInTheDocument()
208
+ })
209
+ })
@@ -0,0 +1,58 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { Swatch, UsageBar, useHostTheme } from 'cursor/canvas'
3
+ import type { ReactNode } from 'react'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { renderWithProvider } from '../../test/render'
6
+ import { CanvasMantineRoot } from '../canvas-mantine-root'
7
+
8
+ function schemeWrapper(scheme: 'light' | 'dark') {
9
+ return function Wrapper({ children }: { children: ReactNode }) {
10
+ return (
11
+ <CanvasMantineRoot defaultColorScheme={scheme}>
12
+ {children}
13
+ </CanvasMantineRoot>
14
+ )
15
+ }
16
+ }
17
+
18
+ describe('category colors on host theme', () => {
19
+ it('exposes category on the host theme root and under tokens', () => {
20
+ const { result } = renderHook(() => useHostTheme(), {
21
+ wrapper: schemeWrapper('dark'),
22
+ })
23
+ const theme = result.current
24
+ expect(Object.keys(theme)).toContain('category')
25
+ expect(theme.tokens.category).toBe(theme.category)
26
+ expect(theme.category.gray).toBe(theme.text.tertiary)
27
+ })
28
+
29
+ it('Swatch gray follows the active color scheme', () => {
30
+ const darkBg = renderWithProvider(<Swatch color="gray" />, 'dark')
31
+ .container.querySelector('span')?.style.background
32
+ const lightBg = renderWithProvider(<Swatch color="gray" />, 'light')
33
+ .container.querySelector('span')?.style.background
34
+ expect(darkBg).toBeTruthy()
35
+ expect(lightBg).toBeTruthy()
36
+ expect(darkBg).not.toBe(lightBg)
37
+ })
38
+
39
+ it('UsageBar segment matches Swatch for the same hue', () => {
40
+ const { container } = renderWithProvider(
41
+ <>
42
+ <Swatch color="purple" />
43
+ <UsageBar
44
+ total={1}
45
+ segments={[{ id: 'a', value: 1, color: 'purple' }]}
46
+ />
47
+ </>,
48
+ )
49
+ const swatchBg = container.querySelector('span')?.style.background
50
+ const segment = Array.from(
51
+ container.querySelectorAll<HTMLElement>('div'),
52
+ ).find(
53
+ el => el.style.borderRadius === '3px' && el.style.width === '100%',
54
+ )?.style.background
55
+ expect(swatchBg).toBeTruthy()
56
+ expect(segment).toBe(swatchBg)
57
+ })
58
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { computeDAGLayout } from 'cursor/canvas'
3
+
4
+ describe('computeDAGLayout', () => {
5
+ it('ranks a linear chain by longest path', () => {
6
+ const layout = computeDAGLayout({
7
+ nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
8
+ edges: [
9
+ { from: 'a', to: 'b' },
10
+ { from: 'b', to: 'c' },
11
+ ],
12
+ })
13
+ const rankOf = (id: string) => layout.nodes.find(n => n.id === id)?.rank
14
+
15
+ expect(rankOf('a')).toBe(0)
16
+ expect(rankOf('b')).toBe(1)
17
+ expect(rankOf('c')).toBe(2)
18
+ expect(layout.width).toBeGreaterThan(0)
19
+ expect(layout.height).toBeGreaterThan(0)
20
+ expect(layout.edges.every(e => !e.isBackEdge)).toBe(true)
21
+ })
22
+
23
+ it('detects a back-edge in a cycle and excludes it from ranking', () => {
24
+ const layout = computeDAGLayout({
25
+ nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
26
+ edges: [
27
+ { from: 'a', to: 'b' },
28
+ { from: 'b', to: 'c' },
29
+ { from: 'c', to: 'a' },
30
+ ],
31
+ })
32
+
33
+ const back = layout.edges.find(e => e.from === 'c' && e.to === 'a')
34
+ expect(back?.isBackEdge).toBe(true)
35
+ expect(layout.edges.filter(e => e.isBackEdge)).toHaveLength(1)
36
+ // Ranking still proceeds over the remaining DAG.
37
+ expect(layout.nodes.find(n => n.id === 'c')?.rank).toBe(2)
38
+ })
39
+
40
+ it('lays out horizontally when requested', () => {
41
+ const layout = computeDAGLayout({
42
+ nodes: [{ id: 'a' }, { id: 'b' }],
43
+ edges: [{ from: 'a', to: 'b' }],
44
+ direction: 'horizontal',
45
+ })
46
+ expect(layout.direction).toBe('horizontal')
47
+ expect(layout.width).toBeGreaterThan(layout.height)
48
+ })
49
+ })
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { applyPrimaryColor, buildHostTokens, canvasPaletteDark, canvasPaletteGrokDark, canvasPaletteLight, categoryPaletteDark, categoryPaletteLight, chartThemeForBrand, colorPalette, parseCanvasHostThemeBrand, resolveThemeForColorScheme, type Color } from '../host-tokens'
3
+
4
+ const CATEGORY_HUES: readonly Color[] = [
5
+ 'gray',
6
+ 'purple',
7
+ 'green',
8
+ 'yellow',
9
+ 'cyan',
10
+ 'pink',
11
+ 'blue',
12
+ 'orange',
13
+ 'red',
14
+ ]
15
+
16
+ describe('host-tokens', () => {
17
+ afterEach(() => {
18
+ delete (window as Window & { __cursorCanvas?: unknown }).__cursorCanvas
19
+ })
20
+
21
+ it('parseCanvasHostThemeBrand defaults unknown to cursor', () => {
22
+ expect(parseCanvasHostThemeBrand('grok')).toBe('grok')
23
+ expect(parseCanvasHostThemeBrand('sand')).toBe('cursor')
24
+ expect(parseCanvasHostThemeBrand(undefined)).toBe('cursor')
25
+ })
26
+
27
+ it('buildHostTokens keeps Grok inks when primary is sent', () => {
28
+ const { palette, brand } = buildHostTokens('dark', {
29
+ brand: 'grok',
30
+ primary: '#ff00ff',
31
+ })
32
+ expect(brand).toBe('grok')
33
+ expect(palette.buttonForeground).toBe(canvasPaletteGrokDark.buttonForeground)
34
+ expect(palette.accent).toBe(canvasPaletteGrokDark.accent)
35
+ })
36
+
37
+ it('buildHostTokens applies primary overlay for cursor brand', () => {
38
+ const { palette } = buildHostTokens('dark', {
39
+ brand: 'cursor',
40
+ primary: '#ff6600',
41
+ })
42
+ expect(palette.accent).toBe('#ff6600')
43
+ expect(palette.buttonBackground).toBe('#ff6600')
44
+ })
45
+
46
+ it('chartThemeForBrand returns Grok chart colors', () => {
47
+ const grok = chartThemeForBrand('grok')
48
+ const cursor = chartThemeForBrand('cursor')
49
+ expect(grok.palette.blue).toBe('#1084FEE0')
50
+ expect(cursor.palette.blue).toBe('#2E79B5E0')
51
+ expect(grok.sequence[0]).not.toBe(cursor.sequence[0])
52
+ })
53
+
54
+ it('resolveTheme reads IDE bridge brand', () => {
55
+ (window as Window & { __cursorCanvas?: { state: { theme: object } } })
56
+ .__cursorCanvas = {
57
+ state: {
58
+ theme: { kind: 'dark', brand: 'grok' },
59
+ },
60
+ }
61
+ const theme = resolveThemeForColorScheme('dark')
62
+ expect(theme.brand).toBe('grok')
63
+ expect(theme.chart.palette.blue).toBe('#1084FEE0')
64
+ expect(theme.radius.md).toBe(8)
65
+ expect(theme.typography.fontFamily).toContain('ui-sans-serif')
66
+ })
67
+
68
+ it('applyPrimaryColor ignores invalid hex', () => {
69
+ const base = buildHostTokens('dark', {}).palette
70
+ expect(applyPrimaryColor(base, 'not-a-color')).toBe(base)
71
+ })
72
+
73
+ describe('category palette', () => {
74
+ it('uses foregroundTertiary for gray on static exports', () => {
75
+ expect(categoryPaletteDark.gray).toBe(
76
+ canvasPaletteDark.foregroundTertiary,
77
+ )
78
+ expect(categoryPaletteLight.gray).toBe(
79
+ canvasPaletteLight.foregroundTertiary,
80
+ )
81
+ expect(categoryPaletteDark.gray).not.toBe(categoryPaletteLight.gray)
82
+ })
83
+
84
+ it('keeps colorPalette as the dark table alias', () => {
85
+ expect(colorPalette).toBe(categoryPaletteDark)
86
+ })
87
+
88
+ it.each(CATEGORY_HUES)(
89
+ 'buildHostTokens resolves category.%s in dark and light',
90
+ (hue) => {
91
+ const dark = buildHostTokens('dark', {}).tokens.category[hue]
92
+ const light = buildHostTokens('light', {}).tokens.category[hue]
93
+ expect(dark).toMatch(/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/)
94
+ expect(light).toMatch(/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/)
95
+ },
96
+ )
97
+
98
+ it('recomputes category gray after workbench foreground override', () => {
99
+ const { tokens } = buildHostTokens('dark', {
100
+ editorForeground: '#aabbcc',
101
+ })
102
+ expect(tokens.category.gray).toBe('#aabbcc')
103
+ expect(tokens.text.tertiary).toBe(tokens.category.gray)
104
+ })
105
+ })
106
+ })
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ChartSeries } from 'cursor/canvas'
3
+ import { reshapeLineChartData } from '../chart-shared'
4
+
5
+ describe('reshapeLineChartData', () => {
6
+ it('reshapes parallel categories + series into row objects', () => {
7
+ const categories = ['Mon', 'Tue', 'Wed']
8
+ const series: ChartSeries[] = [
9
+ { name: 'Current', data: [10, 20, 30], tone: 'info' },
10
+ { name: 'Previous', data: [5, 15, 25], tone: 'neutral' },
11
+ ]
12
+
13
+ expect(reshapeLineChartData(categories, series)).toEqual([
14
+ { category: 'Mon', Current: 10, Previous: 5 },
15
+ { category: 'Tue', Current: 20, Previous: 15 },
16
+ { category: 'Wed', Current: 30, Previous: 25 },
17
+ ])
18
+ })
19
+
20
+ it('fills missing data points with 0', () => {
21
+ const rows = reshapeLineChartData(['a', 'b'], [{ name: 'S', data: [7] }])
22
+
23
+ expect(rows).toEqual([
24
+ { category: 'a', S: 7 },
25
+ { category: 'b', S: 0 },
26
+ ])
27
+ })
28
+ })