Skip to content
Draft
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
39 changes: 39 additions & 0 deletions frontend/common/theme/__tests__/tagSwatches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import tokens from 'common/theme/tokens.json'
import { AA_NORMAL_TEXT, contrastRatio } from 'common/theme/contrast'

type Entry = { cssVar: string; light: string; dark: string }

const surfaces = tokens.tag.surface as Record<string, Entry>
const texts = tokens.tag.text as Record<string, Entry>
const primitives = tokens.primitives as Record<string, string>

Check warning on line 8 in frontend/common/theme/__tests__/tagSwatches.test.ts

View workflow job for this annotation

GitHub Actions / Lint changed files

'primitives' is assigned a value but never used. Allowed unused vars must match /^_/u

const hues = Object.keys(surfaces)

describe('tag swatches', () => {
it('defines a surface and a text token for every hue', () => {
expect(hues.length).toBeGreaterThan(0)
expect(Object.keys(texts)).toEqual(hues)
})

describe.each(hues)('%s', (hue) => {
const surface = surfaces[hue]
const text = texts[hue]

it.each(['light', 'dark'] as const)('passes AA in %s mode', (theme) => {
expect(contrastRatio(surface[theme], text[theme])).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.each(['light', 'dark'] as const)(
'gives every swatch a distinct surface in %s mode',
(theme) => {
const fills = hues.map((hue) => surfaces[hue][theme].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)
}
46 changes: 46 additions & 0 deletions frontend/common/theme/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,52 @@
"info": { "cssVar": "--color-icon-info", "light": "#0aaddf", "dark": "#0aaddf" }
}
},
"tag": {
"surface": {
"indigo": { "cssVar": "--color-tag-indigo-surface", "light": "#ddeaff", "dark": "#222a51", "description": "Custom tag fill, indigo. Pairs with --color-tag-indigo-text." },
"coral": { "cssVar": "--color-tag-coral-surface", "light": "#ffddd3", "dark": "#4b1e17", "description": "Custom tag fill, coral. Pairs with --color-tag-coral-text." },
"gold": { "cssVar": "--color-tag-gold-surface", "light": "#f3edbf", "dark": "#362e00", "description": "Custom tag fill, gold. Pairs with --color-tag-gold-text." },
"green": { "cssVar": "--color-tag-green-surface", "light": "#d6f6d0", "dark": "#183612", "description": "Custom tag fill, green. Pairs with --color-tag-green-text." },
"orange": { "cssVar": "--color-tag-orange-surface", "light": "#ffdece", "dark": "#4a1f11", "description": "Custom tag fill, orange. Pairs with --color-tag-orange-text." },
"blue": { "cssVar": "--color-tag-blue-surface", "light": "#cdefff", "dark": "#0c2f4f", "description": "Custom tag fill, blue. Pairs with --color-tag-blue-text." },
"cyan": { "cssVar": "--color-tag-cyan-surface", "light": "#c1f4ff", "dark": "#003449", "description": "Custom tag fill, cyan. Pairs with --color-tag-cyan-text." },
"lavender": { "cssVar": "--color-tag-lavender-surface", "light": "#fbe0ff", "dark": "#3b2245", "description": "Custom tag fill, lavender. Pairs with --color-tag-lavender-text." },
"teal": { "cssVar": "--color-tag-teal-surface", "light": "#bff9ef", "dark": "#003931", "description": "Custom tag fill, teal. Pairs with --color-tag-teal-text." },
"navy": { "cssVar": "--color-tag-navy-surface", "light": "#d7ecff", "dark": "#1e2e49", "description": "Custom tag fill, navy. Pairs with --color-tag-navy-text." },
"amber": { "cssVar": "--color-tag-amber-surface", "light": "#ffe5c0", "dark": "#432600", "description": "Custom tag fill, amber. Pairs with --color-tag-amber-text." },
"mint": { "cssVar": "--color-tag-mint-surface", "light": "#ccf8da", "dark": "#05381e", "description": "Custom tag fill, mint. Pairs with --color-tag-mint-text." },
"silver": { "cssVar": "--color-tag-silver-surface", "light": "#ebebeb", "dark": "#2e2e2e", "description": "Custom tag fill, silver. Pairs with --color-tag-silver-text." },
"slate": { "cssVar": "--color-tag-slate-surface", "light": "#dbedff", "dark": "#212f3e", "description": "Custom tag fill, slate. Pairs with --color-tag-slate-text." },
"maroon": { "cssVar": "--color-tag-maroon-surface", "light": "#ffddd4", "dark": "#4b1e18", "description": "Custom tag fill, maroon. Pairs with --color-tag-maroon-text." },
"plum": { "cssVar": "--color-tag-plum-surface", "light": "#f4d9ff", "dark": "#3b2246", "description": "Custom tag fill, plum. Pairs with --color-tag-plum-text." },
"burnt-orange": { "cssVar": "--color-tag-burnt-orange-surface", "light": "#ffdfcb", "dark": "#4a200d", "description": "Custom tag fill, burnt-orange. Pairs with --color-tag-burnt-orange-text." },
"salmon": { "cssVar": "--color-tag-salmon-surface", "light": "#ffdcda", "dark": "#4b1d1e", "description": "Custom tag fill, salmon. Pairs with --color-tag-salmon-text." },
"lime": { "cssVar": "--color-tag-lime-surface", "light": "#e7f1c4", "dark": "#2b3200", "description": "Custom tag fill, lime. Pairs with --color-tag-lime-text." },
"cerise": { "cssVar": "--color-tag-cerise-surface", "light": "#ffdbe2", "dark": "#4a1d26", "description": "Custom tag fill, cerise. Pairs with --color-tag-cerise-text." }
},
"text": {
"indigo": { "cssVar": "--color-tag-indigo-text", "light": "#3948ac", "dark": "#a7baff", "description": "Custom tag label, indigo. Pairs with --color-tag-indigo-surface." },
"coral": { "cssVar": "--color-tag-coral-text", "light": "#9a2111", "dark": "#f7a697", "description": "Custom tag label, coral. Pairs with --color-tag-coral-surface." },
"gold": { "cssVar": "--color-tag-gold-text", "light": "#695400", "dark": "#cbc072", "description": "Custom tag label, gold. Pairs with --color-tag-gold-surface." },
"green": { "cssVar": "--color-tag-green-text", "light": "#076900", "dark": "#9bce91", "description": "Custom tag label, green. Pairs with --color-tag-green-surface." },
"orange": { "cssVar": "--color-tag-orange-text", "light": "#992500", "dark": "#f6a78e", "description": "Custom tag label, orange. Pairs with --color-tag-orange-surface." },
"blue": { "cssVar": "--color-tag-blue-text", "light": "#0055a9", "dark": "#8bc3fc", "description": "Custom tag label, blue. Pairs with --color-tag-blue-surface." },
"cyan": { "cssVar": "--color-tag-cyan-text", "light": "#006191", "dark": "#72cbef", "description": "Custom tag label, cyan. Pairs with --color-tag-cyan-surface." },
"lavender": { "cssVar": "--color-tag-lavender-text", "light": "#763090", "dark": "#d7aaea", "description": "Custom tag label, lavender. Pairs with --color-tag-lavender-surface." },
"teal": { "cssVar": "--color-tag-teal-text", "light": "#00675b", "dark": "#6bd3c4", "description": "Custom tag label, teal. Pairs with --color-tag-teal-surface." },
"navy": { "cssVar": "--color-tag-navy-text", "light": "#445674", "dark": "#aabfe1", "description": "Custom tag label, navy. Pairs with --color-tag-navy-surface." },
"amber": { "cssVar": "--color-tag-amber-text", "light": "#8a3d00", "dark": "#e7b374", "description": "Custom tag label, amber. Pairs with --color-tag-amber-surface." },
"mint": { "cssVar": "--color-tag-mint-text", "light": "#006b2e", "dark": "#88d1a2", "description": "Custom tag label, mint. Pairs with --color-tag-mint-surface." },
"silver": { "cssVar": "--color-tag-silver-text", "light": "#555555", "dark": "#bebebe", "description": "Custom tag label, silver. Pairs with --color-tag-silver-surface." },
"slate": { "cssVar": "--color-tag-slate-text", "light": "#485767", "dark": "#aec0d3", "description": "Custom tag label, slate. Pairs with --color-tag-slate-surface." },
"maroon": { "cssVar": "--color-tag-maroon-text", "light": "#853c32", "dark": "#f7a598", "description": "Custom tag label, maroon. Pairs with --color-tag-maroon-surface." },
"plum": { "cssVar": "--color-tag-plum-text", "light": "#6d3e82", "dark": "#d6aaeb", "description": "Custom tag label, plum. Pairs with --color-tag-plum-surface." },
"burnt-orange": { "cssVar": "--color-tag-burnt-orange-text", "light": "#982800", "dark": "#f5a989", "description": "Custom tag label, burnt-orange. Pairs with --color-tag-burnt-orange-surface." },
"salmon": { "cssVar": "--color-tag-salmon-text", "light": "#922b32", "dark": "#f8a4a2", "description": "Custom tag label, salmon. Pairs with --color-tag-salmon-surface." },
"lime": { "cssVar": "--color-tag-lime-text", "light": "#4f5f00", "dark": "#b7c77c", "description": "Custom tag label, lime. Pairs with --color-tag-lime-surface." },
"cerise": { "cssVar": "--color-tag-cerise-text", "light": "#991b41", "dark": "#f6a3af", "description": "Custom tag label, cerise. Pairs with --color-tag-cerise-surface." }
}
},
"chart": {
"1": { "cssVar": "--color-chart-1", "light": "#0aaddf", "dark": "#45bce0", "description": "First series in charts. Blue." },
"2": { "cssVar": "--color-chart-2", "light": "#ef4d56", "dark": "#f57c78", "description": "Second series. Red." },
Expand Down
4 changes: 3 additions & 1 deletion frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,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
76 changes: 72 additions & 4 deletions frontend/documentation/CategoricalPalette.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ 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
Expand Down Expand Up @@ -67,10 +70,10 @@ export const TagColours: StoryObj = {
title='Tag colours'
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
The 20 decorative colours users currently pick from when creating a
tag, held in <code>constants.ts</code>. Tags derive their fill, border
and text from these at render time, which is why most of them fail
WCAG AA. #8465 replaces that with the validated scale below. These are
NOT semantic tokens &mdash; they are categorical identifiers that need
to be visually distinct from each other.
</>
Expand All @@ -88,6 +91,71 @@ export const TagColours: StoryObj = {
),
}

type TagEntry = { cssVar: string; light: string; dark: string }

const TAG_SURFACES = tokens.tag.surface as Record<string, TagEntry>
const TAG_TEXTS = tokens.tag.text as Record<string, TagEntry>
const TAG_HUES = Object.keys(TAG_SURFACES)

export const TagSwatches: StoryObj = {
name: 'Tag swatches',
parameters: { chromatic: { disableSnapshot: false } },
render: () => (
<DocPage
title='Tag swatches'
description={
<>
The scale a custom tag picks from, replacing the runtime colour maths
that made contrast a function of the user&rsquo;s chosen hue. Each hue
is a <code>surface</code> and <code>text</code> pair built from the
primitive ramps, so a ramp change carries through. Ratios below are
for the current theme; every pair clears AA ({AA_NORMAL_TEXT}:1) in
both, enforced by <code>tagSwatches.test.ts</code>.
</>
}
>
<div className='d-flex flex-wrap gap-3'>
{TAG_HUES.map((hue) => (
<div
className='d-flex flex-column align-items-center gap-1'
key={hue}
>
<Chip className={`border-0 tag-${hue}`} size='xs'>
{hue}
</Chip>
<small className='text-secondary'>
{contrastRatio(
TAG_SURFACES[hue].light,
TAG_TEXTS[hue].light,
).toFixed(2)}
:1 light &middot;{' '}
{contrastRatio(
TAG_SURFACES[hue].dark,
TAG_TEXTS[hue].dark,
).toFixed(2)}
:1 dark
</small>
</div>
))}
</div>
<p className='cat-note'>
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>
),
}

export const ProjectColours: StoryObj = {
name: 'Project colours',
render: () => (
Expand Down
105 changes: 103 additions & 2 deletions frontend/documentation/components/Chip.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React from 'react'
import type { Meta, StoryObj } from 'storybook'

import Chip from 'components/base/Chip'
import Chip, { ChipDot } from 'components/base/Chip'
import Icon, { IconName } from 'components/icons/Icon'
import Constants from 'common/constants'
import { getTagSwatchUtilities } from 'components/tags/tagSwatch'

const meta: Meta<typeof Chip> = {
args: { children: 'Production' },
Expand All @@ -11,7 +14,7 @@ const meta: Meta<typeof Chip> = {
docs: {
description: {
component:
'Canonical token-based chip primitive: a small labelled pill token. Layout via Bootstrap utilities, colour/radius via token utilities, padding/sizes/border/truncation in SCSS. Leading/trailing icons go in as children. Selection lives in ToggleChip and count badges are a separate Badge concern. The legacy `.chip` (old SCSS vars + manual dark-mode block, ~35×) migrates onto this under #6606.',
'Canonical token-based chip primitive: a small labelled pill token. Layout via Bootstrap utilities, colour/radius via token utilities, padding/sizes/border/truncation in SCSS. Leading/trailing icons go in as children. `variant` covers neutral, accent, the five status colours and `solid`; `ChipDot` adds the leading dot in `currentColor`. Radius is a fixed 6px from the tags frame, so there is no shape prop. Selection lives in ToggleChip. The legacy `.chip` (old SCSS vars + manual dark-mode block, ~35×) migrates onto this under #6606.',
},
},
layout: 'centered',
Expand All @@ -28,6 +31,61 @@ export const Accent: Story = {
args: { children: '"hello"', variant: 'accent' },
}

export const Solid: Story = {
args: { children: 'Enterprise', variant: 'solid' },
}

// How Tag composes Chip: a decorative colour a user picked is not a semantic
// variant, so it arrives as a tag-* swatch class in className. System
// tags take none of it, and carry their state in the icon instead.
const SYSTEM_TAGS: { label: string; icon: IconName }[] = [
{ icon: 'issue-closed', label: 'Issue closed' },
{ icon: 'issue-linked', label: 'Issue open' },
{ icon: 'pr-closed', label: 'PR closed' },
{ icon: 'pr-dequeued', label: 'PR dequeued' },
{ icon: 'pr-draft', label: 'PR draft' },
{ icon: 'stale', label: 'Stale' },
{ icon: 'pr-linked', label: 'PR open' },
{ icon: 'pr-merged', label: 'PR merged' },
]

export const AsSystemTag: Story = {
name: 'As a system tag',
parameters: { chromatic: { disableSnapshot: false } },
render: () => (
<div className='d-flex flex-wrap gap-2'>
{SYSTEM_TAGS.map(({ icon, label }) => (
<Chip
className='bg-surface-default border-default text-default'
key={label}
size='xs'
>
{label}
<Icon name={icon} />
</Chip>
))}
</div>
),
}

export const AsCustomTag: Story = {
name: 'As a custom tag',
parameters: { chromatic: { disableSnapshot: false } },
render: () => (
<div className='d-flex flex-wrap gap-2'>
{Constants.tagColors.map((colour: string) => (
<Chip
className={`border-0 ${getTagSwatchUtilities(colour)}`}
key={colour}
size='xs'
>
Custom
</Chip>
))}
</div>
),
}

export const Sizes: Story = {
render: () => (
<div className='d-flex align-items-center gap-2'>
Expand All @@ -38,6 +96,49 @@ export const Sizes: Story = {
),
}

export const StatusVariants: Story = {
render: () => (
<div className='d-flex align-items-center gap-2'>
<Chip variant='info' size='sm'>
<ChipDot />
Draft
</Chip>
<Chip variant='success' size='sm'>
<ChipDot />
Running
</Chip>
<Chip variant='warning' size='sm'>
<ChipDot />
Paused
</Chip>
<Chip variant='danger' size='sm'>
<ChipDot />
Failed
</Chip>
<Chip variant='muted' size='sm'>
<ChipDot />
Completed
</Chip>
</div>
),
}

export const Counts: Story = {
render: () => (
<div className='d-flex align-items-center gap-2'>
<Chip variant='accent' size='xs'>
5
</Chip>
<Chip variant='neutral' size='xs'>
0
</Chip>
<Chip variant='neutral' size='xs'>
128
</Chip>
</div>
),
}

export const Removable: Story = {
args: { children: 'feature-flag', onRemove: () => undefined },
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/documentation/components/Icons.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const CATEGORIES: IconCategory[] = [
'info-outlined',
'lock',
'shield',
'stale',
'warning',
],
label: 'Status',
Expand All @@ -70,6 +71,7 @@ const CATEGORIES: IconCategory[] = [
'issue-closed',
'issue-linked',
'pr-closed',
'pr-dequeued',
'pr-draft',
'pr-linked',
'pr-merged',
Expand Down
Loading
Loading