Skip to content
Open
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
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions frontend/common/theme/__tests__/tagSwatches.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
19 changes: 19 additions & 0 deletions frontend/common/theme/contrast.ts
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 13 additions & 1 deletion frontend/common/theme/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
35 changes: 35 additions & 0 deletions frontend/common/theme/tokens.ts

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

89 changes: 50 additions & 39 deletions frontend/documentation/CategoricalPalette.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -60,30 +39,62 @@ export default meta
// Stories
// ---------------------------------------------------------------------------

export const TagColours: StoryObj = {
name: 'Tag colours',
const PRIMITIVES = tokens.primitives as Record<string, string>

// 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: () => (
<DocPage
title='Tag colours'
title='Tag swatches'
description={
<>
20 decorative colours users pick from when creating tags. Will be
defined in <code>_categorical.scss</code> as CSS custom properties (
<code>--color-tag-1</code> through <code>--color-tag-20</code>).
Currently in <code>constants.ts</code> pending migration. These are
NOT semantic tokens &mdash; 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&rsquo;s chosen hue. These
are the design system&rsquo;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{' '}
<code>tagSwatches.test.ts</code>.
</>
}
>
<div className='cat-grid'>
{TAG_COLOURS.map(({ hex, name }) => (
<Swatch key={hex} colour={hex} label={`${name}\n${hex}`} />
<div className='d-flex flex-wrap gap-3'>
{TAG_FILLS.map(([name, hex]) => (
<div
className='d-flex flex-column align-items-center gap-1'
key={name}
>
<Chip className={`border-0 tag-${name}`} size='xs'>
{name}
</Chip>
<small className='text-secondary'>
{contrastRatio(hex, TAG_INK).toFixed(2)}:1
</small>
</div>
))}
</div>
<p className='cat-note'>
Default tag colour: <code>{DEFAULT_TAG_COLOUR}</code>
System tags (Issue, PR, Stale, Unhealthy) are not on this scale. They
stay on existing tokens &mdash; <code>bg-surface-default</code>,{' '}
<code>border-default</code>, <code>text-default</code> &mdash; plus a
coloured icon, so the state is carried by the icon rather than the fill.
</p>
<div className='d-flex mt-3'>
<Chip
className='bg-surface-default border-default text-default'
size='xs'
>
System tag
</Chip>
</div>
</DocPage>
),
}
Expand Down
68 changes: 68 additions & 0 deletions frontend/scripts/generate-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 13 additions & 0 deletions frontend/web/styles/_token-utilities.scss

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

12 changes: 12 additions & 0 deletions frontend/web/styles/_tokens.scss

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

Loading