refactor: add Badge component and fix twMerge font-size detection (#10580)

## Summary
- Rename `text-xxxs`/`text-xxs` to `text-3xs`/`text-2xs` in design
system CSS — fixes `tailwind-merge` incorrectly classifying custom
font-size utilities as color classes, which clobbered text color
- Add `Badge` component with updated severity colors matching Figma
design (white text on colored backgrounds)
- Add Badge stories under `Components/Badges/Badge`
- Add unit tests including twMerge regression coverage

Split from #10438 per review feedback — this PR contains the
foundational Badge component; migration of consumers follows in a
separate PR.

## Test plan
- [x] Unit tests pass (`Badge.test.ts` — 12 tests)
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify Badge stories render correctly in Storybook
- [ ] Verify existing components using `text-2xs`/`text-3xs` render
unchanged

Fixes #10438 (partial)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10580-refactor-add-Badge-component-and-fix-twMerge-font-size-detection-32f6d73d3650810dae7cd0d4af67fd1c)
by [Unito](https://www.unito.io)
This commit is contained in:
Dante
2026-03-28 11:23:59 +09:00
committed by GitHub
parent f9c334092c
commit 82242f1b00
19 changed files with 271 additions and 122 deletions

View File

@@ -28,11 +28,8 @@
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
--text-3xs: 0.5625rem;
--text-3xs--line-height: calc(1 / 0.5625);
/* Font Families */
--font-inter: 'Inter', sans-serif;

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Badge from './Badge.vue'
const meta = {
title: 'Components/Badges/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'NEW',
severity: 'default'
}
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Secondary: Story = {
args: {
label: 'NEW',
severity: 'secondary'
}
}
export const Warn: Story = {
args: {
label: 'NEW',
severity: 'warn'
}
}
export const Danger: Story = {
args: {
label: 'NEW',
severity: 'danger'
}
}
export const Contrast: Story = {
args: {
label: 'NEW',
severity: 'contrast'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeveritiesLabel: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge label="NEW" severity="default" />
<Badge label="NEW" severity="secondary" />
<Badge label="NEW" severity="warn" />
<Badge label="NEW" severity="danger" />
<Badge label="NEW" severity="contrast" />
</div>
`
})
}
export const AllSeveritiesDot: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge variant="dot" severity="default" />
<Badge variant="dot" severity="secondary" />
<Badge variant="dot" severity="warn" />
<Badge variant="dot" severity="danger" />
<Badge variant="dot" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<Badge label="NEW" variant="label" />
<span class="text-xs text-muted-foreground">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge variant="dot" severity="danger" />
<span class="text-xs text-muted-foreground">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge label="5" variant="circle" />
<span class="text-xs text-muted-foreground">circle</span>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import Badge from './Badge.vue'
import { badgeVariants } from './badge.variants'
describe('Badge', () => {
it('renders label text', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.text()).toBe('NEW')
})
it('renders numeric label', () => {
const wrapper = mount(Badge, { props: { label: 5 } })
expect(wrapper.text()).toBe('5')
})
it('defaults to dot variant when no label is provided', () => {
const wrapper = mount(Badge)
expect(wrapper.classes()).toContain('size-2')
})
it('defaults to label variant when label is provided', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.classes()).toContain('font-semibold')
expect(wrapper.classes()).toContain('uppercase')
})
it('applies circle variant', () => {
const wrapper = mount(Badge, {
props: { label: '3', variant: 'circle' }
})
expect(wrapper.classes()).toContain('size-3.5')
})
it('merges custom class via cn()', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', class: 'ml-2' }
})
expect(wrapper.classes()).toContain('ml-2')
expect(wrapper.classes()).toContain('rounded-full')
})
describe('twMerge preserves color alongside text-3xs font size', () => {
it.each([
['default', 'text-white'],
['secondary', 'text-white'],
['warn', 'text-white'],
['danger', 'text-white'],
['contrast', 'text-base-background']
] as const)(
'%s severity retains its text color class',
(severity, expectedColor) => {
const classes = badgeVariants({ severity, variant: 'label' })
expect(classes).toContain(expectedColor)
expect(classes).toContain('text-3xs')
}
)
it('cn() does not clobber text-white when merging with text-3xs', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', severity: 'danger' }
})
const classList = wrapper.classes()
expect(classList).toContain('text-white')
expect(classList).toContain('text-3xs')
})
})
})

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { badgeVariants } from './badge.variants'
import type { BadgeVariants } from './badge.variants'
const {
label,
severity = 'default',
variant,
class: className
} = defineProps<{
label?: string | number
severity?: BadgeVariants['severity']
variant?: BadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
badgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),
className
)
)
</script>
<template>
<span :class="badgeClass">
{{ label }}
</span>
</template>

View File

@@ -3,7 +3,7 @@
data-testid="badge-pill"
:class="
cn(
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xxs',
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-2xs',
textColorClass
)
"

View File

@@ -59,7 +59,7 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-2xs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -44,7 +44,7 @@ const {
<span class="flex-1">{{ item.label }}</span>
<span
v-if="item.badge"
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
>
{{ item.badge }}
</span>

View File

@@ -0,0 +1,26 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-white',
secondary: 'bg-secondary-background-hover text-white',
warn: 'bg-warning-background text-white',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-3xs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -12,9 +12,9 @@ export const statusBadgeVariants = cva({
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-xxxs font-semibold'
circle: 'size-3.5 text-3xs font-semibold'
}
},
defaultVariants: {

View File

@@ -49,14 +49,14 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
>
{{ $t('nodeHelpPage.inputs') }}
</h4>
<div
v-for="input in inputs"
:key="input.name"
class="flex items-center justify-between gap-2 text-xxs"
class="flex items-center justify-between gap-2 text-2xs"
>
<span class="text-foreground shrink-0">{{ input.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{
@@ -71,14 +71,14 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
>
{{ $t('nodeHelpPage.outputs') }}
</h4>
<div
v-for="output in outputs"
:key="output.name"
class="flex items-center justify-between gap-2 text-xxs"
class="flex items-center justify-between gap-2 text-2xs"
>
<span class="text-foreground shrink-0">{{ output.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{

View File

@@ -8,7 +8,7 @@
>
<template #alt-title>
<span
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
>
{{ $t('g.beta') }}
</span>

View File

@@ -14,7 +14,7 @@
/>
<div
v-else-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -33,7 +33,7 @@
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -68,7 +68,7 @@
/>
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -87,7 +87,7 @@
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -115,7 +115,7 @@
/>
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}

View File

@@ -41,7 +41,7 @@ export const WithLabel: Story = {
},
template: `
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
<label class="pointer-events-none absolute left-3 top-1.5 text-2xs text-muted-foreground z-10">
Prompt
</label>
<Textarea

View File

@@ -560,7 +560,7 @@ function drawDisconnectedPlaceholder(
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-xxs', '11px')
const fontSize = readDesignToken('--text-2xs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()

View File

@@ -2,7 +2,7 @@
<div class="flex items-center gap-2 p-4 font-bold">
<span>{{ $t('assetBrowser.uploadModelGeneric') }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 font-inter text-xxs font-semibold text-black uppercase"
class="rounded-full bg-white px-1.5 py-0 font-inter text-2xs font-semibold text-black uppercase"
>
{{ $t('g.beta') }}
</span>

View File

@@ -41,7 +41,7 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
<template #runButton>
<span
class="mx-0.5 inline-flex -translate-y-0.5 transform cursor-default items-center rounded-sm bg-primary-background px-3.5 py-0.5 text-xxs font-medium text-base-foreground"
class="mx-0.5 inline-flex -translate-y-0.5 transform cursor-default items-center rounded-sm bg-primary-background px-3.5 py-0.5 text-2xs font-medium text-base-foreground"
>
{{ t('menu.run') }}
</span>
@@ -86,7 +86,7 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
<i class="icon-[lucide--hammer]" />
{{ t('linearMode.welcome.buildApp') }}
<div
class="absolute -top-2 -right-2 rounded-full bg-base-foreground px-1 text-xxs text-base-background"
class="absolute -top-2 -right-2 rounded-full bg-base-foreground px-1 text-2xs text-base-background"
>
{{ t('g.experimental') }}
</div>

View File

@@ -10,7 +10,7 @@
<label
v-if="!hideLayoutField"
:for="id"
class="pointer-events-none absolute top-1.5 left-3 z-10 text-xxs text-muted-foreground"
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ displayName }}
</label>

View File

@@ -22,7 +22,7 @@
class="pi pi-arrow-circle-up text-xs text-blue-600"
/>
<span>{{ installedVersion }}</span>
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
<i v-if="!isDisabled" class="pi pi-chevron-right text-2xs" />
</div>
<Popover