Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/badge-color-variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@cloudflare/kumo": minor
---

Badge: add color-based variants and deprecate semantic variants

- Add color variants: `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `neutral`, `inverted`
- Add subtle variants for each color (`red-subtle`, `orange-subtle`, etc.) with lighter backgrounds and darker text
- Retain `outline` and `beta` variants unchanged
- Deprecate `primary` (use `inverted`), `secondary` (use `neutral`), `destructive` (use `red`), `success` (use `green`)
- Dark mode support: subtle variants flip to dark backgrounds with light text, regular color variants darken slightly, inverted flips to white bg with black text
- Default variant changed from `primary` to `neutral`
9 changes: 4 additions & 5 deletions packages/kumo-docs-astro/src/components/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,7 @@ const COMPONENT_DESCRIPTIONS: Record<string, string> = {
"page-header": "Combines breadcrumbs and tabs for page navigation.",
"resource-list":
"A layout for displaying resource lists with title and sidebar.",
toast:
"Displays brief, non-intrusive notifications that appear temporarily.",
toast: "Displays brief, non-intrusive notifications that appear temporarily.",
};

interface ComponentRegistryEntry {
Expand Down Expand Up @@ -308,11 +307,11 @@ function getTypeBadge(

switch (type) {
case "block":
return <Badge variant="secondary">Block</Badge>;
return <Badge variant="neutral">Block</Badge>;
case "layout":
return <Badge variant="secondary">Layout</Badge>;
return <Badge variant="neutral">Layout</Badge>;
case "page":
return <Badge variant="secondary">Guide</Badge>;
return <Badge variant="neutral">Guide</Badge>;
default:
return null;
}
Expand Down
81 changes: 68 additions & 13 deletions packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,85 @@ import { Badge } from "@cloudflare/kumo";
export function BadgeVariantsDemo() {
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary">Primary</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="red">Red</Badge>
<Badge variant="orange">Orange</Badge>
<Badge variant="yellow">Yellow</Badge>
<Badge variant="green">Green</Badge>
<Badge variant="teal">Teal</Badge>
<Badge variant="blue">Blue</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="inverted">Inverted</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="beta">Beta</Badge>
<Badge variant="red-subtle">Red subtle</Badge>
<Badge variant="orange-subtle">Orange subtle</Badge>
<Badge variant="yellow-subtle">Yellow subtle</Badge>
<Badge variant="green-subtle">Green subtle</Badge>
<Badge variant="teal-subtle">Teal subtle</Badge>
<Badge variant="blue-subtle">Blue subtle</Badge>
<Badge variant="neutral-subtle">Neutral subtle</Badge>
</div>
);
}

export function BadgePrimaryDemo() {
return <Badge variant="primary">Primary</Badge>;
export function BadgeRedDemo() {
return <Badge variant="red">Red</Badge>;
}

export function BadgeSecondaryDemo() {
return <Badge variant="secondary">Secondary</Badge>;
export function BadgeRedSubtleDemo() {
return <Badge variant="red-subtle">Red subtle</Badge>;
}

export function BadgeDestructiveDemo() {
return <Badge variant="destructive">Destructive</Badge>;
export function BadgeOrangeDemo() {
return <Badge variant="orange">Orange</Badge>;
}

export function BadgeSuccessDemo() {
return <Badge variant="success">Success</Badge>;
export function BadgeOrangeSubtleDemo() {
return <Badge variant="orange-subtle">Orange subtle</Badge>;
}

export function BadgeYellowDemo() {
return <Badge variant="yellow">Yellow</Badge>;
}

export function BadgeYellowSubtleDemo() {
return <Badge variant="yellow-subtle">Yellow subtle</Badge>;
}

export function BadgeGreenDemo() {
return <Badge variant="green">Green</Badge>;
}

export function BadgeGreenSubtleDemo() {
return <Badge variant="green-subtle">Green subtle</Badge>;
}

export function BadgeTealDemo() {
return <Badge variant="teal">Teal</Badge>;
}

export function BadgeTealSubtleDemo() {
return <Badge variant="teal-subtle">Teal subtle</Badge>;
}

export function BadgeBlueDemo() {
return <Badge variant="blue">Blue</Badge>;
}

export function BadgeBlueSubtleDemo() {
return <Badge variant="blue-subtle">Blue subtle</Badge>;
}

export function BadgeNeutralDemo() {
return <Badge variant="neutral">Neutral</Badge>;
}

export function BadgeNeutralSubtleDemo() {
return <Badge variant="neutral-subtle">Neutral subtle</Badge>;
}

export function BadgeInvertedDemo() {
return <Badge variant="inverted">Inverted</Badge>;
}

export function BadgeOutlineDemo() {
Expand All @@ -41,7 +96,7 @@ export function BadgeInSentenceDemo() {
return (
<p className="flex items-center gap-2">
Workers
<Badge variant="beta">Beta</Badge>
<Badge variant="blue">New</Badge>
</p>
);
}
107 changes: 102 additions & 5 deletions packages/kumo-docs-astro/src/components/demos/ColorsDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type FC, useMemo, useSyncExternalStore } from "react";
import { kumoColors, type ColorToken } from "virtual:kumo-colors";
import { kumoRegistryJson } from "virtual:kumo-registry";

/**
* Extract the actual color value from a CSS variable fallback.
Expand Down Expand Up @@ -96,9 +97,52 @@ function useCurrentTheme(): string {
return useSyncExternalStore(subscribeToTheme, getTheme, () => "kumo");
}

/**
* Build a set of lowercase component names from the registry.
* Cached once — the registry doesn't change at runtime.
*/
const componentNames: Set<string> = new Set(
Object.keys(kumoRegistryJson.components).map((n) => n.toLowerCase()),
);

/**
* Extract the component name from a token, if it matches a known component.
*
* Token format: `--color-kumo-{component}-{variant}` or `--text-color-kumo-{component}-{variant}`
* Returns the matched component name (lowercase) or null.
*/
function getComponentFromToken(tokenName: string): string | null {
// Strip the CSS variable prefix to get the semantic name
// "--color-kumo-badge-red" → "kumo-badge-red"
// "--text-color-kumo-badge-red-subtle" → "kumo-badge-red-subtle"
const semantic = tokenName
.replace(/^--text-color-/, "")
.replace(/^--color-/, "");

// Must start with "kumo-"
if (!semantic.startsWith("kumo-")) return null;

// "kumo-badge-red" → "badge-red" → check if "badge" is a component
const afterKumo = semantic.slice("kumo-".length);
const segments = afterKumo.split("-");

// Require at least 2 segments: component name + variant.
// This avoids matching standalone semantic tokens like "kumo-link" or "kumo-surface".
if (segments.length < 2) return null;

return componentNames.has(segments[0]) ? segments[0] : null;
}

type ComponentColorGroup = {
component: string;
displayName: string;
tokens: ColorToken[];
};

type ColorsByCategory = {
textColors: ColorToken[];
colors: ColorToken[];
componentGroups: ComponentColorGroup[];
};

/**
Expand Down Expand Up @@ -140,14 +184,45 @@ function getColorsForTheme(theme: string): ColorsByCategory {
effectiveTokens = [...effectiveSemanticTokens, ...globalTokens];
}

// Split by category
// Partition: component-specific tokens vs semantic tokens
const componentTokenMap = new Map<string, ColorToken[]>();
const semanticTokens2: ColorToken[] = [];

for (const token of effectiveTokens) {
const comp = getComponentFromToken(token.name);
if (comp) {
const list = componentTokenMap.get(comp) ?? [];
list.push(token);
componentTokenMap.set(comp, list);
} else {
semanticTokens2.push(token);
}
}

// Build sorted component groups with display names from the registry
const componentGroups: ComponentColorGroup[] = [...componentTokenMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([comp, tokens]) => {
// Find the proper-cased name from the registry
const registryName = Object.keys(kumoRegistryJson.components).find(
(n) => n.toLowerCase() === comp,
);
return {
component: comp,
displayName: registryName ?? comp.charAt(0).toUpperCase() + comp.slice(1),
tokens,
};
});

// Split remaining semantic tokens by category
return {
textColors: effectiveTokens.filter(
textColors: semanticTokens2.filter(
(c) => getTokenCategory(c.name) === "text-colors",
),
colors: effectiveTokens.filter(
colors: semanticTokens2.filter(
(c) => getTokenCategory(c.name) === "colors",
),
componentGroups,
};
}

Expand Down Expand Up @@ -181,8 +256,13 @@ const TokenGrid: FC<{ tokens: ColorToken[] }> = ({ tokens }) => (

export const TailwindColorTokens: FC = () => {
const currentTheme = useCurrentTheme();
const { textColors, colors } = getColorsForTheme(currentTheme);
const { textColors, colors, componentGroups } =
getColorsForTheme(currentTheme);

const componentTokenCount = componentGroups.reduce(
(sum, g) => sum + g.tokens.length,
0,
);
const allTokens = [...textColors, ...colors];

// Count override tokens for display
Expand All @@ -198,7 +278,7 @@ export const TailwindColorTokens: FC = () => {
<div className="flex flex-col gap-1">
<h2 className="m-0 text-2xl font-semibold">Colors</h2>
<div className="text-sm text-kumo-default">
Displaying {allTokens.length} tokens
Displaying {allTokens.length + componentTokenCount} tokens
{overrideCount > 0 && (
<span className="ml-1">
— {overrideCount} overridden by{" "}
Expand All @@ -223,6 +303,23 @@ export const TailwindColorTokens: FC = () => {
</h2>
<TokenGrid tokens={colors} />
</section>

{/* Component Colors Section */}
{componentGroups.length > 0 && (
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold">
Component Colors ({componentTokenCount})
</h2>
{componentGroups.map((group) => (
<div key={group.component} className="flex flex-col gap-3">
<h3 className="!m-0 text-xs font-semibold text-kumo-subtle">
{group.displayName} ({group.tokens.length})
</h3>
<TokenGrid tokens={group.tokens} />
</div>
))}
</section>
)}
</div>
);
};
10 changes: 5 additions & 5 deletions packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,11 @@ export function HomeGrid() {
id: "badge",
Component: (
<div className="flex flex-col gap-2">
<Badge variant="primary">Primary</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="beta">Beta</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="blue">Blue</Badge>
<Badge variant="green">Green</Badge>
<Badge variant="orange">Orange</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="red">Red</Badge>
</div>
),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function SelectMultipleWithIndicatorDemo() {
<span className="flex items-center gap-2">
<span>Issue Types</span>
{selected.length > 0 && (
<Badge variant="secondary">{selected.length}</Badge>
<Badge variant="neutral">{selected.length}</Badge>
)}
</span>
)}
Expand Down
Loading
Loading