diff --git a/changes/34369-tag-contrast b/changes/34369-tag-contrast
new file mode 100644
index 0000000000..5d71fdb00c
--- /dev/null
+++ b/changes/34369-tag-contrast
@@ -0,0 +1 @@
+- Fixed low color contrast on the "Inherited" tag and unified the styling of tags (e.g. "Inherited," "API," "Patch," host filter chips, and host label pills) across the UI to match the design system.
diff --git a/frontend/components/CommandPalette/components/PolicyPicker.tsx b/frontend/components/CommandPalette/components/PolicyPicker.tsx
index d224e6e7a8..375ef0a846 100644
--- a/frontend/components/CommandPalette/components/PolicyPicker.tsx
+++ b/frontend/components/CommandPalette/components/PolicyPicker.tsx
@@ -10,7 +10,7 @@ import {
IPolicyStats,
} from "interfaces/policy";
import CriticalPolicyBadge from "components/CriticalPolicyBadge";
-import PillBadge from "components/PillBadge";
+import Tag from "components/Tag";
import { PATCH_TOOLTIP_CONTENT } from "components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges";
import usePickerSearch from "./usePickerSearch";
@@ -110,12 +110,14 @@ const PolicyPicker = ({
{showCriticalBadge && }
{showPatchBadge && (
- Patch
+
+ Patch
+
)}
{showInheritedBadge && (
-
+
Inherited
-
+
)}
diff --git a/frontend/components/CommandPalette/components/ReportPicker.tsx b/frontend/components/CommandPalette/components/ReportPicker.tsx
index 70c992b1b9..eb43fcdb47 100644
--- a/frontend/components/CommandPalette/components/ReportPicker.tsx
+++ b/frontend/components/CommandPalette/components/ReportPicker.tsx
@@ -5,7 +5,7 @@ import { APP_CONTEXT_ALL_TEAMS_ID, ITeamSummary } from "interfaces/team";
import queriesAPI, { IQueriesResponse } from "services/entities/queries";
import { ISchedulableQuery } from "interfaces/schedulable_query";
import Icon from "components/Icon";
-import PillBadge from "components/PillBadge";
+import Tag from "components/Tag";
import TooltipWrapper from "components/TooltipWrapper";
import usePickerSearch from "./usePickerSearch";
@@ -110,9 +110,9 @@ const ReportPicker = ({
)}
{showInheritedBadge && (
-
+
Inherited
-
+
)}
diff --git a/frontend/components/PillBadge/PillBadge.tsx b/frontend/components/PillBadge/PillBadge.tsx
deleted file mode 100644
index 10f3c281a9..0000000000
--- a/frontend/components/PillBadge/PillBadge.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from "react";
-import classnames from "classnames";
-
-import TooltipWrapper from "components/TooltipWrapper";
-
-const baseClass = "pill-badge";
-
-interface IPillBadgeProps {
- children: React.ReactNode;
- tipContent?: JSX.Element | string;
- className?: string;
-}
-
-const PillBadge = ({ children, tipContent, className }: IPillBadgeProps) => {
- const classNames = classnames(baseClass, className);
-
- return (
-
-
- {children}
-
-
- );
-};
-
-export default PillBadge;
diff --git a/frontend/components/PillBadge/_styles.scss b/frontend/components/PillBadge/_styles.scss
deleted file mode 100644
index d29933cded..0000000000
--- a/frontend/components/PillBadge/_styles.scss
+++ /dev/null
@@ -1,25 +0,0 @@
-.pill-badge {
- &__element {
- display: flex;
- height: 16px;
- padding: 0 4px;
- justify-content: center;
- align-items: center;
- gap: $pad-small;
- font-weight: $bold;
- font-size: $xxx-small;
- color: $ui-fleet-black-75;
- line-height: 15px;
- border-radius: $border-radius;
- background: $ui-fleet-black-10;
- padding: 2px 4px;
- }
-
- @include tooltip5-arrow-styles;
-
- .react-tooltip {
- @include tooltip-text;
- font-style: normal;
- text-align: center;
- }
-}
diff --git a/frontend/components/PillBadge/index.ts b/frontend/components/PillBadge/index.ts
deleted file mode 100644
index 72d2797469..0000000000
--- a/frontend/components/PillBadge/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./PillBadge";
diff --git a/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx b/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx
index 0f4aa34f11..cde9c19818 100644
--- a/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx
+++ b/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx
@@ -4,7 +4,7 @@ import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
import { SoftwareInstallPolicyTypeSet } from "interfaces/software";
-import PillBadge from "components/PillBadge";
+import Tag from "components/Tag";
const baseClass = "software-install-policy-badges";
@@ -20,7 +20,9 @@ interface IPatchBadgesProps {
const SoftwareInstallPolicyBadges = ({ policyType }: IPatchBadgesProps) => {
const renderPatchBadge = () => (
- Patch
+
+ Patch
+
);
const renderAutomaticInstallBadge = () => (
diff --git a/frontend/components/Tag/Tag.stories.tsx b/frontend/components/Tag/Tag.stories.tsx
new file mode 100644
index 0000000000..7c8f81b196
--- /dev/null
+++ b/frontend/components/Tag/Tag.stories.tsx
@@ -0,0 +1,92 @@
+import React from "react";
+import { Meta, StoryObj } from "@storybook/react";
+
+import Icon from "components/Icon";
+
+import Tag from "./Tag";
+import "../../index.scss";
+
+const meta: Meta = {
+ component: Tag,
+ title: "Components/Tag",
+ argTypes: {
+ children: { control: "text" },
+ size: { control: "radio", options: ["large", "small"] },
+ disabled: { control: "boolean" },
+ tooltip: { control: "text" },
+ className: { control: "text" },
+ onClick: { table: { disable: true } },
+ onDismiss: { table: { disable: true } },
+ },
+ parameters: { controls: { expanded: true } },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Static: Story = {
+ args: {
+ children: "Inherited",
+ },
+};
+
+export const Small: Story = {
+ args: {
+ children: "Patch",
+ size: "small",
+ },
+};
+
+export const WithTooltip: Story = {
+ args: {
+ children: "Inherited",
+ tooltip: "This report runs on all hosts.",
+ },
+};
+
+export const WithIconAndText: Story = {
+ args: {
+ children: (
+ <>
+
+ Report clipped
+ >
+ ),
+ },
+};
+
+export const Clickable: Story = {
+ args: {
+ type: "clickable",
+ children: "iPadOS",
+ onClick: () => undefined,
+ },
+};
+
+export const ClickableDisabled: Story = {
+ args: {
+ type: "clickable",
+ children: "iPadOS",
+ disabled: true,
+ onClick: () => undefined,
+ },
+};
+
+export const Dismissible: Story = {
+ args: {
+ type: "dismissible",
+ children: "Apple Silicon macOS hosts",
+ onDismiss: () => undefined,
+ },
+};
+
+export const DismissibleWithTooltip: Story = {
+ args: {
+ type: "dismissible",
+ children: "Apple Silicon macOS hosts",
+ tooltip: "Hosts filtered to Apple Silicon Macs.",
+ dismissLabel: "Apple Silicon macOS hosts",
+ onDismiss: () => undefined,
+ },
+};
diff --git a/frontend/components/Tag/Tag.tests.tsx b/frontend/components/Tag/Tag.tests.tsx
new file mode 100644
index 0000000000..0d7eb11d5b
--- /dev/null
+++ b/frontend/components/Tag/Tag.tests.tsx
@@ -0,0 +1,158 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import Tag from "./Tag";
+
+describe("Tag", () => {
+ it("renders static tags as non-interactive text", () => {
+ render(Inherited);
+
+ expect(screen.getByText("Inherited")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("defaults to the large size", () => {
+ render(Inherited);
+
+ expect(screen.getByText("Inherited")).not.toHaveClass("tag--small");
+ });
+
+ it("adds the small modifier class when size is set to small", () => {
+ render(Inherited);
+
+ expect(screen.getByText("Inherited")).toHaveClass("tag--small");
+ });
+
+ it("does not wrap the tag in a tooltip when tooltip is omitted", () => {
+ const { container } = render(Inherited);
+
+ expect(container.querySelector(".component__tooltip-wrapper")).toBeNull();
+ });
+
+ it("wraps the tag in a tooltip when tooltip is provided", () => {
+ const { container } = render(
+ Inherited
+ );
+
+ expect(screen.getByText("Inherited")).toBeInTheDocument();
+ expect(
+ container.querySelector(".component__tooltip-wrapper")
+ ).not.toBeNull();
+ });
+
+ it("renders clickable tags as a button and calls onClick", async () => {
+ const handler = jest.fn();
+ render(
+
+ iPadOS
+
+ );
+
+ const button = screen.getByRole("button", { name: "iPadOS" });
+ await userEvent.click(button);
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("disables the clickable tag's button when disabled is set", () => {
+ render(
+ undefined} disabled>
+ iPadOS
+
+ );
+
+ expect(screen.getByRole("button", { name: "iPadOS" })).toBeDisabled();
+ });
+
+ it("renders dismissible tags with a dismiss button and calls onDismiss", async () => {
+ const handler = jest.fn();
+ render(
+
+ Apple Silicon macOS hosts
+
+ );
+
+ expect(screen.getByText("Apple Silicon macOS hosts")).toBeInTheDocument();
+ const dismissButton = screen.getByRole("button");
+ await userEvent.click(dismissButton);
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("gives the dismiss button an accessible name even when dismissLabel is omitted", () => {
+ render(
+ undefined}>
+ Apple Silicon macOS hosts
+
+ );
+
+ expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
+ });
+
+ it("uses dismissLabel as the dismiss button's accessible name when provided", () => {
+ render(
+ undefined}
+ dismissLabel="Apple Silicon macOS hosts"
+ >
+ Apple Silicon macOS hosts
+
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Apple Silicon macOS hosts" })
+ ).toBeInTheDocument();
+ });
+
+ it("does not render a native title tooltip on the dismiss button (aria-label carries the accessible name)", () => {
+ render(
+ undefined}>
+ Apple Silicon macOS hosts
+
+ );
+
+ expect(screen.getByRole("button", { name: "Dismiss" })).not.toHaveAttribute(
+ "title"
+ );
+ });
+
+ it.each([
+ {
+ case: "static",
+ renderTag: () => render(Inherited),
+ label: "Inherited",
+ },
+ {
+ case: "clickable",
+ renderTag: () =>
+ render(
+ undefined}
+ >
+ iPadOS
+
+ ),
+ label: "iPadOS",
+ },
+ {
+ case: "dismissible",
+ renderTag: () =>
+ render(
+ undefined}
+ >
+ Apple Silicon macOS hosts
+
+ ),
+ label: "Apple Silicon macOS hosts",
+ },
+ ])("applies className to the root of a $case tag", ({ renderTag, label }) => {
+ renderTag();
+
+ expect(screen.getByText(label).closest(".tag")).toHaveClass("custom-tag");
+ });
+});
diff --git a/frontend/components/Tag/Tag.tsx b/frontend/components/Tag/Tag.tsx
new file mode 100644
index 0000000000..2fd3556ded
--- /dev/null
+++ b/frontend/components/Tag/Tag.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import classnames from "classnames";
+
+import Icon from "components/Icon";
+import TooltipWrapper from "components/TooltipWrapper";
+
+const baseClass = "tag";
+
+interface ITagBaseProps {
+ children: React.ReactNode;
+ /** Default: "large" (28px). Per design, use "small" (24px) sparingly. */
+ size?: "large" | "small";
+ className?: string;
+ /** Wraps the tag in a tooltip that shows this content on hover */
+ tooltip?: JSX.Element | string;
+}
+
+interface IStaticTagProps extends ITagBaseProps {
+ type?: "static";
+ onClick?: never;
+ onDismiss?: never;
+ dismissLabel?: never;
+ /** Static tags are non-interactive — disabled doesn't apply. */
+ disabled?: never;
+}
+
+interface IClickableTagProps extends ITagBaseProps {
+ type: "clickable";
+ onClick: () => void;
+ onDismiss?: never;
+ dismissLabel?: never;
+ disabled?: boolean;
+}
+
+interface IDismissibleTagProps extends ITagBaseProps {
+ type: "dismissible";
+ onClick?: never;
+ onDismiss: () => void;
+ /** Accessible name for the dismiss button (screen readers only, no native tooltip). Defaults to "Dismiss". */
+ dismissLabel?: string;
+ /** Dismissible tags are always interactive — no production caller disables them. */
+ disabled?: never;
+}
+
+type ITagProps = IStaticTagProps | IClickableTagProps | IDismissibleTagProps;
+
+const Tag = (props: ITagProps) => {
+ const { children, className, tooltip } = props;
+
+ const classNames = classnames(baseClass, className, {
+ [`${baseClass}--clickable`]: props.type === "clickable",
+ [`${baseClass}--dismissible`]: props.type === "dismissible",
+ [`${baseClass}--small`]: props.size === "small",
+ });
+
+ let content: JSX.Element;
+
+ if (props.type === "clickable") {
+ content = (
+
+ );
+ } else if (props.type === "dismissible") {
+ const dismissLabel = props.dismissLabel ?? "Dismiss";
+
+ content = (
+
+ {children}
+
+
+ );
+ } else {
+ content = {children};
+ }
+
+ if (!tooltip) {
+ return content;
+ }
+
+ return (
+
+ {content}
+
+ );
+};
+
+export default Tag;
diff --git a/frontend/components/Tag/_styles.scss b/frontend/components/Tag/_styles.scss
new file mode 100644
index 0000000000..32ac51cb9a
--- /dev/null
+++ b/frontend/components/Tag/_styles.scss
@@ -0,0 +1,95 @@
+.tag {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: $pad-small;
+ height: 28px;
+ padding: 0 $pad-small;
+ border: 1px solid $ui-fleet-black-25;
+ border-radius: $border-radius;
+ font-size: $xx-small;
+ font-weight: $bold;
+ color: $ui-fleet-black-75;
+ white-space: nowrap;
+ box-sizing: border-box;
+
+ // per design, large (28px, the default set above) is used most often;
+ // small is reserved for tight contexts like a table row
+ &--small {
+ height: 24px;
+ }
+
+ &--clickable {
+ background: none;
+ cursor: pointer;
+
+ // Interactive states only apply while enabled — disabled clickable tags
+ // should look inert, not respond to hover/focus/press.
+ &:not(:disabled) {
+ &:hover {
+ background-color: $ui-off-white;
+ border-color: $ui-fleet-black-50;
+ }
+
+ &:focus-visible {
+ border-color: $core-fleet-black;
+ outline: none;
+ }
+
+ &:active {
+ background-color: $ui-fleet-black-5;
+ border-color: $ui-fleet-black-50;
+ }
+ }
+
+ &:disabled {
+ border-color: $ui-fleet-black-33;
+ color: $ui-fleet-black-33;
+ cursor: default;
+ }
+ }
+
+ &--dismissible {
+ padding: 0 $pad-xsmall 0 $pad-small;
+
+ // Only the dismiss button is interactive on a dismissible tag, so hover
+ // and active feedback lives on the button itself (see &__dismiss below).
+ // :focus-within stays here — when the button takes keyboard focus, the
+ // outer tag border lights up so the focused element is easy to see.
+ &:focus-within {
+ border-color: $core-fleet-black;
+ }
+ }
+
+ &__label {
+ display: inline-flex;
+ align-items: center;
+ gap: $pad-small;
+ overflow: hidden;
+ }
+
+ &__dismiss {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ background: none;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+
+ &:hover {
+ background-color: $ui-off-white;
+ }
+
+ &:active {
+ background-color: $ui-fleet-black-5;
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+ }
+}
diff --git a/frontend/components/Tag/index.ts b/frontend/components/Tag/index.ts
new file mode 100644
index 0000000000..21bb058ad4
--- /dev/null
+++ b/frontend/components/Tag/index.ts
@@ -0,0 +1 @@
+export { default } from "./Tag";
diff --git a/frontend/components/buttons/Button/Button.tsx b/frontend/components/buttons/Button/Button.tsx
index ab6144e063..2e6457c7b6 100644
--- a/frontend/components/buttons/Button/Button.tsx
+++ b/frontend/components/buttons/Button/Button.tsx
@@ -10,7 +10,6 @@ export type ButtonVariant =
| "default"
| "alert"
| "pill"
- | "grey-pill"
| "link" // Looks like CustomLink with animated underline on hover
| "secondary" // Bordered secondary button (off-white fill + border). The new preferred secondary — see #35329.
| "subdued" // Low-emphasis borderless text + icon button. Not to be confused with a link.
diff --git a/frontend/components/buttons/Button/_styles.scss b/frontend/components/buttons/Button/_styles.scss
index beb8fa5d3d..d73f07aecf 100644
--- a/frontend/components/buttons/Button/_styles.scss
+++ b/frontend/components/buttons/Button/_styles.scss
@@ -196,32 +196,6 @@ $base-class: "button";
}
}
- &--grey-pill {
- @include button-variant(
- $core-fleet-white,
- $ui-off-white,
- null,
- $inverse: true
- );
- color: $ui-fleet-black-75;
- border: 1px solid $ui-fleet-black-25;
- border-radius: 4px;
- box-sizing: border-box;
- font-size: $xx-small;
- font-weight: $bold;
- padding: 0 $pad-small;
- height: 28px;
-
- &:hover,
- &:focus {
- border: 1px solid $ui-fleet-black-50;
- }
-
- &:active {
- box-shadow: inset 2px 2px 2px rgba(0, 0, 0, 0.1);
- }
- }
-
// Looks exactly like a CustomLink but is a
);
@@ -180,13 +181,10 @@ const HostReportCard = ({
{report.report_clipped && (
-
+
Report clipped
-
+
)}
{
return (
- onLabelClick(label)}
- variant="pill"
className={`${baseClass}__list-button`}
>
-
+
);
});
diff --git a/frontend/pages/hosts/details/cards/Labels/_styles.scss b/frontend/pages/hosts/details/cards/Labels/_styles.scss
index 2848d18409..acd9333b7e 100644
--- a/frontend/pages/hosts/details/cards/Labels/_styles.scss
+++ b/frontend/pages/hosts/details/cards/Labels/_styles.scss
@@ -17,15 +17,14 @@
min-width: 0;
}
- .button,
- .children-wrapper {
+ .tag {
max-width: 100%;
.__react_component_tooltip {
font-weight: $regular;
}
}
- .button.host-labels-card__list-button {
+ .tag.host-labels-card__list-button {
max-width: 300px;
}
}
diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss
index 12255d6821..0a84abfe79 100644
--- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss
+++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss
@@ -163,10 +163,6 @@
text-align: center;
}
}
-
- .inherited-badge {
- overflow: initial;
- }
}
}
}
diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx
index 3e28ce7d67..bbb3a7b766 100644
--- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx
+++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx
@@ -26,7 +26,7 @@ import sortUtils from "utilities/sort";
import { DEFAULT_EMPTY_CELL_VALUE, PolicyResponse } from "utilities/constants";
import CriticalPolicyBadge from "components/CriticalPolicyBadge";
-import PillBadge from "components/PillBadge";
+import Tag from "components/Tag";
import { PATCH_TOOLTIP_CONTENT } from "components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges";
import { getConditionalSelectHeaderCheckboxProps } from "components/TableContainer/utilities/config_utils";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
@@ -293,14 +293,14 @@ const generateTableHeaders = (
<>
{isPremiumTier && critical && }
{type === "patch" && (
-
+
Patch
-
+
)}
{viewingTeamPolicies && team_id === null && (
-
+
Inherited
-
+
)}
>
}
diff --git a/frontend/pages/queries/ManageQueriesPage/_styles.scss b/frontend/pages/queries/ManageQueriesPage/_styles.scss
index 38f1bedba4..f5ba7549c2 100644
--- a/frontend/pages/queries/ManageQueriesPage/_styles.scss
+++ b/frontend/pages/queries/ManageQueriesPage/_styles.scss
@@ -94,10 +94,6 @@
tbody {
.name__cell {
width: auto;
-
- .inherited-badge {
- overflow: initial;
- }
}
@media (max-width: $break-md) {
diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx
index 60b0d4d76e..0900029872 100644
--- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx
+++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx
@@ -36,7 +36,7 @@ import PlatformCell from "components/TableContainer/DataTable/PlatformCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import PerformanceImpactCell from "components/TableContainer/DataTable/PerformanceImpactCell";
import TooltipWrapper from "components/TooltipWrapper";
-import PillBadge from "components/PillBadge";
+import Tag from "components/Tag";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip";
@@ -171,9 +171,9 @@ const generateColumnConfigs = ({
{viewingTeamScope &&
// inherited
team_id !== currentTeamId && (
-
+
Inherited
-
+
)}
>
}