diff --git a/.changeset/themeable-button-foreground.md b/.changeset/themeable-button-foreground.md
new file mode 100644
index 0000000000..471fb3f11f
--- /dev/null
+++ b/.changeset/themeable-button-foreground.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/kumo": patch
+---
+
+Make primary and destructive Button foreground colors themeable through the semantic `text-kumo-button-emphasis` token.
diff --git a/packages/kumo-docs-astro/src/pages/colors.mdx b/packages/kumo-docs-astro/src/pages/colors.mdx
index 87f7e63115..a23f4c123a 100644
--- a/packages/kumo-docs-astro/src/pages/colors.mdx
+++ b/packages/kumo-docs-astro/src/pages/colors.mdx
@@ -284,6 +284,10 @@ Use the solid token `bg-kumo-*` for status dots, `fill-kumo-*` for icons, and `b
text-kumo-inverse |
Text intended for use on high-contrast or inverted backgrounds |
+
+ text-kumo-button-emphasis |
+ Foreground text and icons on primary and destructive Button emphasis backgrounds |
+
text-kumo-link |
Link text |
diff --git a/packages/kumo/ai/USAGE.md b/packages/kumo/ai/USAGE.md
index c5f32da522..cbf7f22453 100644
--- a/packages/kumo/ai/USAGE.md
+++ b/packages/kumo/ai/USAGE.md
@@ -104,19 +104,20 @@ Use the solid token on icons, status dots, and progress fills. Banners and badge
### Text Colors
-| Token | Purpose |
-| ----------------------- | -------------------------------------------------------------- |
-| `text-kumo-default` | Primary body text |
-| `text-kumo-strong` | Secondary text with slightly less contrast than default |
-| `text-kumo-subtle` | Muted text for descriptions, captions, or secondary labels |
-| `text-kumo-inactive` | Disabled or inactive text |
-| `text-kumo-placeholder` | Placeholder text in inputs |
-| `text-kumo-inverse` | Text intended for use on high-contrast or inverted backgrounds |
-| `text-kumo-link` | Link text |
-| `text-kumo-info` | Info-colored text |
-| `text-kumo-success` | Success-colored text |
-| `text-kumo-warning` | Warning-colored text |
-| `text-kumo-danger` | Error/destructive text |
+| Token | Purpose |
+| --------------------------- | -------------------------------------------------------------- |
+| `text-kumo-default` | Primary body text |
+| `text-kumo-strong` | Secondary text with slightly less contrast than default |
+| `text-kumo-subtle` | Muted text for descriptions, captions, or secondary labels |
+| `text-kumo-inactive` | Disabled or inactive text |
+| `text-kumo-placeholder` | Placeholder text in inputs |
+| `text-kumo-inverse` | Text intended for use on high-contrast or inverted backgrounds |
+| `text-kumo-button-emphasis` | Foreground text and icons on emphasized Button backgrounds |
+| `text-kumo-link` | Link text |
+| `text-kumo-info` | Info-colored text |
+| `text-kumo-success` | Success-colored text |
+| `text-kumo-warning` | Warning-colored text |
+| `text-kumo-danger` | Error/destructive text |
### Borders & Rings
diff --git a/packages/kumo/scripts/theme-generator/config.ts b/packages/kumo/scripts/theme-generator/config.ts
index 80d8954007..decc11f0b4 100644
--- a/packages/kumo/scripts/theme-generator/config.ts
+++ b/packages/kumo/scripts/theme-generator/config.ts
@@ -39,6 +39,17 @@ export const THEME_CONFIG: ThemeConfig = {
},
},
},
+ "kumo-button-emphasis": {
+ newName: "",
+ description:
+ "Foreground color for primary and destructive Button emphasis backgrounds",
+ theme: {
+ kumo: {
+ light: "var(--color-white, #fff)",
+ dark: "var(--color-white, #fff)",
+ },
+ },
+ },
"kumo-strong": {
newName: "",
theme: {
diff --git a/packages/kumo/scripts/theme-generator/generate-css.test.ts b/packages/kumo/scripts/theme-generator/generate-css.test.ts
index 8a5cdf502c..1fc0f214a8 100644
--- a/packages/kumo/scripts/theme-generator/generate-css.test.ts
+++ b/packages/kumo/scripts/theme-generator/generate-css.test.ts
@@ -27,4 +27,17 @@ describe("theme css generator", () => {
);
expect(css).not.toMatch(/\n\[data-theme="fedramp"\] \{/);
});
+
+ it("emits a themeable Button emphasis foreground with white defaults", () => {
+ const css = generateKumoThemeCSS(THEME_CONFIG);
+
+ expect(css).toContain(`--text-color-kumo-button-emphasis: light-dark(
+ var(--color-white, #fff),
+ var(--color-white, #fff)
+ );`);
+ expect(countOccurrences(css, "--text-color-kumo-button-emphasis")).toBe(3);
+ expect(css).not.toContain(
+ "--text-color-kumo-button-emphasis: var(--color-white, #fff) !important",
+ );
+ });
});
diff --git a/packages/kumo/src/components/banner/banner.test.tsx b/packages/kumo/src/components/banner/banner.test.tsx
index 0faa017a28..041febf052 100644
--- a/packages/kumo/src/components/banner/banner.test.tsx
+++ b/packages/kumo/src/components/banner/banner.test.tsx
@@ -39,7 +39,7 @@ describe("Banner", () => {
const cta = screen.getByTestId("cta");
expect(cta.tagName).toBe("BUTTON");
expect(cta.getAttribute("data-kumo-component")).toBe("Button");
- expect(cta.className).toContain("text-white");
+ expect(cta.className).toContain("!text-kumo-button-emphasis");
expect(cta.className).toContain("bg-(--kumo-button-emphasis-bg)");
expect(
cta.style.getPropertyValue("--kumo-button-emphasis-gradient-end"),
diff --git a/packages/kumo/src/components/button/button.browser.test.tsx b/packages/kumo/src/components/button/button.browser.test.tsx
new file mode 100644
index 0000000000..c9a1ea165d
--- /dev/null
+++ b/packages/kumo/src/components/button/button.browser.test.tsx
@@ -0,0 +1,59 @@
+import type { CSSProperties } from "react";
+import { describe, expect, test } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+import { Button } from "./button";
+
+const customTheme = {
+ "--text-color-kumo-button-emphasis": "rgb(17 24 39)",
+} as CSSProperties;
+
+describe("Button emphasis theme", () => {
+ test("preserves the default white foreground", async () => {
+ const { getByRole } = await render(
+ <>
+
+
+ >,
+ );
+
+ for (const button of [
+ getByRole("button", { name: "Save" }),
+ getByRole("button", { name: "Delete" }),
+ ]) {
+ await expect.element(button).toBeVisible();
+ expect(getComputedStyle(button.element()).color).toBe(
+ "rgb(255, 255, 255)",
+ );
+ }
+ });
+
+ test("uses a custom semantic foreground in enabled, disabled, and loading states", async () => {
+ const { getByRole } = await render(
+
+
+
+
+
,
+ );
+
+ const save = getByRole("button", { name: "Save" });
+ const deleteButton = getByRole("button", { name: "Delete" });
+ const loading = getByRole("button", { name: "Loading" });
+
+ await Promise.all([
+ expect.element(save).toBeVisible(),
+ expect.element(deleteButton).toBeVisible(),
+ expect.element(loading).toBeVisible(),
+ ]);
+
+ for (const button of [save, deleteButton, loading]) {
+ expect(getComputedStyle(button.element()).color).toBe("rgb(17, 24, 39)");
+ }
+ expect(getComputedStyle(deleteButton.element()).opacity).toBe("0.5");
+ expect(getComputedStyle(loading.element()).opacity).toBe("0.5");
+ });
+});
diff --git a/packages/kumo/src/components/button/button.test.tsx b/packages/kumo/src/components/button/button.test.tsx
index ed2cf490df..6cdeb7823d 100644
--- a/packages/kumo/src/components/button/button.test.tsx
+++ b/packages/kumo/src/components/button/button.test.tsx
@@ -192,6 +192,36 @@ describe("Button", () => {
expect(className).not.toContain("focus-visible:ring-kumo-brand");
}
});
+
+ it("uses a semantic foreground for emphasized variants", () => {
+ for (const variant of ["primary", "destructive"] as const) {
+ const className = buttonVariants({ variant });
+
+ expect(className).toContain("!text-kumo-button-emphasis");
+ expect(className).not.toContain("!text-white");
+ expect(className).toContain("disabled:opacity-50");
+ }
+ });
+
+ it("keeps the semantic emphasis foreground while disabled or loading", () => {
+ render(
+ <>
+
+
+ >,
+ );
+
+ for (const button of screen.getAllByRole("button")) {
+ expect(button.hasAttribute("disabled")).toBe(true);
+ expect(button.classList.contains("!text-kumo-button-emphasis")).toBe(
+ true,
+ );
+ }
+ });
});
describe("RefreshButton", () => {
diff --git a/packages/kumo/src/components/button/button.tsx b/packages/kumo/src/components/button/button.tsx
index a8e50b53d0..9b3a88ba53 100644
--- a/packages/kumo/src/components/button/button.tsx
+++ b/packages/kumo/src/components/button/button.tsx
@@ -49,7 +49,7 @@ export const KUMO_BUTTON_VARIANTS = {
variant: {
primary: {
classes:
- "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50",
+ "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-kumo-button-emphasis ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50",
description: "High-emphasis button for primary actions",
},
secondary: {
@@ -63,7 +63,7 @@ export const KUMO_BUTTON_VARIANTS = {
},
destructive: {
classes:
- "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50",
+ "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-kumo-button-emphasis ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50",
description: "Danger button for destructive actions like delete",
},
"secondary-destructive": {
diff --git a/packages/kumo/src/styles/theme-kumo.css b/packages/kumo/src/styles/theme-kumo.css
index 7ac498a1e7..98b5333b1c 100644
--- a/packages/kumo/src/styles/theme-kumo.css
+++ b/packages/kumo/src/styles/theme-kumo.css
@@ -17,6 +17,11 @@
var(--color-neutral-900, oklch(20.5% 0 0))
);
+ --text-color-kumo-button-emphasis: light-dark(
+ var(--color-white, #fff),
+ var(--color-white, #fff)
+ );
+
--text-color-kumo-strong: light-dark(
var(--color-neutral-950, oklch(14.5% 0 0)),
var(--color-neutral-50, oklch(98.5% 0 0))
@@ -294,6 +299,7 @@
oklch(21% 0.006 285.885)
);
--text-color-kumo-inverse: var(--color-neutral-100, oklch(97% 0 0));
+ --text-color-kumo-button-emphasis: var(--color-white, #fff);
--text-color-kumo-strong: var(--color-neutral-950, oklch(14.5% 0 0));
--text-color-kumo-subtle: var(--color-neutral-500, oklch(55.6% 0 0));
--text-color-kumo-inactive: var(--color-neutral-300, oklch(87% 0 0));
@@ -376,6 +382,7 @@
[data-theme="kumo"] [data-mode="dark"] {
--text-color-kumo-default: var(--color-neutral-100, oklch(97% 0 0));
--text-color-kumo-inverse: var(--color-neutral-900, oklch(20.5% 0 0));
+ --text-color-kumo-button-emphasis: var(--color-white, #fff);
--text-color-kumo-strong: var(--color-neutral-50, oklch(98.5% 0 0));
--text-color-kumo-subtle: var(--color-neutral-400, oklch(70.8% 0 0));
--text-color-kumo-inactive: var(--color-neutral-600, oklch(43.9% 0 0));