From 8495cc785146694c5d136487bde375a596d12137 Mon Sep 17 00:00:00 2001 From: balint Date: Wed, 5 Aug 2026 14:13:18 +0100 Subject: [PATCH] refactor(link): collapse variants into a single underlined style Remove the 'inline', 'current', and 'plain' Link variants in favor of a single 'default' variant that inherits color from the surrounding text and is always underlined. This drops the brand-blue link treatment Kumo used to ship. - link.tsx: single 'default' variant using text-current + underline - link.test.tsx: cover the single-variant shape; assert no text-kumo-link - LinkDemo/link.mdx/HomeGrid: drop per-variant showcases and prop usage - kumo-figma/link.ts: bind to text-default; handle 1-variant edge case (combineAsVariants requires 2+ components) - Changeset: major bump with migration guide --- .changeset/remove-blue-link.md | 13 +++ .../src/components/demos/HomeGrid.tsx | 13 +-- .../src/components/demos/LinkDemo.tsx | 27 +++---- .../src/pages/components/link.mdx | 81 +++++-------------- packages/kumo-figma/src/generators/link.ts | 60 +++++++------- .../kumo/src/components/link/link.test.tsx | 62 +++++--------- packages/kumo/src/components/link/link.tsx | 47 ++++++----- 7 files changed, 125 insertions(+), 178 deletions(-) create mode 100644 .changeset/remove-blue-link.md diff --git a/.changeset/remove-blue-link.md b/.changeset/remove-blue-link.md new file mode 100644 index 0000000000..9bcdb2aeaa --- /dev/null +++ b/.changeset/remove-blue-link.md @@ -0,0 +1,13 @@ +--- +"@cloudflare/kumo": major +--- + +**Breaking:** `Link` now ships a single visual style — an underlined anchor that inherits color from the surrounding text (renders as the default foreground, i.e. black on light mode). The `"inline"`, `"current"`, and `"plain"` variants have been removed and consolidated into a single `"default"` variant. + +Migration: + +- `` → `` (default). The result is no longer blue; if you need the brand-blue treatment, wrap the link and add `className="text-kumo-link"` yourself. +- `` → `` (default). Behavior is unchanged; the default now always inherits `currentColor`. +- `` → `` (or drop `Link` and use a plain `` styled by your app). Kumo no longer ships an un-underlined link variant. + +The `KUMO_LINK_VARIANTS` export still exists and continues to satisfy the Kumo variant standard; it now contains a single `default` entry. diff --git a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx index a88a484629..b3560c07c7 100644 --- a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx +++ b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx @@ -543,12 +543,13 @@ export function HomeGrid() { id: "link", Component: (
- Default link - - Current color link - - - Plain link + Inline link + + External link
), diff --git a/packages/kumo-docs-astro/src/components/demos/LinkDemo.tsx b/packages/kumo-docs-astro/src/components/demos/LinkDemo.tsx index 6e27f08a66..80352dad22 100644 --- a/packages/kumo-docs-astro/src/components/demos/LinkDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/LinkDemo.tsx @@ -2,13 +2,14 @@ import { Link } from "@cloudflare/kumo"; export function LinkBasicDemo() { return ( -
- Default inline link - - Current color link - - - Plain inline link +
+ Underlined inline link + + External link
); @@ -37,14 +38,11 @@ export function LinkExternalDemo() { ); } -export function LinkCurrentVariantDemo() { +export function LinkColorInheritanceDemo() { return (

- This error message contains a{" "} - - link - {" "} - that inherits the red color from its parent. + This error message contains a link that inherits + the red color from its parent.

); } @@ -62,7 +60,7 @@ const CustomRouterLink = ({ export function LinkRenderDemo() { return (
- } variant="inline"> + }> Dashboard (via render) } - variant="inline" > Cloudflare Docs diff --git a/packages/kumo-docs-astro/src/pages/components/link.mdx b/packages/kumo-docs-astro/src/pages/components/link.mdx index 08fbc44c90..af7c75698a 100644 --- a/packages/kumo-docs-astro/src/pages/components/link.mdx +++ b/packages/kumo-docs-astro/src/pages/components/link.mdx @@ -1,7 +1,7 @@ --- layout: ~/layouts/MdxDocLayout.astro title: "Link" -description: "A styled anchor component for inline text links with multiple variants and composition support." +description: "A styled anchor component for inline text links with composition support." sourceFile: "components/link" --- @@ -11,7 +11,7 @@ import { LinkBasicDemo, LinkInParagraphDemo, LinkExternalDemo, - LinkCurrentVariantDemo, + LinkColorInheritanceDemo, LinkRenderDemo, } from "~/components/demos/LinkDemo"; @@ -52,7 +52,9 @@ import { Link } from "@cloudflare/kumo/components/link"; ### Basic Link

- The default Link component renders an underlined anchor with primary color styling. + Link renders an underlined anchor that inherits its color from the + surrounding text. There is a single visual style — Kumo intentionally does + not ship a distinct "brand blue" link color.

```tsx @@ -146,9 +148,7 @@ export default function Example() { return ( <> {/* Force a specific router link (bypasses LinkProvider) */} - } variant="inline"> - Dashboard - + }>Dashboard {/* Force a plain anchor (bypasses LinkProvider) */} } href="https://example.com" target="_blank" rel="noopener noreferrer"> @@ -184,14 +184,14 @@ export default function Example() { -### Current Variant (Color Inheritance) +### Color Inheritance

- The `current` variant inherits color from its parent, useful for links within - colored contexts like alerts. + Link inherits color from its parent, which makes it work naturally inside + colored contexts such as alerts and error messages.

- - + + ### Composition with render prop @@ -228,11 +228,12 @@ export default function Example() { variant - - "inline" | "current" | "plain" + "default" + "default" + + Visual style. Currently a single underlined variant that inherits + color from the surrounding text. - "inline" - Visual style variant render @@ -277,41 +278,6 @@ export default function Example() {
-### Variants - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
VariantDescriptionUse Case
inlinePrimary color with underlineDefault for inline text links
current - Inherits parent text color with underline - - Links within colored contexts (alerts, errors) -
plainPrimary color without underlineNavigation links, menus, footers
-
- ### Link.ExternalIcon

@@ -336,19 +302,16 @@ export default function Example() {

- ### When to Use Each Variant + ### Visual Style
  • - inline: Default choice for links within body text -
  • -
  • - current: Links inside alerts, banners, or other - colored containers + Links are underlined and inherit their color from the surrounding + text — there is no separate "brand blue" link color
  • - plain: Navigation menus, footers, or where underlines - are distracting + Inside colored contexts (alerts, errors, callouts), the link + automatically picks up the parent color
@@ -402,7 +365,7 @@ export default function Example() { The external icon has `aria-hidden="true"` - add descriptive text for screen readers -
  • Ensure sufficient color contrast for all variants
  • +
  • Ensure sufficient color contrast with the surrounding text
  • Use descriptive link text (avoid "click here")
  • diff --git a/packages/kumo-figma/src/generators/link.ts b/packages/kumo-figma/src/generators/link.ts index 90d9cebc03..d2b3174ff0 100644 --- a/packages/kumo-figma/src/generators/link.ts +++ b/packages/kumo-figma/src/generators/link.ts @@ -4,10 +4,9 @@ * Generates a single Link ComponentSet in Figma with variant property. * Reads variant definitions from component-registry.json (the source of truth). * - * Link is a text-only component with three variants: - * - inline: Default underlined link (text-primary) with semi-transparent underline - * - current: Inherits color from parent (text-current) with semi-transparent underline - * - plain: No underline decoration (text-primary) + * Link is a text-only component with a single visual style: + * - default: Underlined link that inherits color from parent text + * (text-current) with semi-transparent underline * * Underline opacity (from .link-current CSS class in kumo-binding.css): * - Light mode: 35% opacity @@ -90,11 +89,11 @@ export function getLinkParsedVariantStyles(variant: string) { variant, classes, description: variantProp.descriptions[variant] || "", - // Link uses text-primary or text-current classes hasUnderline: classes.includes("underline"), // Check if variant uses link-current class (has semi-transparent underline) hasLinkCurrentClass: classes.includes("link-current"), - textColor: classes.includes("text-current") ? "current" : "primary", + // Link always renders in the surrounding text color (text-current) + textColor: classes.includes("text-current") ? "current" : "default", }; } @@ -128,11 +127,12 @@ export function getAllLinkVariantData() { /** * Create a single Link component with the specified variant * - * For variants with underlines (inline, current), we create a custom underline - * as a separate line element to support the semi-transparent opacity that - * matches the CSS implementation (.link-current class). + * Link renders an underlined text node that inherits its color from the + * surrounding text. The underline is created as a separate line element so + * we can preserve the semi-transparent opacity from the CSS `.link-current` + * class. * - * @param variant - The variant name (inline, current, plain) + * @param variant - The variant name (currently only "default") * @param mode - Color mode for underline opacity (light = 35%, dark = 65%) */ async function createLinkComponent( @@ -161,15 +161,8 @@ async function createLinkComponent( const textNode = await createTextNode("Link text", FONT_SIZE.base, 400); textNode.name = "Label"; - // Get the appropriate text color variable - let textColorVar; - if (variantData.textColor === "current") { - // For "current" variant, use default text color in Figma demo - textColorVar = getVariableByName(VAR_NAMES.text.default); - } else { - // For inline and plain, use link/brand color - textColorVar = getVariableByName(VAR_NAMES.text.link); - } + // Link inherits parent color; use the default text variable for the Figma preview. + const textColorVar = getVariableByName(VAR_NAMES.text.default); // Apply text color if (textColorVar) { @@ -234,6 +227,10 @@ async function createLinkComponent( * Creates a single "Link" ComponentSet with variants derived from * component-registry.json. Creates both light and dark mode sections. * + * `figma.combineAsVariants` requires at least two components. Link currently + * ships a single variant, so when only one variant exists we fall back to + * placing the bare component in each section without combining. + * * @param page - Target page for components * @param startY - Y position to start placing the section * @returns The Y position after this section (for next section placement) @@ -275,15 +272,18 @@ export async function generateLinkComponents( components.push(component); } - // Combine all variants into a single ComponentSet - const componentSet = figma.combineAsVariants(components, page); - componentSet.name = "Link"; - componentSet.description = - "Link component with variant styles for navigation"; + // Combine variants into a single ComponentSet. `combineAsVariants` requires + // 2+ components; with a single variant we keep the bare component. + const primary: ComponentNode | ComponentSetNode = + components.length > 1 + ? figma.combineAsVariants(components, page) + : components[0]; + primary.name = "Link"; + primary.description = "Link component with variant styles for navigation"; // Calculate content dimensions (add label column width) - const contentWidth = componentSet.width + labelColumnWidth; - const contentHeight = componentSet.height; + const contentWidth = primary.width + labelColumnWidth; + const contentHeight = primary.height; // Content Y offset to make room for title inside frame const contentYOffset = SECTION_TITLE.height; @@ -302,10 +302,10 @@ export async function generateLinkComponents( contentHeight + SECTION_PADDING * 2 + contentYOffset, ); - // Move ComponentSet into light section frame - lightSection.frame.appendChild(componentSet); - componentSet.x = SECTION_PADDING + labelColumnWidth; - componentSet.y = SECTION_PADDING + contentYOffset; + // Move primary node into light section frame + lightSection.frame.appendChild(primary); + primary.x = SECTION_PADDING + labelColumnWidth; + primary.y = SECTION_PADDING + contentYOffset; // Add row labels to light section for (const label of rowLabels) { diff --git a/packages/kumo/src/components/link/link.test.tsx b/packages/kumo/src/components/link/link.test.tsx index 92b735febc..d6fdbf9076 100644 --- a/packages/kumo/src/components/link/link.test.tsx +++ b/packages/kumo/src/components/link/link.test.tsx @@ -17,54 +17,29 @@ describe("Link", () => { expect(() => createElement(Link, props)).not.toThrow(); }); - it("should apply inline variant classes", () => { - expect(KUMO_LINK_VARIANTS.variant.inline.classes).toContain( - "text-kumo-link", - ); - expect(KUMO_LINK_VARIANTS.variant.inline.classes).toContain("underline"); - expect(KUMO_LINK_VARIANTS.variant.inline.classes).toContain("link-current"); + it("exposes a single 'default' variant", () => { + expect(Object.keys(KUMO_LINK_VARIANTS.variant)).toEqual(["default"]); }); - it("should apply current variant classes", () => { - expect(KUMO_LINK_VARIANTS.variant.current.classes).toContain( - "text-current", - ); - expect(KUMO_LINK_VARIANTS.variant.current.classes).toContain("underline"); - expect(KUMO_LINK_VARIANTS.variant.current.classes).toContain( + it("applies default variant classes: current color + underline", () => { + expect(KUMO_LINK_VARIANTS.variant.default.classes).toContain("text-current"); + expect(KUMO_LINK_VARIANTS.variant.default.classes).toContain("underline"); + expect(KUMO_LINK_VARIANTS.variant.default.classes).toContain( "link-current", ); }); - it("should apply plain variant classes", () => { - expect(KUMO_LINK_VARIANTS.variant.plain.classes).toContain( - "text-kumo-link", - ); - expect(KUMO_LINK_VARIANTS.variant.plain.classes).not.toContain("underline"); - }); - - it("should render with inline variant", () => { - const props = { - href: "#", - variant: "inline" as const, - children: "Inline link", - }; - expect(() => createElement(Link, props)).not.toThrow(); - }); - - it("should render with current variant", () => { - const props = { - href: "#", - variant: "current" as const, - children: "Current link", - }; - expect(() => createElement(Link, props)).not.toThrow(); + it("does not ship a brand-blue link color", () => { + for (const { classes } of Object.values(KUMO_LINK_VARIANTS.variant)) { + expect(classes).not.toContain("text-kumo-link"); + } }); - it("should render with plain variant", () => { + it("should render with default variant", () => { const props = { href: "#", - variant: "plain" as const, - children: "Plain link", + variant: "default" as const, + children: "Default link", }; expect(() => createElement(Link, props)).not.toThrow(); }); @@ -79,13 +54,13 @@ describe("Link", () => { }); it("should generate variant classes via linkVariants helper", () => { - expect(linkVariants({ variant: "inline" })).toContain("text-kumo-link"); - expect(linkVariants({ variant: "current" })).toContain("text-current"); - expect(linkVariants({ variant: "plain" })).toContain("text-kumo-link"); + expect(linkVariants({ variant: "default" })).toContain("text-current"); + expect(linkVariants({ variant: "default" })).toContain("underline"); }); - it("should default to inline variant", () => { - expect(linkVariants()).toContain("text-kumo-link"); + it("should default to the 'default' variant", () => { + expect(linkVariants()).toBe(linkVariants({ variant: "default" })); + expect(linkVariants()).toContain("text-current"); expect(linkVariants()).toContain("underline"); }); @@ -110,7 +85,6 @@ describe("Link", () => { const customAnchor = createElement("a", { href: "/dashboard" }); const props = { render: customAnchor, - variant: "inline" as const, children: "Dashboard", }; expect(() => createElement(Link, props)).not.toThrow(); diff --git a/packages/kumo/src/components/link/link.tsx b/packages/kumo/src/components/link/link.tsx index 9d4012b9fd..cd98fd96f7 100644 --- a/packages/kumo/src/components/link/link.tsx +++ b/packages/kumo/src/components/link/link.tsx @@ -38,31 +38,27 @@ const ExternalIcon = (props: SVGProps) => ( ExternalIcon.displayName = "Link.ExternalIcon"; -/** Link variant definitions mapping variant names to their Tailwind classes. */ +/** + * Link variant definitions. + * + * Link intentionally exposes a single visual style: an underlined anchor that + * inherits color from its parent text (typically rendering as the default + * foreground color, e.g. black on light mode). This keeps links consistent + * across the product and avoids competing "brand blue" link colors. + */ export const KUMO_LINK_VARIANTS = { variant: { - inline: { - classes: - // text-kumo-link provides defensive color that won't be overridden by global `a` styles - "text-kumo-link underline underline-offset-[0.15em] decoration-[0.0625em] link-current transition-colors", - description: "Inline text link that flows with content", - }, - current: { + default: { classes: "text-current underline underline-offset-[0.15em] decoration-[0.0625em] link-current transition-colors", - description: "Link that inherits color from parent text", - }, - plain: { - classes: - // text-kumo-link provides defensive color that won't be overridden by global `a` styles - "text-kumo-link hover:text-kumo-link/70 transition-colors", - description: "Link without underline decoration", + description: + "Underlined link that inherits color from parent text (default foreground)", }, }, } as const; export const KUMO_LINK_DEFAULT_VARIANTS = { - variant: "inline", + variant: "default", } as const; export type KumoLinkVariant = keyof typeof KUMO_LINK_VARIANTS.variant; @@ -70,10 +66,11 @@ export type KumoLinkVariant = keyof typeof KUMO_LINK_VARIANTS.variant; export interface KumoLinkVariantsProps { /** * Visual style of the link. - * - `"inline"` — Inline text link that flows with content - * - `"current"` — Link that inherits color from parent text - * - `"plain"` — Link without underline decoration - * @default "inline" + * + * Link currently exposes a single `"default"` variant: an underlined anchor + * that inherits the surrounding text color. The prop is retained so the + * component conforms to the Kumo variant standard and remains extensible. + * @default "default" */ variant?: KumoLinkVariant; } @@ -124,6 +121,10 @@ export type LinkProps = useRender.ComponentProps<"a"> & * accessibility. Routing behavior belongs in the application layer, via * either the `render` prop or a `LinkProvider`. * + * Visually, Link renders as an underlined anchor that inherits its color from + * the surrounding text. There is a single `variant` (`"default"`) — Kumo does + * not ship a distinct "brand blue" link style. + * * - Without `render`: renders via LinkProvider (default `
    ` or configured component) * - With `render`: merges props onto the provided element with proper ref/event handling * @@ -141,9 +142,7 @@ export type LinkProps = useRender.ComponentProps<"a"> & * * @example Composition with React Router via render prop * ```tsx - * } variant="inline"> - * Dashboard - * + * }>Dashboard * ``` * * @example Composition via LinkProvider (recommended for app-wide routing) @@ -159,7 +158,7 @@ export type LinkProps = useRender.ComponentProps<"a"> & * ``` */ const LinkBase = forwardRef(function Link( - { className, variant = "inline", render, ...props }, + { className, variant = "default", render, ...props }, ref, ) { const LinkComponent = useLinkComponent();