Skip to content
Closed
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
25 changes: 8 additions & 17 deletions frontend/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,26 +725,17 @@ const Constants = {
'Receive a webhook for when feature values are changed.',
},
tagColors: [
'#3d4db6',
'#ea5a45',
'#c6b215',
'#60bd4e',
'#fe5505',
'#1492f4',
'#14c0f4',
'#c277e0',
'#039587',
'#344562',
'#ffa500',
'#3cb371',
'#d3d3d3',
'#5D6D7E',
'#641E16',
'#5B2C6F',
'#D35400',
'#F08080',
'#AAC200',
'#DE3163',
'#039587',
'#c6b215',
'#d35400',
'#f08080',
'#de3163',
'#c277e0',
'#641e16',
'#60bd4e',
],
untaggedTag: { color: '#dedede', label: 'Untagged' },
}
Expand Down
35 changes: 35 additions & 0 deletions frontend/common/theme/__tests__/tagSwatches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import tokens from 'common/theme/tokens.json'
import { AA_NORMAL_TEXT, contrastRatio } from 'common/theme/contrast'

const primitives = tokens.primitives as Record<string, string>

const swatches = Object.entries(primitives).filter(([name]) =>
name.startsWith('content-'),
)

// Every tag chip uses this one ink. The Content colours are fixed rather than
// theme-aware, because a chip carries its own surface and does not follow the
// page, so one ink has to work on all of them in both themes.
const TAG_INK = primitives['slate-600']

describe('tag swatches', () => {
it('has a swatch for every Content colour', () => {
expect(swatches.length).toBeGreaterThan(0)
})

describe.each(swatches)('%s', (_name, fill) => {
it('passes AA against the tag ink', () => {
expect(contrastRatio(fill, TAG_INK)).toBeGreaterThanOrEqual(
AA_NORMAL_TEXT,
)
})
})

// Tags are told apart by colour alone, so two swatches rendering alike is
// the same defect as failing contrast: #8465 found the picker offering 20
// options that resolved to 7 colours.
it('gives every swatch a distinct fill', () => {
const fills = swatches.map(([, hex]) => hex.toLowerCase())
expect(new Set(fills).size).toBe(fills.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-blue": "#c7e6ff",
"content-light-blue": "#c9f1fe",
"content-light-grey": "#eff0f3",
"content-light-mint": "#c7e7e2",
"content-light-yellow": "#f3edca",
"content-light-peach": "#fcd6c7",
"content-light-red": "#ffe0df",
"content-light-pink": "#ffceda",
"content-light-purple": "#dacdde",
"content-light-brown": "#dfc8c6",
"content-light-green": "#d6f1d4"

},
"color": {
"surface": {
Expand Down
17 changes: 17 additions & 0 deletions frontend/common/theme/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,20 @@ 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)'

/** Tag and read-only content colours. Fixed: they do not follow the theme. */
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

export type ContentColour = keyof typeof contentColours
4 changes: 3 additions & 1 deletion frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,9 @@ export type APIKey = {
name: string
}

export type TagType = 'STALE' | 'UNHEALTHY' | 'NONE'
// Mirrors TagType in api/projects/tags/models.py. GITHUB and GITLAB were
// missing, though the UI has always branched on them to pick a VCS icon.
export type TagType = 'NONE' | 'STALE' | 'GITHUB' | 'UNHEALTHY' | 'GITLAB'

export type Tag = {
id: number
Expand Down
88 changes: 49 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,61 @@ 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_FILLS = Object.entries(PRIMITIVES)
.filter(([name]) => name.startsWith('content-'))
.map(([name, hex]) => [name.replace('content-', ''), hex] as const)
const TAG_INK = PRIMITIVES['slate-600']

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
Loading
Loading