Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
d800235
feat(tokens): add the Content tag palette
talissoncosta Sep 29, 2026
bcfab83
feat(icons): add stale and pr-dequeued, and colour pr-draft
talissoncosta Sep 29, 2026
c81a59a
feat(chip): let a caller supply the colour
talissoncosta Sep 29, 2026
a24face
fix(tags): take a tag's colour from the palette, not from its stored hex
talissoncosta Sep 29, 2026
83ed816
fix(tags): give the colour picker a real swatch
talissoncosta Sep 29, 2026
437cb11
docs(tags): story Tag, the colour picker, and how a tag composes Chip
talissoncosta Sep 29, 2026
bd91f09
fix(tags): keep a system tag plain when it is selectable
talissoncosta Sep 29, 2026
52f443d
docs(tags): drop the system tag example that cannot render
talissoncosta Sep 29, 2026
67f36f5
fix(tags): name a bare swatch and say whether it is pressed
talissoncosta Sep 29, 2026
db3052d
chore(tags): correct a stale comment and drop the archaeology
talissoncosta Sep 29, 2026
d15ffed
refactor(chip): let utilities do the checkbox layout
talissoncosta Sep 29, 2026
f1a484f
refactor(tags): let Bootstrap lay the colour picker out
talissoncosta Sep 29, 2026
65b91d6
refactor(chip): fold ToggleChip into a selected prop
talissoncosta Sep 29, 2026
7524ed9
feat(tags): give a tag list a row
talissoncosta Sep 29, 2026
b81a420
fix(menu): keep a click on a menu item inside its own panel
talissoncosta Sep 29, 2026
efce4be
test(tags): report every failing tint at once
talissoncosta Sep 29, 2026
c650d10
chore: restore the lockfile
talissoncosta Sep 29, 2026
6acbbbb
refactor(tags): give the last tag styles to the components that use them
talissoncosta Sep 29, 2026
d739aad
refactor(tags): look a tag's colour up, do not compute it
talissoncosta Sep 29, 2026
c374843
chore: collapse the generated token files in diffs
talissoncosta Sep 29, 2026
7d4b68a
fix(tags): leave the old tag palette alone
talissoncosta Sep 29, 2026
f5fe092
fix(tags): seed the picker stories from the palette it renders
talissoncosta Sep 29, 2026
8f09b7a
fix(chip): let the ring alone mean selected
talissoncosta Sep 29, 2026
bff4683
fix(tags): make the selecting part of a row the button
talissoncosta Sep 29, 2026
0a5a050
fix(chip): report selection through the attribute the role supports
talissoncosta Sep 29, 2026
8f25320
refactor(tags): render the colour picker on Chip directly
talissoncosta Sep 29, 2026
5734457
refactor(tags): pass the tag to renderIcon
talissoncosta Sep 29, 2026
94bf749
refactor(tags): drop the dot's dead props
talissoncosta Sep 29, 2026
aa84feb
feat(tags): make the create row a button
talissoncosta Sep 29, 2026
4774584
refactor: extract useContainClicks
talissoncosta Sep 29, 2026
011c82c
fix(tags): size the permanent padlock to the label
talissoncosta Sep 29, 2026
6275601
feat(icons): add lock-outline
talissoncosta Sep 29, 2026
60cba18
feat(tags): one always-visible row for making a tag
talissoncosta Sep 29, 2026
a3ff65e
refactor(tags): move the create row's colours onto utilities
talissoncosta Sep 29, 2026
2c12e5b
refactor(tags): one place knows a swatch's name and class
talissoncosta Sep 29, 2026
1383d75
fix(tags): soften the picker swatch's corners
talissoncosta Sep 29, 2026
f1b33d6
refactor(tags): build the colour picker on ColorSwatch
talissoncosta Sep 29, 2026
b3dd38a
refactor: drop comments the types already state
talissoncosta Sep 29, 2026
63d59d8
refactor(tags): fold the picker swatch's radius into the ring
talissoncosta Sep 29, 2026
7291281
refactor(tokens): emit the palette as a typed list
talissoncosta Sep 29, 2026
8b6b59a
refactor(tags): drop a comment the map already states
talissoncosta Sep 29, 2026
8e5bf8f
refactor(tags): push the row's checkmark with ms-auto
talissoncosta Sep 29, 2026
be9ac38
refactor(tags): drop the picker's unstyled root class
talissoncosta Sep 29, 2026
e88d8df
docs(tags): say what the swatch map is for
talissoncosta Sep 29, 2026
ddb1461
docs(tags): correct and shorten the swatch map's comment
talissoncosta Sep 29, 2026
4142788
refactor(tags): tidy tagSwatch after review
talissoncosta Sep 29, 2026
8be76b8
refactor(tags): tidy the tag filter
talissoncosta Sep 29, 2026
5b2ab66
refactor(tags): share the chip markup the tooltips build
talissoncosta Sep 29, 2026
0dda73a
fix(tags): restore the escape regex's unicode escapes
talissoncosta Sep 29, 2026
790ecce
docs(chip): stop restating the radius token's value
talissoncosta Sep 30, 2026
cfce27c
fix(tags): keep a plan-locked tag row focusable
talissoncosta Sep 30, 2026
7bfa87f
refactor(tags): group the tag helpers under utils
talissoncosta Sep 30, 2026
0ac40b7
fix(tags): make the tag search case-insensitive
talissoncosta Sep 30, 2026
054df5f
fix(tags): ring the swatch an existing tag already renders as
talissoncosta Sep 30, 2026
c6ad6e2
fix(chip): report selection only where a role supports it
talissoncosta Sep 30, 2026
8f2ea22
fix(tags): give the colour trigger a frame
talissoncosta Sep 30, 2026
d42d7a9
fix(tags): size and space the banner colour trigger
talissoncosta Sep 30, 2026
c743f22
refactor(swatch): order the size map by size
talissoncosta Sep 30, 2026
11dc256
docs(tags): say what the row's trailing slot actually takes
talissoncosta Sep 30, 2026
6ef5a91
docs(tags): shorten the trailing slot's comment
talissoncosta Sep 30, 2026
0cfaa39
fix(tags): close the edit form and block Enter on a taken name
talissoncosta Sep 30, 2026
3a0322d
fix(tags): make the table's tag search case-insensitive
talissoncosta Sep 30, 2026
95d1588
fix(tags): let the table filter's search reach every row
talissoncosta Sep 30, 2026
83483b1
refactor(tags): draw the dot with ColorSwatch
talissoncosta Sep 30, 2026
fe7d38d
docs(tags): trim the Tag prop comments
talissoncosta Sep 30, 2026
7c5a813
docs(tags): drop a comment systemTag.ts already makes
talissoncosta Sep 30, 2026
f9f0106
fix(chip): restore the xs height and keep Enter working
talissoncosta Sep 30, 2026
9852f42
fix(chip): let xs keep the height its padding gives it
talissoncosta Sep 30, 2026
5e28979
docs(storybook): group the tag stories
talissoncosta Sep 30, 2026
2a3d1a6
fix(e2e): point the tag helper at the new picker
talissoncosta Sep 30, 2026
4e0996f
fix(tags): look colours up in a Map, not an object literal
talissoncosta Sep 30, 2026
a6df67d
refactor(tags): annotate the tuple instead of asserting it
talissoncosta Sep 30, 2026
cb455c8
fix(tags): keep the plan lock on the export filter's tags
talissoncosta Sep 30, 2026
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
36 changes: 36 additions & 0 deletions frontend/common/theme/__tests__/tagSwatches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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 INK = primitives['content-always-dark']

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

const ratio = (hex: string) => contrastRatio(INK, hex)

describe('tag swatches', () => {
// Or everything below runs against nothing and still passes.
it('finds swatches', () => {
expect(swatches.length).toBeGreaterThan(0)
})

// One fill on both themes, so the palette settles the label's contrast. The
// fill is 1.18:1 against white, under the 3:1 for non-text, by design: a tag
// is read from its label, not its edge.
it('clears AA for the label on every fill', () => {
const failing = swatches
.filter(([, hex]) => ratio(hex) < AA_NORMAL_TEXT)
.map(([name, hex]) => `${name} ${ratio(hex).toFixed(2)}:1`)
expect(failing).toEqual([])
})

// #8465 found 20 options resolving to 7 colours. Two tags that look alike is
// the same defect as failing contrast.
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
32 changes: 32 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.

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

export type TagType = 'STALE' | 'UNHEALTHY' | 'NONE'
// Mirrors TagType in api/projects/tags/models.py.
export type TagType = 'NONE' | 'STALE' | 'GITHUB' | 'UNHEALTHY' | 'GITLAB'

export type Tag = {
id: number
Expand Down
29 changes: 29 additions & 0 deletions frontend/common/useContainClicks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { RefObject, useEffect } from 'react'

/**
* Keeps clicks inside `ref` from reaching the outside-click watchers, so a
* subtree portalled elsewhere still counts as part of whatever opened it.
*
* Without this, an InlineModal holding a portalled menu closes on the very item
* you picked, because the menu is not a DOM descendant of the modal. Native
* listeners rather than React's onMouseUp, because those sit below document,
* which is where useOutsideClick listens.
*/
const useContainClicks = (
ref: RefObject<HTMLElement | null>,
active = true,
) => {
useEffect(() => {
const node = ref.current
if (!active || !node) return
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('mouseup', stop)
node.addEventListener('touchend', stop)
return () => {
node.removeEventListener('mouseup', stop)
node.removeEventListener('touchend', stop)
}
}, [ref, active])
}

export default useContainClicks
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
78 changes: 77 additions & 1 deletion frontend/documentation/components/Chip.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import React from 'react'
import type { Meta, StoryObj } from 'storybook'

import Chip from 'components/base/Chip'
import Icon, { IconName } from 'components/icons/Icon'
import { contentColours } from 'common/theme/tokens'
import { getTagSwatchUtilities } from 'components/tags/utils'

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. `none` leaves the colour to the caller, for a decorative colour a user picked rather than a semantic role. `selected` rings the chip. The legacy `.chip` (old SCSS vars + manual dark-mode block, ~35×) migrates onto this under #6606.',
},
},
layout: 'centered',
Expand All @@ -28,6 +31,79 @@ export const Accent: Story = {
args: { children: '"hello"', variant: 'accent' },
}

// 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'>
{Object.values(contentColours).map((colour) => (
<Chip
className={`border-0 ${getTagSwatchUtilities(colour)}`}
key={colour}
size='xs'
>
Custom
</Chip>
))}
</div>
),
}

/**
* `selected` rings the chip. For a cloud of filter chips the chip is the whole
* control, and anything drawn inside it competes with the colour the user
* picked. See TagFilter.
*/
export const SelectedAsARing: Story = {
name: 'Selected',
render: () => (
<div className='d-flex flex-wrap gap-2 align-items-center'>
<Chip className='tag-light-green' selected>
onboarding
</Chip>
<Chip className='tag-light-mint' selected={false}>
analytics
</Chip>
<Chip className='tag-blue' selected={false}>
billing
</Chip>
</div>
),
}

export const Sizes: Story = {
render: () => (
<div className='d-flex align-items-center gap-2'>
Expand Down
Loading
Loading