diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index 3340c028da74..0facf7078f25 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -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' }, } diff --git a/frontend/common/theme/__tests__/tagSwatches.test.ts b/frontend/common/theme/__tests__/tagSwatches.test.ts new file mode 100644 index 000000000000..c0e68cdcb6a0 --- /dev/null +++ b/frontend/common/theme/__tests__/tagSwatches.test.ts @@ -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 + +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) + }) +}) 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..f8e671deffc2 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-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": { diff --git a/frontend/common/theme/tokens.ts b/frontend/common/theme/tokens.ts index 025e5418cc4c..df95d5e6d09f 100644 --- a/frontend/common/theme/tokens.ts +++ b/frontend/common/theme/tokens.ts @@ -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 diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 3ebc0ba09639..ad9c4b6af484 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -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 diff --git a/frontend/documentation/CategoricalPalette.stories.tsx b/frontend/documentation/CategoricalPalette.stories.tsx index 2bcfcadc6a99..7c814035d34b 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,61 @@ 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_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: () => ( - 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/documentation/components/Chip.stories.tsx b/frontend/documentation/components/Chip.stories.tsx index c88fe9860935..a7d55e965876 100644 --- a/frontend/documentation/components/Chip.stories.tsx +++ b/frontend/documentation/components/Chip.stories.tsx @@ -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 = { args: { children: 'Production' }, @@ -11,7 +14,7 @@ const meta: Meta = { 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. `selected` renders a leading checkbox with a tick; a chip with no children shows the tick alone, for bare swatches like the tag colour picker. The legacy `.chip` (old SCSS vars + manual dark-mode block, ~35×) migrates onto this under #6606.', }, }, layout: 'centered', @@ -28,6 +31,73 @@ 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: () => ( +
+ {SYSTEM_TAGS.map(({ icon, label }) => ( + + {label} + + + ))} +
+ ), +} + +export const AsCustomTag: Story = { + name: 'As a custom tag', + parameters: { chromatic: { disableSnapshot: false } }, + render: () => ( +
+ {Constants.tagColors.map((colour: string) => ( + + Custom + + ))} +
+ ), +} + +export const Selected: Story = { + name: 'Selected', + render: () => ( +
+ Selected + Not selected + + +
+ ), +} + export const Sizes: Story = { render: () => (
@@ -38,6 +108,49 @@ export const Sizes: Story = { ), } +export const StatusVariants: Story = { + render: () => ( +
+ + + Draft + + + + Running + + + + Paused + + + + Failed + + + + Completed + +
+ ), +} + +export const Counts: Story = { + render: () => ( +
+ + 5 + + + 0 + + + 128 + +
+ ), +} + export const Removable: Story = { args: { children: 'feature-flag', onRemove: () => undefined }, } diff --git a/frontend/documentation/components/Icons.stories.tsx b/frontend/documentation/components/Icons.stories.tsx index dff1c2ba7125..e35040a5ffb8 100644 --- a/frontend/documentation/components/Icons.stories.tsx +++ b/frontend/documentation/components/Icons.stories.tsx @@ -60,6 +60,7 @@ const CATEGORIES: IconCategory[] = [ 'info-outlined', 'lock', 'shield', + 'stale', 'warning', ], label: 'Status', @@ -70,6 +71,7 @@ const CATEGORIES: IconCategory[] = [ 'issue-closed', 'issue-linked', 'pr-closed', + 'pr-dequeued', 'pr-draft', 'pr-linked', 'pr-merged', diff --git a/frontend/documentation/components/Tag.stories.tsx b/frontend/documentation/components/Tag.stories.tsx new file mode 100644 index 000000000000..118a2251b882 --- /dev/null +++ b/frontend/documentation/components/Tag.stories.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Tag from 'components/tags/Tag' +import Constants from 'common/constants' +import type { Tag as TTag } from 'common/types/responses' + +const meta: Meta = { + component: Tag, + parameters: { + chromatic: { disableSnapshot: false }, + docs: { + description: { + component: + 'A project tag. Custom tags take a fill from the design system Content palette, keyed on the colour stored on the tag so nothing needs migrating. System tags (Stale, GitHub, GitLab, Unhealthy) stay on the default surface and carry their state in a coloured icon rather than the fill, so the state survives for anyone who cannot tell the fills apart.', + }, + }, + layout: 'padded', + }, + title: 'Components/Data Display/Tag', +} +export default meta + +type Story = StoryObj + +const tag = (over: Partial): Partial => ({ + color: Constants.tagColors[0], + label: 'Checkout', + type: 'NONE', + ...over, +}) + +export const Custom: Story = { args: { tag: tag({}) } } + +export const EveryColour: Story = { + name: 'Every colour', + render: () => ( +
+ {Constants.tagColors.map((colour: string) => ( + + ))} +
+ ), +} + +export const SystemTags: Story = { + name: 'System tags', + render: () => ( +
+ {(['STALE', 'GITHUB', 'GITLAB', 'UNHEALTHY'] as const).map((type) => ( + + ))} +
+ ), +} + +// A colour the picker never offered: the API takes any hex, so a tag created +// outside the UI falls through to the neutral rather than an unvalidated fill. +export const UnknownColour: Story = { + args: { tag: tag({ color: '#123456', label: 'Set via API' }) }, + name: 'Colour outside the scale', +} + +export const AsADot: Story = { + args: { isDot: true, tag: tag({}) }, + name: 'As a dot', +} + +/** Hooks cannot live in a story's render, so selection state gets a component. */ +const SelectableTags: React.FC = () => { + const [picked, setPicked] = useState('Checkout') + return ( +
+ {['Checkout', 'Billing', 'Search'].map((label, i) => ( + setPicked(label)} + selected={picked === label} + tag={tag({ color: Constants.tagColors[i], label })} + /> + ))} +
+ ) +} + +export const Selectable: Story = { + render: () => , +} diff --git a/frontend/documentation/components/TagColourPicker.stories.tsx b/frontend/documentation/components/TagColourPicker.stories.tsx new file mode 100644 index 000000000000..e16dfd6ecb62 --- /dev/null +++ b/frontend/documentation/components/TagColourPicker.stories.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import TagColourPicker from 'components/tags/TagColourPicker' +import Constants from 'common/constants' + +const meta: Meta = { + component: TagColourPicker, + parameters: { + chromatic: { disableSnapshot: false }, + docs: { + description: { + component: + "The swatch grid a custom tag picks its colour from, shared by the create/edit form and the inline picker. Each swatch is a `Tag` with no label, so the grid sizes it: without that it collapses to the chip's minimum width and reads as a sliver. Colours come from the design system's Content palette and are fixed rather than theme-aware, because a chip carries its own surface.", + }, + }, + layout: 'padded', + }, + title: 'Components/Forms/TagColourPicker', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { value: Constants.tagColors[0] }, +} + +export const NothingSelected: Story = { + args: { value: undefined }, + name: 'Nothing selected', +} + +/** Hooks cannot live in a story's render, so selection state gets a component. */ +const PickerWithState: React.FC = () => { + const [colour, setColour] = useState(Constants.tagColors[4]) + return ( +
+ + {colour} +
+ ) +} + +export const Interactive: Story = { + name: 'Picking a colour', + render: () => , +} diff --git a/frontend/documentation/components/ToggleChip.stories.tsx b/frontend/documentation/components/ToggleChip.stories.tsx deleted file mode 100644 index b9d153a091aa..000000000000 --- a/frontend/documentation/components/ToggleChip.stories.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useState } from 'react' -import type { Meta, StoryObj } from 'storybook' - -import ToggleChip from 'components/ToggleChip' -import { - colorChart2, - colorChart3, - colorSurfaceAction, -} from 'common/theme/tokens' - -const meta: Meta = { - argTypes: { - color: { control: 'color' }, - }, - args: { active: false, children: 'Feature flag', color: colorSurfaceAction }, - component: ToggleChip, - parameters: { layout: 'centered' }, - title: 'Components/Data Display/ToggleChip', -} -export default meta - -type Story = StoryObj - -const Interactive = (args: React.ComponentProps) => { - const [active, setActive] = useState(args.active ?? false) - return ( - setActive(!active)} /> - ) -} - -export const Default: Story = { - render: (args: React.ComponentProps) => ( - - ), -} - -export const AllStates: Story = { - render: () => ( -
- - Active - - Inactive - - Danger - - Success -
- ), -} diff --git a/frontend/documentation/components/VCSProviderTag.stories.tsx b/frontend/documentation/components/VCSProviderTag.stories.tsx index ad9b4fc08d67..5d106320f88a 100644 --- a/frontend/documentation/components/VCSProviderTag.stories.tsx +++ b/frontend/documentation/components/VCSProviderTag.stories.tsx @@ -1,7 +1,7 @@ import React from 'react' import type { Meta, StoryObj } from 'storybook' -import VCSProviderTag from 'components/tags/VCSProviderTag' +import VCSProviderTag from 'components/VCSProviderTag' import { VCSProvider } from 'common/types/responses' const meta: Meta = { diff --git a/frontend/scripts/generate-tokens.mjs b/frontend/scripts/generate-tokens.mjs index c36e86cc0e42..8140d635e844 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,33 @@ function generateTs() { ...describedBlocks, '', ...flatLines, + '', + ...buildContentColours(), ] return output.join('\n') } +/** + * 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 = Object.entries(json.primitives ?? {}).filter(([n]) => + n.startsWith('content-'), + ) + if (!entries.length) return [] + return [ + '/** Tag and read-only content colours. Fixed: they do not follow the theme. */', + 'export const contentColours = {', + ...entries.map(([n, hex]) => ` '${n.replace('content-', '')}': '${hex}',`), + '} as const', + '', + 'export type ContentColour = keyof typeof contentColours', + ] +} + function generateMcpStory() { // Build all table rows const tables = [] @@ -470,6 +507,41 @@ function generateUtilities() { lines.push('') } + // Tag swatches. One class per hue rather than a bg/text pair, because the + // two are only accessible together: applying a fill without its label colour + // is the contrast bug this scale exists to fix. + if (json.tag) { + lines.push('// Tag swatches') + for (const [hue, surface] of sorted(json.tag.surface)) { + const text = json.tag.text[hue] + if (!text) continue + lines.push( + `.tag-${hue} { background-color: var(${surface.cssVar}); color: var(${text.cssVar}); }`, + ) + } + lines.push('') + } + + // Tag utilities. The Content colours are fixed rather than theme-aware: a + // tag chip carries its own surface, so it does not follow the page. One dark + // ink works on all of them, 9.64:1 at worst. + const contentColours = Object.keys(json.primitives ?? {}).filter((n) => + n.startsWith('content-'), + ) + if (contentColours.length) { + lines.push('// Tags') + for (const name of contentColours.sort()) { + const swatch = name.replace('content-', '') + // --ds-chip-check-ink lets a selected checkbox invert: the box fills + // with the ink and the tick takes the swatch, a pair already checked at + // 9.64:1 or better. + lines.push( + `.tag-${swatch} { background-color: var(--${name}); color: var(--slate-600); --ds-chip-check-ink: var(--${name}); }`, + ) + } + lines.push('') + } + // Radius utilities if (json.radius) { lines.push('// Radius') diff --git a/frontend/web/components/ToggleChip.tsx b/frontend/web/components/ToggleChip.tsx deleted file mode 100644 index a56708d9e83d..000000000000 --- a/frontend/web/components/ToggleChip.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { FC, ReactNode } from 'react' -import cx from 'classnames' -import Icon from './icons/Icon' -import Utils from 'common/utils/utils' - -type ToggleChipProps = { - color?: string - active?: boolean - onClick?: () => void - className?: string - children?: ReactNode -} - -const ToggleChip: FC = ({ - active, - children, - className, - color, - onClick, -}) => { - const colour = Utils.colour(color) - return ( - - - {active && } - - {children} - - ) -} - -export default ToggleChip diff --git a/frontend/web/components/tags/VCSProviderTag.tsx b/frontend/web/components/VCSProviderTag.tsx similarity index 91% rename from frontend/web/components/tags/VCSProviderTag.tsx rename to frontend/web/components/VCSProviderTag.tsx index 46aef8dccdf6..c168aaad7ae1 100644 --- a/frontend/web/components/tags/VCSProviderTag.tsx +++ b/frontend/web/components/VCSProviderTag.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { GithubIcon } from 'components/icons/GithubIcon' -import GitlabIcon from 'components/icons/GitlabIcon' +import { GithubIcon } from './icons/GithubIcon' +import GitlabIcon from './icons/GitlabIcon' import { VCSProvider } from 'common/types/responses' interface VCSProviderTagProps { diff --git a/frontend/web/components/base/Chip/Chip.scss b/frontend/web/components/base/Chip/Chip.scss index 854427ce3f98..2a3f0fb4ef13 100644 --- a/frontend/web/components/base/Chip/Chip.scss +++ b/frontend/web/components/base/Chip/Chip.scss @@ -3,7 +3,10 @@ // the remove-button reset. Distinct class (`ds-chip`) until the legacy `.chip` // (_chip.scss) migrates here under #6606. .ds-chip { - padding: 4px 10px; + // 32px with an 8px gap, from the chip frame in Figma. Height is stated so a + // chip holding only an icon matches one holding text. + height: 32px; + padding: 0 10px; font-size: 0.8125rem; white-space: nowrap; border: 1px solid var(--color-border-default); @@ -13,13 +16,41 @@ border-color: var(--color-border-action); } + // Status colours carry their meaning in the fill, so an outline only adds + // noise. Transparent rather than removed, so a status chip is the same + // height as a bordered one beside it. + &--success, + &--warning, + &--danger, + &--info, + &--muted, + &--solid { + border-color: transparent; + } + + // Status dot. currentColor, so it follows the variant with nothing to wire. + &__dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: currentColor; + flex-shrink: 0; + } + // Sizes (default is the base above). &--sm { - padding: 3px 8px; + // Stated now the base has a fixed height, or this would inherit 32px. + // Matches what the padding produced before. + height: 24px; + padding: 0 8px; font-size: 0.75rem; } + // 24px is fixed in the tags frame, so it is stated rather than left to derive + // from the inherited line-height. The frame's 8px vertical padding is an + // artefact of a height override on the auto-layout and is not applied. &--xs { + height: 24px; padding: 1px 6px; font-size: 0.6875rem; } @@ -49,3 +80,33 @@ cursor: pointer; } } + +// The leading checkbox for `selected`. A chip with no children is a bare +// swatch, where only the tick renders and this box is skipped. +.ds-chip__check { + width: 14px; + height: 14px; + flex-shrink: 0; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xs); + + // Checked: the box fills with the chip's ink and the tick takes the chip's + // own fill, which reads more strongly than a bare tick. Falls back to the + // page surface for chips that publish no fill of their own. + &--selected { + // currentColor here is the chip's inherited ink. The override sits on the + // svg, not this element: setting `color` here would change what + // currentColor resolves to and the box would vanish. + background-color: currentColor; + border-color: transparent; + + svg { + color: var(--ds-chip-check-ink, var(--color-surface-default)); + } + } + + svg { + width: 14px; + height: 14px; + } +} diff --git a/frontend/web/components/base/Chip/Chip.tsx b/frontend/web/components/base/Chip/Chip.tsx index 296219372dda..42d8f6ff6704 100644 --- a/frontend/web/components/base/Chip/Chip.tsx +++ b/frontend/web/components/base/Chip/Chip.tsx @@ -5,13 +5,30 @@ import { colorIconSecondary } from 'common/theme/tokens' import './Chip.scss' export type ChipSize = 'default' | 'sm' | 'xs' -export type ChipVariant = 'neutral' | 'accent' +export type ChipVariant = + | 'neutral' + | 'accent' + | 'success' + | 'warning' + | 'danger' + | 'info' + | 'muted' + | 'solid' + // The caller supplies the colour through className. Used by tags, whose + // colour is a user's decorative choice rather than a semantic role. + | 'none' export type ChipProps = { - children: ReactNode + // Optional: a chip with `selected` and no children is a bare swatch, where + // the tick alone marks the choice. See the tag colour picker. + children?: ReactNode variant?: ChipVariant size?: ChipSize truncate?: boolean + // Renders a leading checkbox with a tick. A chip with no children is a bare + // swatch (the tag colour picker), where the box would be noise and the tick + // alone marks the choice. + selected?: boolean onRemove?: () => void onClick?: () => void className?: string @@ -30,7 +47,24 @@ export type ChipProps = { // bg + text come from token utilities; the variant border lives in Chip.scss. const VARIANT_UTILITIES: Record = { accent: 'bg-surface-action-subtle text-action', + + danger: 'bg-surface-danger text-danger', + + info: 'bg-surface-info text-info', + + muted: 'bg-surface-muted text-secondary', + neutral: 'bg-surface-subtle text-default', + // No utilities: a caller-supplied colour class would otherwise have to beat + // these on source order alone, which a reordered stylesheet would break. + none: '', + // The one filled variant. `text-white` rather than a token because there is + // no inverse-text token yet; white on --color-surface-action is 5.93:1, so AA + // but not AAA. Note the app has a second, darker solid (`bg-primary900`, used + // by BetaFlag and PlanBasedAccess) that this deliberately does not cover. + solid: 'bg-surface-action text-white', + success: 'bg-surface-success text-success', + warning: 'bg-surface-warning text-warning', } // Token-based chip primitive. Uses `ds-chip` rather than the legacy `.chip` @@ -48,6 +82,7 @@ const Chip = ({ onRemove, ref, role, + selected, size = 'default', tabIndex, truncate = false, @@ -58,10 +93,12 @@ const Chip = ({ + {selected !== undefined && ( + + {selected && } + + )} {truncate ? {children} : children} {onRemove && (