@flesler/cursor-canvas-web 1.3.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.
- package/dist/cursor-canvas-web.css +1 -0
- package/dist/cursor-canvas.cjs +1 -1
- package/dist/cursor-canvas.js +753 -766
- package/dist/pie-donut-DNbA0GeH.cjs +1 -0
- package/dist/pie-donut-qRBFP0tT.js +63 -0
- package/package.json +10 -3
- package/src/runtime/index.tsx +64 -0
- package/src/shim/__tests__/barChart.test.ts +61 -0
- package/src/shim/__tests__/canvas.smoke.test.tsx +209 -0
- package/src/shim/__tests__/category-theme.test.tsx +58 -0
- package/src/shim/__tests__/dag.test.ts +49 -0
- package/src/shim/__tests__/host-tokens.test.ts +106 -0
- package/src/shim/__tests__/lineChart.test.ts +28 -0
- package/src/shim/__tests__/useCanvasState.test.tsx +35 -0
- package/src/shim/__tests__/useHostTheme.test.tsx +43 -0
- package/src/shim/__tests__/valueAxis.test.ts +39 -0
- package/src/shim/bar-chart.tsx +77 -0
- package/src/shim/canvas-hooks.tsx +57 -0
- package/src/shim/canvas-mantine-root.tsx +129 -0
- package/src/shim/chart-shared.tsx +252 -0
- package/src/shim/cursor-canvas.tsx +136 -0
- package/src/shim/dag.ts +179 -0
- package/src/shim/diff.tsx +161 -0
- package/src/shim/host-tokens.ts +614 -0
- package/src/shim/line-chart.tsx +62 -0
- package/src/shim/pie-chart.tsx +127 -0
- package/src/shim/pie-donut.tsx +79 -0
- package/src/shim/theme.ts +77 -0
- package/src/shim/todo.tsx +184 -0
- package/src/shim/ui/actions.tsx +58 -0
- package/src/shim/ui/card-context.tsx +11 -0
- package/src/shim/ui/card.tsx +79 -0
- package/src/shim/ui/chevron.tsx +23 -0
- package/src/shim/ui/display.tsx +83 -0
- package/src/shim/ui/form.tsx +102 -0
- package/src/shim/ui/layout.tsx +67 -0
- package/src/shim/ui/rich.tsx +136 -0
- package/src/shim/ui/table.tsx +71 -0
- package/src/shim/ui/typography.tsx +129 -0
- package/src/shim/use-tokens.ts +13 -0
- package/src/superset/chart-extra.d.ts +13 -7
- 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,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flesler/cursor-canvas-web",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.
|
|
5
|
-
"repository":
|
|
4
|
+
"version": "1.5.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/flesler/cursor-canvas-web.git"
|
|
8
|
+
},
|
|
6
9
|
"type": "module",
|
|
7
10
|
"description": "Deploy your Cursor canvases to the web.",
|
|
8
11
|
"license": "MIT",
|
|
@@ -17,9 +20,13 @@
|
|
|
17
20
|
"node": ">=22.12.0",
|
|
18
21
|
"npm": ">=10.0.0"
|
|
19
22
|
},
|
|
20
|
-
"sideEffects":
|
|
23
|
+
"sideEffects": [
|
|
24
|
+
"**/*.css"
|
|
25
|
+
],
|
|
21
26
|
"files": [
|
|
22
27
|
"dist",
|
|
28
|
+
"src/shim",
|
|
29
|
+
"src/runtime",
|
|
23
30
|
"src/vendor",
|
|
24
31
|
"src/superset"
|
|
25
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
|
+
})
|