Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.0",
"vitest": "^4.0.16"
"vitest": "^4.0.16",
"recharts": "2.15.4"
}
}
92 changes: 92 additions & 0 deletions apps/storybook/tests/Chart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChartContainer } from '@trycompai/design-system';
import { Area, AreaChart } from 'recharts';

// jsdom has no ResizeObserver, which recharts' ResponsiveContainer needs.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
);

describe('ChartContainer', () => {
it('renders a style block for a safe config', () => {
const { container } = render(
<ChartContainer
id='revenue'
config={{ revenue: { label: 'Revenue', color: '#22c55e' } }}
>
<AreaChart data={[{ value: 1 }]}>
<Area dataKey='value' />
</AreaChart>
</ChartContainer>,
);
const style = container.querySelector('style');
expect(style?.innerHTML).toContain('--color-revenue: #22c55e;');
});

it('drops config keys that could break out of the style element', () => {
const { container } = render(
<ChartContainer
id='revenue'
config={{
'x</style><img src=x onerror=alert(1)>': {
label: 'Evil',
color: 'red',
},
revenue: { label: 'Revenue', color: '#22c55e' },
}}
>
<AreaChart data={[{ value: 1 }]}>
<Area dataKey='value' />
</AreaChart>
</ChartContainer>,
);
const style = container.querySelector('style');
expect(style?.innerHTML).not.toContain('</style>');
expect(style?.innerHTML).not.toContain('onerror');
expect(style?.innerHTML).toContain('--color-revenue: #22c55e;');
expect(container.querySelector('img')).toBeNull();
});

it('drops color values that could break out of the style element', () => {
const { container } = render(
<ChartContainer
id='revenue'
config={{
revenue: {
label: 'Revenue',
color: 'red;}</style><script>alert(1)</script>',
},
}}
>
<AreaChart data={[{ value: 1 }]}>
<Area dataKey='value' />
</AreaChart>
</ChartContainer>,
);
const style = container.querySelector('style');
expect(style?.innerHTML).not.toContain('</style>');
expect(style?.innerHTML).not.toContain('--color-revenue');
expect(container.querySelector('script')).toBeNull();
});

it('does not render a style block for an unsafe id', () => {
const { container } = render(
<ChartContainer
id='x]</style><script>alert(1)</script>'
config={{ revenue: { label: 'Revenue', color: '#22c55e' } }}
>
<AreaChart data={[{ value: 1 }]}>
<Area dataKey='value' />
</AreaChart>
</ChartContainer>,
);
expect(container.querySelector('style')).toBeNull();
expect(container.querySelector('script')).toBeNull();
});
});
1 change: 0 additions & 1 deletion packages/design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"default": "./lib/utils.ts"
},
"./globals.css": "./src/styles/globals.css",
"./fonts/*": "./src/fonts/*",
"./tailwind.config": {
"types": "./tailwind.config.ts",
"import": "./tailwind.config.ts",
Expand Down
17 changes: 15 additions & 2 deletions packages/design-system/src/components/organisms/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,20 @@ function ChartContainer({
);
}

const CHART_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const CHART_CONFIG_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;

// Colors interpolated into the <style> block must not be able to close the
// element or break out of the declaration, so allow only a safe CSS value
// charset (hex/rgb/hsl values, var()/theme() functions, named colors).
const CHART_COLOR_PATTERN = /^[a-zA-Z0-9#%,.()\-\s]+$/;

const isSafeChartValue = (value: string, pattern: RegExp) => pattern.test(value) && !value.includes('<');

const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);

if (!colorConfig.length) {
if (!colorConfig.length || !isSafeChartValue(id, CHART_ID_PATTERN)) {
return null;
}

Expand All @@ -77,8 +87,11 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
if (!isSafeChartValue(key, CHART_CONFIG_KEY_PATTERN)) {
return null;
}
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
return color && isSafeChartValue(color, CHART_COLOR_PATTERN) ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
83 changes: 5 additions & 78 deletions packages/design-system/src/styles/globals.css
Original file line number Diff line number Diff line change
@@ -1,88 +1,15 @@
@import '@fontsource-variable/plus-jakarta-sans';
@import 'tailwindcss';
@import 'tw-animate-css';

/* Ramp font: Lausanne */
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-300.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-300.woff') format('woff');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-300-Italic.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-300-Italic.woff') format('woff');
font-weight: 300;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-350.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-350.woff') format('woff');
font-weight: 350;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-350-Italic.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-350-Italic.woff') format('woff');
font-weight: 350;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-400.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-400.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-400-Italic.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-400-Italic.woff') format('woff');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-700.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-700.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lausanne';
src:
url('../fonts/TWKLausanne/TWKLausanne-700-Italic.woff2') format('woff2'),
url('../fonts/TWKLausanne/TWKLausanne-700-Italic.woff') format('woff');
font-weight: 700;
font-style: italic;
font-display: swap;
}

/* Include design system components in Tailwind's content scan */
@source "../components/**/*.tsx";

@custom-variant dark (&:is(.dark *));

@layer base {
:root {
--font-sans: 'Lausanne', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-sans: 'Plus Jakarta Sans Variable', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
Expand Down Expand Up @@ -158,7 +85,7 @@
}

@theme inline {
--font-sans: 'Lausanne', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-sans: 'Plus Jakarta Sans Variable', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
Expand Down Expand Up @@ -239,8 +166,8 @@
}
}

/* Lausanne-only weight mapping.
We DO NOT have 500/600 files, so make Tailwind's common semantic weights resolve to real faces. */
/* Weight mapping for the Plus Jakarta Sans variable font.
Keep Tailwind's semantic weights on the same scale as the previous 350/400/700 ramp. */
@layer utilities {
.font-normal {
font-weight: 350;
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading