diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..8b5b1b531bb1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Generated from frontend/common/theme/tokens.json by `npm run generate:tokens`, +# which lint-staged reruns whenever that file changes. Collapsed in diffs and +# excluded from language stats: read the source, not the output. +frontend/common/theme/tokens.ts linguist-generated=true +frontend/web/styles/_tokens.scss linguist-generated=true +frontend/web/styles/_token-utilities.scss linguist-generated=true +frontend/documentation/TokenReference.generated.stories.tsx linguist-generated=true diff --git a/frontend/common/theme/__tests__/tagSwatches.test.ts b/frontend/common/theme/__tests__/tagSwatches.test.ts new file mode 100644 index 000000000000..732a612fa3c9 --- /dev/null +++ b/frontend/common/theme/__tests__/tagSwatches.test.ts @@ -0,0 +1,26 @@ +import { contentColours, contentInk } from 'common/theme/tokens' +import { AA_NORMAL_TEXT, contrastRatio } from 'common/theme/contrast' + +const swatches = Object.entries(contentColours) +const ratio = (hex: string) => contrastRatio(contentInk, hex) + +describe('tag swatches', () => { + // Or everything below runs against nothing and still passes. + it('finds swatches', () => { + expect(swatches.length).toBeGreaterThan(0) + }) + + it('clears AA for the label on every fill', () => { + const failing = swatches + // Negated rather than `<`: an unparseable hex gives NaN, and NaN is not + // less than anything, so a typo would pass this instead of failing it. + .filter(([, hex]) => !(ratio(hex) >= AA_NORMAL_TEXT)) + .map(([name, hex]) => `${name} ${ratio(hex).toFixed(2)}:1`) + expect(failing).toEqual([]) + }) + + it('gives every hue a distinct colour', () => { + const seen = new Set(swatches.map(([, hex]) => hex.toLowerCase())) + expect(seen.size).toBe(swatches.length) + }) +}) diff --git a/frontend/common/theme/contrast.ts b/frontend/common/theme/contrast.ts new file mode 100644 index 000000000000..28c64e120e1f --- /dev/null +++ b/frontend/common/theme/contrast.ts @@ -0,0 +1,19 @@ +// WCAG relative luminance and contrast ratio, per +// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance + +export const AA_NORMAL_TEXT = 4.5 + +export const relativeLuminance = (hex: string): number => { + const value = hex.replace('#', '') + const [r, g, b] = [0, 2, 4] + .map((i) => parseInt(value.substring(i, i + 2), 16) / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +export const contrastRatio = (a: string, b: string): number => { + const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort( + (x, y) => y - x, + ) + return (lighter + 0.05) / (darker + 0.05) +} diff --git a/frontend/common/theme/tokens.json b/frontend/common/theme/tokens.json index d75701d1ab1f..668c864ae1be 100644 --- a/frontend/common/theme/tokens.json +++ b/frontend/common/theme/tokens.json @@ -79,7 +79,19 @@ "orange-800": "#9f5208", "orange-900": "#7b4008", "orange-950": "#592f07", - "slate-1000": "#000000" + "slate-1000": "#000000", + "content-always-dark": "#080c17", + "content-blue": "#c7e6ff", + "content-light-blue": "#c9f1fe", + "content-light-brown": "#dfc8c6", + "content-light-green": "#d6f1d4", + "content-light-grey": "#eff0f3", + "content-light-mint": "#c7e7e2", + "content-light-peach": "#fcd6c7", + "content-light-pink": "#ffceda", + "content-light-purple": "#dacdde", + "content-light-red": "#ffe0df", + "content-light-yellow": "#f3edca" }, "color": { "surface": { diff --git a/frontend/common/theme/tokens.ts b/frontend/common/theme/tokens.ts index 025e5418cc4c..f990f6880649 100644 --- a/frontend/common/theme/tokens.ts +++ b/frontend/common/theme/tokens.ts @@ -272,3 +272,38 @@ export const fontWeightBold = 'var(--font-weight-bold, 700)' export const fontWeightMedium = 'var(--font-weight-medium, 500)' export const fontWeightRegular = 'var(--font-weight-regular, 400)' export const fontWeightSemibold = 'var(--font-weight-semibold, 600)' + +/** The label colour every tag fill is chosen to carry. */ +export const contentInk = '#080c17' + +/** One colour per tag hue, the same on both themes. */ +export const contentColours = { + 'blue': '#c7e6ff', + 'light-blue': '#c9f1fe', + 'light-brown': '#dfc8c6', + 'light-green': '#d6f1d4', + 'light-grey': '#eff0f3', + 'light-mint': '#c7e7e2', + 'light-peach': '#fcd6c7', + 'light-pink': '#ffceda', + 'light-purple': '#dacdde', + 'light-red': '#ffe0df', + 'light-yellow': '#f3edca', +} as const + +/** The palette in order, so a picker can map it without a cast. */ +export const contentColourNames = [ + 'blue', + 'light-blue', + 'light-brown', + 'light-green', + 'light-grey', + 'light-mint', + 'light-peach', + 'light-pink', + 'light-purple', + 'light-red', + 'light-yellow', +] as const + +export type ContentColour = (typeof contentColourNames)[number] diff --git a/frontend/documentation/CategoricalPalette.stories.tsx b/frontend/documentation/CategoricalPalette.stories.tsx index 2bcfcadc6a99..4965b6c6bd4f 100644 --- a/frontend/documentation/CategoricalPalette.stories.tsx +++ b/frontend/documentation/CategoricalPalette.stories.tsx @@ -2,40 +2,19 @@ import React from 'react' import type { Meta, StoryObj } from 'storybook' import './docs.scss' +import Chip from 'components/base/Chip' import DocPage from './components/DocPage' import Swatch from './components/Swatch' +import tokens from 'common/theme/tokens.json' +import { AA_NORMAL_TEXT, contrastRatio } from 'common/theme/contrast' // --------------------------------------------------------------------------- // Colour data — inlined to avoid importing Constants (which pulls in the // full app dependency tree and breaks Storybook's ESM context). -// Source of truth: common/constants.ts +// Source of truth: common/constants.ts. The tag scale is not copied here; it +// comes from tokens.json below, so it cannot drift. // --------------------------------------------------------------------------- -const TAG_COLOURS = [ - { hex: '#3d4db6', name: 'Indigo' }, - { hex: '#ea5a45', name: 'Coral' }, - { hex: '#c6b215', name: 'Gold' }, - { hex: '#60bd4e', name: 'Green' }, - { hex: '#fe5505', name: 'Orange' }, - { hex: '#1492f4', name: 'Blue' }, - { hex: '#14c0f4', name: 'Cyan' }, - { hex: '#c277e0', name: 'Lavender' }, - { hex: '#039587', name: 'Teal' }, - { hex: '#344562', name: 'Navy' }, - { hex: '#ffa500', name: 'Amber' }, - { hex: '#3cb371', name: 'Mint' }, - { hex: '#d3d3d3', name: 'Silver' }, - { hex: '#5D6D7E', name: 'Slate' }, - { hex: '#641E16', name: 'Maroon' }, - { hex: '#5B2C6F', name: 'Plum' }, - { hex: '#D35400', name: 'Burnt Orange' }, - { hex: '#F08080', name: 'Salmon' }, - { hex: '#AAC200', name: 'Lime' }, - { hex: '#DE3163', name: 'Cerise' }, -] - -const DEFAULT_TAG_COLOUR = '#dedede' - const PROJECT_COLOURS = [ '#906AF6', '#FAE392', @@ -60,30 +39,62 @@ export default meta // Stories // --------------------------------------------------------------------------- -export const TagColours: StoryObj = { - name: 'Tag colours', +const PRIMITIVES = tokens.primitives as Record + +// The Content palette, fixed rather than theme-aware: a tag chip carries its +// own surface, so it does not follow the page. One ink serves all of them. +const TAG_INK_NAME = 'content-always-dark' +const TAG_INK = PRIMITIVES[TAG_INK_NAME] +const TAG_FILLS = Object.entries(PRIMITIVES) + .filter(([name]) => name.startsWith('content-') && name !== TAG_INK_NAME) + .map(([name, hex]) => [name.replace('content-', ''), hex] as const) + +export const TagSwatches: StoryObj = { + name: 'Tag swatches', + parameters: { chromatic: { disableSnapshot: false } }, render: () => ( - 20 decorative colours users pick from when creating tags. Will be - defined in _categorical.scss as CSS custom properties ( - --color-tag-1 through --color-tag-20). - Currently in constants.ts pending migration. These are - NOT semantic tokens — they are categorical identifiers that need - to be visually distinct from each other. + The scale a custom tag picks from, replacing the runtime colour maths + that made contrast a function of the user’s chosen hue. These + are the design system’s Content colours, fixed in both themes + because a chip carries its own surface. Every one clears AA ( + {AA_NORMAL_TEXT}:1) against the shared ink, enforced by{' '} + tagSwatches.test.ts. } > -
- {TAG_COLOURS.map(({ hex, name }) => ( - +
+ {TAG_FILLS.map(([name, hex]) => ( +
+ + {name} + + + {contrastRatio(hex, TAG_INK).toFixed(2)}:1 + +
))}

- Default tag colour: {DEFAULT_TAG_COLOUR} + System tags (Issue, PR, Stale, Unhealthy) are not on this scale. They + stay on existing tokens — bg-surface-default,{' '} + border-default, text-default — plus a + coloured icon, so the state is carried by the icon rather than the fill.

+
+ + System tag + +
), } diff --git a/frontend/scripts/generate-tokens.mjs b/frontend/scripts/generate-tokens.mjs index c36e86cc0e42..1dd9bcd9c11d 100644 --- a/frontend/scripts/generate-tokens.mjs +++ b/frontend/scripts/generate-tokens.mjs @@ -138,6 +138,21 @@ function buildScssLines() { rootLines.push('') } + // Feature palettes. Themed like the semantic tokens, but scoped to one + // feature, so they sit outside `color` where only cross-cutting roles live. + if (json.tag) { + rootLines.push(' // Tag') + for (const [, entries] of sorted(json.tag)) { + for (const [, e] of sorted(entries)) { + rootLines.push(` ${e.cssVar}: ${toPrimitiveRef(e.light)};`) + if (e.dark && e.dark !== e.light) { + darkLines.push(` ${e.cssVar}: ${toPrimitiveRef(e.dark)};`) + } + } + } + rootLines.push('') + } + // Chart colour tokens if (json[CHART_CATEGORY]) { rootLines.push(' // Chart') @@ -348,11 +363,50 @@ function generateTs() { ...describedBlocks, '', ...flatLines, + '', + ...buildContentColours(), ] return output.join('\n') } +const TAG_INK = 'content-always-dark' + +// The one rule for what a tag can be set to. The ink is excluded by name, not +// by the shape of its name, so renaming it cannot quietly make it selectable. +function tagSwatchEntries() { + return Object.entries(json.primitives ?? {}).filter( + ([n]) => n.startsWith('content-') && n !== TAG_INK, + ) +} + +/** + * The Content palette as a map, for anything that needs to iterate it — + * the tag colour picker, most obviously. Keyed by the design system's own + * name so a swatch in Figma and a key here read the same. + */ +function buildContentColours() { + const entries = tagSwatchEntries() + if (!entries.length) return [] + const ink = json.primitives?.[TAG_INK] + return [ + '/** The label colour every tag fill is chosen to carry. */', + `export const contentInk = '${ink}'`, + '', + '/** One colour per tag hue, the same on both themes. */', + 'export const contentColours = {', + ...entries.map(([n, hex]) => ` '${n.replace('content-', '')}': '${hex}',`), + '} as const', + '', + '/** The palette in order, so a picker can map it without a cast. */', + 'export const contentColourNames = [', + ...entries.map(([n]) => ` '${n.replace('content-', '')}',`), + '] as const', + '', + 'export type ContentColour = (typeof contentColourNames)[number]', + ] +} + function generateMcpStory() { // Build all table rows const tables = [] @@ -470,6 +524,20 @@ function generateUtilities() { lines.push('') } + // Tag utilities. One class per hue: surface, ink and border are only + // accessible together, and applying a fill without its label colour is the + // bug this scale exists to fix. + const utilHues = tagSwatchEntries().map(([n]) => n.replace('content-', '')) + if (utilHues.length) { + lines.push('// Tags') + for (const hue of utilHues.sort()) { + lines.push( + `.tag-${hue} { background-color: var(--content-${hue}); color: var(--${TAG_INK}); --ds-chip-border: var(--content-${hue}); }`, + ) + } + lines.push('') + } + // Radius utilities if (json.radius) { lines.push('// Radius') diff --git a/frontend/web/styles/_token-utilities.scss b/frontend/web/styles/_token-utilities.scss index 488ed0b1c3db..65eea80eed55 100644 --- a/frontend/web/styles/_token-utilities.scss +++ b/frontend/web/styles/_token-utilities.scss @@ -54,6 +54,19 @@ .icon-success { color: var(--color-icon-success); fill: var(--color-icon-success); } .icon-warning { color: var(--color-icon-warning); fill: var(--color-icon-warning); } +// Tags +.tag-blue { background-color: var(--content-blue); color: var(--content-always-dark); --ds-chip-border: var(--content-blue); } +.tag-light-blue { background-color: var(--content-light-blue); color: var(--content-always-dark); --ds-chip-border: var(--content-light-blue); } +.tag-light-brown { background-color: var(--content-light-brown); color: var(--content-always-dark); --ds-chip-border: var(--content-light-brown); } +.tag-light-green { background-color: var(--content-light-green); color: var(--content-always-dark); --ds-chip-border: var(--content-light-green); } +.tag-light-grey { background-color: var(--content-light-grey); color: var(--content-always-dark); --ds-chip-border: var(--content-light-grey); } +.tag-light-mint { background-color: var(--content-light-mint); color: var(--content-always-dark); --ds-chip-border: var(--content-light-mint); } +.tag-light-peach { background-color: var(--content-light-peach); color: var(--content-always-dark); --ds-chip-border: var(--content-light-peach); } +.tag-light-pink { background-color: var(--content-light-pink); color: var(--content-always-dark); --ds-chip-border: var(--content-light-pink); } +.tag-light-purple { background-color: var(--content-light-purple); color: var(--content-always-dark); --ds-chip-border: var(--content-light-purple); } +.tag-light-red { background-color: var(--content-light-red); color: var(--content-always-dark); --ds-chip-border: var(--content-light-red); } +.tag-light-yellow { background-color: var(--content-light-yellow); color: var(--content-always-dark); --ds-chip-border: var(--content-light-yellow); } + // Radius .rounded-2xl { border-radius: var(--radius-2xl); } .rounded-full { border-radius: var(--radius-full); } diff --git a/frontend/web/styles/_tokens.scss b/frontend/web/styles/_tokens.scss index ce37f7bf5dfa..14927dc9b1ea 100644 --- a/frontend/web/styles/_tokens.scss +++ b/frontend/web/styles/_tokens.scss @@ -16,6 +16,18 @@ --blue-800: #0b576e; --blue-900: #094456; --blue-950: #07313e; + --content-always-dark: #080c17; + --content-blue: #c7e6ff; + --content-light-blue: #c9f1fe; + --content-light-brown: #dfc8c6; + --content-light-green: #d6f1d4; + --content-light-grey: #eff0f3; + --content-light-mint: #c7e7e2; + --content-light-peach: #fcd6c7; + --content-light-pink: #ffceda; + --content-light-purple: #dacdde; + --content-light-red: #ffe0df; + --content-light-yellow: #f3edca; --gold-50: #fefbf0; --gold-100: #fdf6e0; --gold-200: #faeec5;