Skip to content

Commit 17e41f8

Browse files
fix(tokens): show gradient value and hex in color token docs (#1931)
* fix(tokens): show gradient value and hex in color token docs The Tokens/Color Storybook docs read getComputedStyle().backgroundColor, which is rgba(0, 0, 0, 0) for gradient tokens since they are painted via background-image. Read background-image for gradients and display the gradient definition instead of the meaningless transparent value. Also derive and show a hex value for solid color tokens, keeping the rgba string alongside it for backwards compatibility. * test(tokens): cover color token value rendering Extract the token value formatting into a testable colorTokens module and add unit tests verifying that solid colors show both hex and the original rgba (kept for backwards compatibility) and that gradient tokens show the gradient definition instead of the transparent rgba(0, 0, 0, 0) fallback. Anchor the rgb->hex regex so it only parses bare color strings. * feat(tokens): split color token docs into Hex and rgba columns Show the hex and rgba values in separate columns in the Tokens/Color Storybook docs. Gradient tokens show the gradient definition in the rgba column and a dash in the hex column. Add an empty changeset since this is a Storybook-only change with no published package impact. * fix(tokens): show rgba (with alpha) in color token docs Address review feedback: the rgba column displayed rgb() for opaque colors since getComputedStyle omits the alpha channel. Normalize solid color values to the rgba() form with an explicit alpha so the column matches its label. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 68cf6f3 commit 17e41f8

4 files changed

Lines changed: 213 additions & 4 deletions

File tree

.changeset/tangy-aliens-hammer.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { getTokenHex, getTokenValue, rgbToHex, TokenCode, toRgba } from '../stories/colorTokens';
5+
6+
const GRADIENT = 'linear-gradient(136deg, rgb(61, 214, 245) 22.68%, rgb(64, 91, 255) 127.6%)';
7+
8+
describe('rgbToHex', () => {
9+
it('converts an opaque rgb() string to 6-digit hex', () => {
10+
expect(rgbToHex('rgb(61, 214, 245)')).toBe('#3DD6F5');
11+
});
12+
13+
it('converts a translucent rgba() string to 8-digit hex', () => {
14+
expect(rgbToHex('rgba(0, 0, 0, 0.5)')).toBe('#00000080');
15+
});
16+
17+
it('omits the alpha channel when fully opaque', () => {
18+
expect(rgbToHex('rgba(255, 53, 162, 1)')).toBe('#FF35A2');
19+
});
20+
21+
it('supports the space/slash-separated syntax', () => {
22+
expect(rgbToHex('rgb(61 214 245 / 0.5)')).toBe('#3DD6F580');
23+
});
24+
25+
it('returns null for non-color values such as gradients', () => {
26+
expect(rgbToHex(GRADIENT)).toBeNull();
27+
});
28+
});
29+
30+
describe('getTokenHex', () => {
31+
it('returns the hex for a solid color', () => {
32+
expect(getTokenHex({ color: 'rgb(61, 214, 245)', image: 'none' })).toBe('#3DD6F5');
33+
});
34+
35+
it('returns null for a gradient token', () => {
36+
expect(getTokenHex({ color: 'rgba(0, 0, 0, 0)', image: GRADIENT })).toBeNull();
37+
});
38+
39+
it('returns null when the value is not yet computed', () => {
40+
expect(getTokenHex(undefined)).toBeNull();
41+
});
42+
});
43+
44+
describe('toRgba', () => {
45+
it('adds an explicit alpha to opaque rgb() values', () => {
46+
expect(toRgba('rgb(61, 214, 245)')).toBe('rgba(61, 214, 245, 1)');
47+
});
48+
49+
it('preserves an existing alpha channel', () => {
50+
expect(toRgba('rgba(0, 0, 0, 0.5)')).toBe('rgba(0, 0, 0, 0.5)');
51+
});
52+
53+
it('leaves non-color values untouched', () => {
54+
expect(toRgba(GRADIENT)).toBe(GRADIENT);
55+
});
56+
});
57+
58+
describe('getTokenValue', () => {
59+
it('returns the rgba string for a solid color (kept for backwards compatibility)', () => {
60+
expect(getTokenValue({ color: 'rgb(61, 214, 245)', image: 'none' })).toBe(
61+
'rgba(61, 214, 245, 1)',
62+
);
63+
});
64+
65+
it('returns the gradient definition instead of the transparent fallback', () => {
66+
expect(getTokenValue({ color: 'rgba(0, 0, 0, 0)', image: GRADIENT })).toBe(GRADIENT);
67+
});
68+
69+
it('returns null when the value is not yet computed', () => {
70+
expect(getTokenValue(undefined)).toBeNull();
71+
});
72+
});
73+
74+
describe('TokenCode', () => {
75+
it('renders its content', () => {
76+
render(<TokenCode>#3DD6F5</TokenCode>);
77+
78+
expect(screen.getByText('#3DD6F5')).toBeInTheDocument();
79+
});
80+
81+
it('renders nothing when there is no content', () => {
82+
const { container } = render(<TokenCode>{null}</TokenCode>);
83+
84+
expect(container).toBeEmptyDOMElement();
85+
});
86+
});

packages/tokens/stories/color.stories.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
44
import { Button } from '../../components/src/Button';
55
import { ToastRegion, toastQueue } from '../../components/src/Toast';
66
import { Tooltip, TooltipTrigger } from '../../components/src/Tooltip';
7+
import { type ComputedValue, getTokenHex, getTokenValue, TokenCode } from './colorTokens';
78

89
export default {
910
title: 'Tokens/Color',
@@ -35,11 +36,18 @@ const flatten = (obj: Record<string, unknown>) => {
3536

3637
const TokenTable = ({ tokens }: { tokens: Record<string, string> }) => {
3738
const itemEls = useRef<Record<string, HTMLDivElement | null>>({});
38-
const [colors, setColors] = useState<Record<string, string>>({});
39+
const [colors, setColors] = useState<Record<string, ComputedValue>>({});
3940

4041
useEffect(() => {
4142
for (const [key, value] of Object.entries(itemEls.current)) {
42-
const item = { [key]: getComputedStyle(value as Element).backgroundColor };
43+
if (!value) {
44+
continue;
45+
}
46+
47+
const styles = getComputedStyle(value);
48+
const item = {
49+
[key]: { color: styles.backgroundColor, image: styles.backgroundImage },
50+
};
4351
setColors((c) => ({ ...c, ...item }));
4452
}
4553
}, []);
@@ -51,11 +59,15 @@ const TokenTable = ({ tokens }: { tokens: Record<string, string> }) => {
5159
<tr>
5260
<th />
5361
<th style={{ textAlign: 'left' }}>Name</th>
54-
<th style={{ textAlign: 'left' }}>Value</th>
62+
<th style={{ textAlign: 'left' }}>Hex</th>
63+
<th style={{ textAlign: 'left' }}>rgba</th>
5564
</tr>
5665
</thead>
5766
<tbody>
5867
{Object.entries(tokens).map(([key, value]) => {
68+
const computed = colors[key];
69+
const hex = getTokenHex(computed);
70+
5971
return (
6072
<tr key={key}>
6173
<td>
@@ -88,7 +100,14 @@ const TokenTable = ({ tokens }: { tokens: Record<string, string> }) => {
88100
<Tooltip placement="bottom">Copy to clipboard</Tooltip>
89101
</TooltipTrigger>
90102
</td>
91-
<td>{colors[key]}</td>
103+
<td>
104+
{/* Gradients have no single hex value. */}
105+
<TokenCode>{hex ?? (computed ? '—' : null)}</TokenCode>
106+
</td>
107+
<td>
108+
{/* rgba is kept for backwards compatibility now that hex is shown. */}
109+
<TokenCode muted={Boolean(hex)}>{getTokenValue(computed)}</TokenCode>
110+
</td>
92111
</tr>
93112
);
94113
})}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { ReactNode } from 'react';
2+
3+
export type ComputedValue = { color: string; image: string };
4+
5+
// Converts a computed `rgb()`/`rgba()` string to an uppercase hex string.
6+
// Handles both comma-separated (`rgb(61, 214, 245)`) and space/slash-separated
7+
// (`rgb(61 214 245 / 0.5)`) syntaxes returned by `getComputedStyle`. Alpha is
8+
// appended as an 8-digit hex only when the color is not fully opaque.
9+
export const rgbToHex = (rgb: string): string | null => {
10+
const match = rgb.trim().match(/^rgba?\(([^)]+)\)$/);
11+
12+
if (!match) {
13+
return null;
14+
}
15+
16+
const parts = match[1]
17+
.split(/[\s,/]+/)
18+
.map((part) => part.trim())
19+
.filter(Boolean);
20+
const [r, g, b] = parts.map((part) => Number.parseFloat(part));
21+
22+
if ([r, g, b].some((channel) => Number.isNaN(channel))) {
23+
return null;
24+
}
25+
26+
const toHex = (channel: number) => Math.round(channel).toString(16).padStart(2, '0');
27+
let hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
28+
29+
const alpha = parts[3] !== undefined ? Number.parseFloat(parts[3]) : 1;
30+
31+
if (!Number.isNaN(alpha) && alpha < 1) {
32+
hex += toHex(alpha * 255);
33+
}
34+
35+
return hex.toUpperCase();
36+
};
37+
38+
const isGradient = (computed: ComputedValue) =>
39+
Boolean(computed.image && computed.image !== 'none');
40+
41+
// Normalizes a computed `rgb()`/`rgba()` string to the `rgba()` form. Opaque
42+
// colors come back from `getComputedStyle` as `rgb(...)`, so add an explicit
43+
// alpha of `1` to keep the displayed value consistently rgba.
44+
export const toRgba = (color: string): string => {
45+
const match = color.trim().match(/^rgba?\(([^)]+)\)$/);
46+
47+
if (!match) {
48+
return color;
49+
}
50+
51+
const parts = match[1]
52+
.split(/[\s,/]+/)
53+
.map((part) => part.trim())
54+
.filter(Boolean);
55+
56+
if (parts.length < 3) {
57+
return color;
58+
}
59+
60+
const [r, g, b] = parts;
61+
const alpha = parts[3] ?? '1';
62+
63+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
64+
};
65+
66+
// Hex value for solid color tokens. Gradient tokens have no single hex value.
67+
export const getTokenHex = (computed?: ComputedValue): string | null => {
68+
if (!computed || isGradient(computed)) {
69+
return null;
70+
}
71+
72+
return rgbToHex(computed.color);
73+
};
74+
75+
// The raw value, kept for backwards compatibility: the gradient definition for
76+
// gradient tokens, otherwise the computed rgba string. Gradients are painted via
77+
// `background-image`, so their `background-color` is the meaningless transparent
78+
// default (`rgba(0, 0, 0, 0)`), which we intentionally avoid surfacing.
79+
export const getTokenValue = (computed?: ComputedValue): string | null => {
80+
if (!computed) {
81+
return null;
82+
}
83+
84+
return isGradient(computed) ? computed.image : toRgba(computed.color);
85+
};
86+
87+
export const TokenCode = ({ children, muted }: { children: ReactNode; muted?: boolean }) => {
88+
if (children == null) {
89+
return null;
90+
}
91+
92+
return (
93+
<span
94+
style={{
95+
font: 'var(--lp-text-code-1-regular)',
96+
...(muted ? { color: 'var(--lp-color-text-ui-secondary)' } : {}),
97+
}}
98+
>
99+
{children}
100+
</span>
101+
);
102+
};

0 commit comments

Comments
 (0)