diff --git a/docs/data/material/components/activity-timeline/activity-timeline.md b/docs/data/material/components/activity-timeline/activity-timeline.md
index 39ed8bc8..43a45d02 100644
--- a/docs/data/material/components/activity-timeline/activity-timeline.md
+++ b/docs/data/material/components/activity-timeline/activity-timeline.md
@@ -81,6 +81,7 @@ For audit trails, server events, or retryable workflows, pass explicit step stat
| `steps` | `ActivityTimelineStep[]` | - | Ordered list of timeline steps. |
| `automaticProgress` | `boolean` | `false` | Calculates progress from step position instead of using only step status. |
| `size` | `'medium' \| 'large'` | `'large'` | Controls text sizing and spacing. |
+| `variant` | `'default' \| 'gradient'` | `'default'` | Renders glowing status dots, a per-step time, and a fade-out down older steps. |
## Step shape
@@ -90,9 +91,28 @@ interface ActivityTimelineStep {
title: string;
subTitle?: string;
content?: React.ReactNode;
+ time?: string;
}
```
+## Gradient variant
+
+Set `variant="gradient"` to show events with glowing status dots and a time
+against the timeline. Older steps fade out down the list.
+
+```tsx
+
+```
+
## Accessibility
Keep titles short and descriptive so the sequence is easy to scan.
diff --git a/docs/data/material/components/button/ButtonIconOnlyGradient.js b/docs/data/material/components/button/ButtonIconOnlyGradient.js
new file mode 100644
index 00000000..d7c6c91d
--- /dev/null
+++ b/docs/data/material/components/button/ButtonIconOnlyGradient.js
@@ -0,0 +1,27 @@
+import * as React from "react";
+import MicNoneOutlinedIcon from "@mui/icons-material/MicNoneOutlined";
+import { Button, Stack, ThemeMode, ThemeProvider } from "@open-ui-kit/core";
+
+export default function ButtonIconOnlyGradient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/button/ButtonIconOnlyGradient.tsx b/docs/data/material/components/button/ButtonIconOnlyGradient.tsx
new file mode 100644
index 00000000..d7c6c91d
--- /dev/null
+++ b/docs/data/material/components/button/ButtonIconOnlyGradient.tsx
@@ -0,0 +1,27 @@
+import * as React from "react";
+import MicNoneOutlinedIcon from "@mui/icons-material/MicNoneOutlined";
+import { Button, Stack, ThemeMode, ThemeProvider } from "@open-ui-kit/core";
+
+export default function ButtonIconOnlyGradient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/button/button.md b/docs/data/material/components/button/button.md
index 1fac8e20..1058abe1 100644
--- a/docs/data/material/components/button/button.md
+++ b/docs/data/material/components/button/button.md
@@ -64,6 +64,13 @@ For icon-only buttons, pass the icon as the only child and provide an accessible
{{"demo": "ButtonIcons.js", "bg": true}}
+### Icon Button AI
+
+An icon-only button on the `gradient` variant gets its own treatment rather than the text button scaled down: a round control filled with the `Icon-Button-Blue` gradient and ringed by `Icon-Button-Blue-Glow`.
+It applies automatically — pass the icon as the only child, as above, and set `variant="gradient"`.
+
+{{"demo": "ButtonIconOnlyGradient.js", "bg": true}}
+
## States
Buttons support disabled and loading states through the standard button API.
diff --git a/docs/data/material/components/card/AlertCard.js b/docs/data/material/components/card/AlertCard.js
new file mode 100644
index 00000000..322a8c55
--- /dev/null
+++ b/docs/data/material/components/card/AlertCard.js
@@ -0,0 +1,42 @@
+import * as React from "react";
+import {
+ Card,
+ CardAlertHeader,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ Stack,
+ ThemeMode,
+ ThemeProvider,
+} from "@open-ui-kit/core";
+
+export default function AlertCard() {
+ return (
+
+
+
+ CRITICAL ALERT
+
+
+
+ The system detected a revision loop during itinerary optimization
+ within a high-density semantic cluster. The agent produced an
+ itinerary that violated walking constraints, triggering an
+ optimization cycle before producing the final output.
+
+
+
+
+ WARNING
+
+
+
+ Initial itinerary violated constraints and required a revision
+ pass. Consider improving constraint conditioning upstream.
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/AlertCard.tsx b/docs/data/material/components/card/AlertCard.tsx
new file mode 100644
index 00000000..322a8c55
--- /dev/null
+++ b/docs/data/material/components/card/AlertCard.tsx
@@ -0,0 +1,42 @@
+import * as React from "react";
+import {
+ Card,
+ CardAlertHeader,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ Stack,
+ ThemeMode,
+ ThemeProvider,
+} from "@open-ui-kit/core";
+
+export default function AlertCard() {
+ return (
+
+
+
+ CRITICAL ALERT
+
+
+
+ The system detected a revision loop during itinerary optimization
+ within a high-density semantic cluster. The agent produced an
+ itinerary that violated walking constraints, triggering an
+ optimization cycle before producing the final output.
+
+
+
+
+ WARNING
+
+
+
+ Initial itinerary violated constraints and required a revision
+ pass. Consider improving constraint conditioning upstream.
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/CardWithImage.js b/docs/data/material/components/card/CardWithImage.js
new file mode 100644
index 00000000..55c90b56
--- /dev/null
+++ b/docs/data/material/components/card/CardWithImage.js
@@ -0,0 +1,38 @@
+import * as React from "react";
+import {
+ Button,
+ Card,
+ CardActions,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ ThemeMode,
+ ThemeProvider,
+} from "@open-ui-kit/core";
+
+export default function CardWithImage() {
+ return (
+
+
+
+
+
+ Get clear, AI-powered explanations for events, anomalies, or
+ performance changes.
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/CardWithImage.tsx b/docs/data/material/components/card/CardWithImage.tsx
new file mode 100644
index 00000000..55c90b56
--- /dev/null
+++ b/docs/data/material/components/card/CardWithImage.tsx
@@ -0,0 +1,38 @@
+import * as React from "react";
+import {
+ Button,
+ Card,
+ CardActions,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ ThemeMode,
+ ThemeProvider,
+} from "@open-ui-kit/core";
+
+export default function CardWithImage() {
+ return (
+
+
+
+
+
+ Get clear, AI-powered explanations for events, anomalies, or
+ performance changes.
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/ConnectorCard.js b/docs/data/material/components/card/ConnectorCard.js
new file mode 100644
index 00000000..ea43391a
--- /dev/null
+++ b/docs/data/material/components/card/ConnectorCard.js
@@ -0,0 +1,32 @@
+import * as React from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ ThemeMode,
+ ThemeProvider,
+ Typography,
+} from "@open-ui-kit/core";
+
+export default function ConnectorCard() {
+ return (
+
+
+ ({ color: theme.palette.vars.baseTextMedium })}
+ >
+ Divergent Planning Paths
+
+
+
+
+ The Itinerary Planner and Schedule Planner increasingly disagree on
+ the ordering of the same set of activities.
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/ConnectorCard.tsx b/docs/data/material/components/card/ConnectorCard.tsx
new file mode 100644
index 00000000..ea43391a
--- /dev/null
+++ b/docs/data/material/components/card/ConnectorCard.tsx
@@ -0,0 +1,32 @@
+import * as React from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ ThemeMode,
+ ThemeProvider,
+ Typography,
+} from "@open-ui-kit/core";
+
+export default function ConnectorCard() {
+ return (
+
+
+ ({ color: theme.palette.vars.baseTextMedium })}
+ >
+ Divergent Planning Paths
+
+
+
+
+ The Itinerary Planner and Schedule Planner increasingly disagree on
+ the ordering of the same set of activities.
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/DisabledCard.js b/docs/data/material/components/card/DisabledCard.js
index e61e46d5..2a9cd87c 100644
--- a/docs/data/material/components/card/DisabledCard.js
+++ b/docs/data/material/components/card/DisabledCard.js
@@ -10,7 +10,7 @@ import {
export default function DisabledCard() {
return (
-
+
diff --git a/docs/data/material/components/card/DisabledCard.tsx b/docs/data/material/components/card/DisabledCard.tsx
index e61e46d5..2a9cd87c 100644
--- a/docs/data/material/components/card/DisabledCard.tsx
+++ b/docs/data/material/components/card/DisabledCard.tsx
@@ -10,7 +10,7 @@ import {
export default function DisabledCard() {
return (
-
+
diff --git a/docs/data/material/components/card/GlassCard.js b/docs/data/material/components/card/GlassCard.js
new file mode 100644
index 00000000..f5b73a1f
--- /dev/null
+++ b/docs/data/material/components/card/GlassCard.js
@@ -0,0 +1,29 @@
+import * as React from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ ThemeMode,
+ ThemeProvider,
+} from "@open-ui-kit/core";
+
+export default function GlassCard() {
+ return (
+
+
+
+
+
+ Implement a layered request strategy with automatic retries,
+ provider failover, and timeout tuning to reduce sensitivity to
+ transient outages.
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/GlassCard.tsx b/docs/data/material/components/card/GlassCard.tsx
new file mode 100644
index 00000000..f5b73a1f
--- /dev/null
+++ b/docs/data/material/components/card/GlassCard.tsx
@@ -0,0 +1,29 @@
+import * as React from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ ThemeMode,
+ ThemeProvider,
+} from "@open-ui-kit/core";
+
+export default function GlassCard() {
+ return (
+
+
+
+
+
+ Implement a layered request strategy with automatic retries,
+ provider failover, and timeout tuning to reduce sensitivity to
+ transient outages.
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/card/card.md b/docs/data/material/components/card/card.md
index 0f5200dc..f96c3b3b 100644
--- a/docs/data/material/components/card/card.md
+++ b/docs/data/material/components/card/card.md
@@ -66,10 +66,55 @@ Keep headings short and put supporting metadata in the subheader or content area
{{"demo": "CardDenseContent.js", "bg": true}}
+## Alerts
+
+Pass `alert` with `"critical"` or `"warning"` to render the card as an alert.
+Both severities share one translucent surface at the larger alert radius and padding; `critical` additionally draws a rainbow gradient border, and each severity carries its own accent colour.
+
+Pair it with `CardAlertHeader`, which renders the severity label and a right-aligned timestamp.
+The label picks up the accent colour from the parent card, so the severity is declared once on `Card`.
+
+{{"demo": "AlertCard.js", "bg": true}}
+
+Keep the label short and in the alert's own words — `CRITICAL ALERT`, `WARNING` — and put the actionable detail in the title.
+The `timestamp` slot is optional; omit it when the alert has no meaningful age.
+
+## Graph connector
+
+Pass `connector` for the graph-connector surface.
+It stacks the `Graph-Connector` fill and glow gradients over a backdrop blur and edges the card with the matching 1px gradient stroke, at a tighter 6px radius than the other treatments.
+
+{{"demo": "ConnectorCard.js", "bg": true}}
+
+Use it for the small cards that hang off a graph or flow diagram, where the surface should read as part of the canvas rather than as a raised panel.
+
+## Glass
+
+Pass `glass` for the frosted-glass surface.
+It fills the card with the `Gradient/Card-Glass-BG` token over a backdrop blur, adds a white hairline, and drops a soft shadow beneath it.
+
+{{"demo": "GlassCard.js", "bg": true}}
+
+The fill is translucent, so the card picks up whatever sits behind it.
+In the design it sits directly on the app surface, which is where it reads best.
+The backdrop blur only has a visible effect when there is imagery or a pattern behind the card; over a flat colour the translucent fill alone carries the treatment.
+
+## Image background
+
+Pass `image` to use a photo as the card surface.
+The photo is layered at half strength over the `Gradient/Welcome-Card-BG-Dark` fill, under two scrims that keep the copy legible over the picture: one runs top to bottom (`Gradient/Overlay-Black-Fade-In`), the other runs left to right and clears by the midpoint of the card, so the title and body copy land on flat colour rather than on the image.
+The card also switches to the larger radius and padding the design uses for these promotional surfaces, and its text primitives inherit the light-on-photo colour.
+
+{{"demo": "CardWithImage.js", "bg": true}}
+
+Use it for a single hero or welcome surface, not for cards in a list.
+Do not combine `image` with `glow`; the gradient ring is designed for the plain card surface.
+The gradients are only design-approved in the Midnight theme — the other themes fall back to a generic dark background gradient.
+
## Disabled appearance
-Cards do not have a dedicated `disabled` prop.
-When a card represents unavailable content, make that state explicit in surrounding logic and apply a muted style.
+Use the `disabled` prop when a card represents unavailable content.
+It applies the muted surface treatment and sets `aria-disabled` on the card so assistive technology reports the group as unavailable.
{{"demo": "DisabledCard.js", "bg": true}}
@@ -92,6 +137,20 @@ Card primitives support the underlying card props.
| `CardActionArea` | `CardActionAreaProps` | Makes the card surface interactive. |
| `CardDescription` | `TypographyProps` | Body text with Open UI Kit card description styling. |
| `CardSubheader` | `TypographyProps` | Compact supporting text with Open UI Kit card subheader styling. |
+| `CardAlertHeader` | `StackProps` | Severity label and timestamp row for alert cards. |
+
+`Card` adds three props on top of `CardProps`:
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `alert` | `"warning" \| "critical"` | — | Applies the alert treatment. `critical` adds the rainbow gradient border. |
+| `connector` | `boolean` | `false` | Applies the graph-connector treatment. |
+| `disabled` | `boolean` | `false` | Applies the disabled treatment and sets `aria-disabled`. |
+| `glass` | `boolean` | `false` | Applies the frosted-glass treatment. |
+| `glow` | `boolean` | `false` | Applies the gradient border and blue glow treatment. |
+| `image` | `string` | — | Background image URL. Applies the image treatment described above. |
+
+`alert`, `connector`, `glass`, `glow`, and `image` are decorative treatments for the same surface — use one at a time.
## Accessibility
diff --git a/docs/data/material/components/gauge-chart/gauge-chart.md b/docs/data/material/components/gauge-chart/gauge-chart.md
index d5167b8f..31fe1eb1 100644
--- a/docs/data/material/components/gauge-chart/gauge-chart.md
+++ b/docs/data/material/components/gauge-chart/gauge-chart.md
@@ -50,11 +50,30 @@ export function GaugeChartExample() {
}
```
+## Gradient variant
+
+Set `variant` on the chart to use one of the three design-approved gauge ramps.
+The ramp strokes a 270° value arc over an equal-weight track, a matching glow renders behind the value, and the value gains a muted `%` suffix.
+
+```tsx
+
+```
+
+| `variant` | Gradient token | Glow |
+| --- | --- | --- |
+| `amber` | `Gradient/Gauge-Arc-Amber` | `FBAF45` |
+| `teal` | `Gradient/Gauge-Arc-Teal` | `00B98D` |
+| `blue` | `Gradient/Icon-Subtract-Blue` | `187ADC` |
+
+The ramps are design-approved in the Midnight theme.
+The data item's `color` is unused while `variant` is set.
+
## Storybook scenarios
Storybook is the source of truth for interactive examples, controls, and visual state checks.
Start with the closest story, then adapt the props to match your product flow.
+- `Gradient` — the three gauge ramps in one frame, with the frame's labels and values.
- Dedicated Storybook coverage is still being expanded for this export.
## Behavior notes
diff --git a/docs/data/material/components/spider-chart/spider-chart.md b/docs/data/material/components/spider-chart/spider-chart.md
index 36b98110..03a129bf 100644
--- a/docs/data/material/components/spider-chart/spider-chart.md
+++ b/docs/data/material/components/spider-chart/spider-chart.md
@@ -50,11 +50,34 @@ export function SpiderChartExample() {
}
```
+## Gradient variant
+
+Set `gradient` on a radar series to use one of the four design-approved data-viz ramps.
+The ramp fills the radar area, its paired accent draws the outline, and every data vertex gets a ring in the same accent.
+
+```tsx
+
+```
+
+| `gradient` | Gradient token |
+| --- | --- |
+| `pinkPurple` | `Gradient/Data-Viz-Pink-Purple` |
+| `cyanBlue` | `Gradient/Data-Viz-Cyan-Blue` |
+| `orangeGold` | `Gradient/Data-Viz-Orange-Gold` |
+| `blueDark` | `Gradient/Data-Viz-Blue-Dark` |
+
+The ramps are design-approved in the Midnight theme; the other themes fall back to the closest gradient the library already ships.
+Use one ramp per series, and pass `background`, `stroke`, or `dot` on the series to override any part of the treatment.
+
## Storybook scenarios
Storybook is the source of truth for interactive examples, controls, and visual state checks.
Start with the closest story, then adapt the props to match your product flow.
+- `Gradient` — the four data-viz ramps, one agent per ramp.
- Dedicated Storybook coverage is still being expanded for this export.
## Behavior notes
diff --git a/docs/data/material/components/toast/toast.md b/docs/data/material/components/toast/toast.md
index d536fccf..98eb50aa 100644
--- a/docs/data/material/components/toast/toast.md
+++ b/docs/data/material/components/toast/toast.md
@@ -63,6 +63,20 @@ Start with the closest story, then adapt the props to match your product flow.
- Without Action
- All Types
- All Types Without Title
+- Glow / With header
+- Glow / Without header
+
+## Glow
+
+Pass `glow` for the "Toast message Glow" treatment: a `Global-Border/Fade` gradient border with a blue glow cast from behind the toast.
+
+It comes in two forms, picked automatically from whether a `title` is set:
+
+- **With header** — title above the message, carrying the stronger glow.
+- **Without header** — message only, with the softer glow.
+
+Use it for AI-generated insights and summaries, where the toast reports something the system noticed rather than the outcome of a user action.
+Keep the status `type` at its default; the gradient border replaces the status border, so pairing the two reads as two competing signals.
## Behavior notes
@@ -78,6 +92,7 @@ Use the exported TypeScript props for implementation details and keep local over
| Prop | Type | Description |
| --- | --- | --- |
| `Toast` props | Component-specific props | Controls the supported behavior, slots, state, and styling for Toast. |
+| `glow` | `boolean` | Applies the glow treatment. The glow is stronger when `title` is also set. |
| `children` | `React.ReactNode` | Content rendered inside the component when the component supports composition. |
| `className` | `string` | Adds a class to the root slot for product-level styling hooks. |
| `sx` | `SxProps` | Applies local style overrides while still using the active Open UI Kit theme. |
diff --git a/docs/data/material/components/typography/typography.md b/docs/data/material/components/typography/typography.md
index adafd2cc..5a20ae49 100644
--- a/docs/data/material/components/typography/typography.md
+++ b/docs/data/material/components/typography/typography.md
@@ -48,6 +48,23 @@ export function TypographyExample() {
}
```
+## Gradient text
+
+Set the `gradient` prop to fill the text with a gradient instead of a flat color.
+It is a boolean, so it composes with any `variant`.
+
+```tsx
+import { Typography } from '@open-ui-kit/core';
+
+export function GradientTextExample() {
+ return (
+
+ Welcome Amy!
+
+ );
+}
+```
+
## Storybook scenarios
Storybook is the source of truth for interactive examples, controls, and visual state checks.
@@ -69,6 +86,7 @@ Use the exported TypeScript props for implementation details and keep local over
| Prop | Type | Description |
| --- | --- | --- |
| `Typography` props | Component-specific props | Controls the supported behavior, slots, state, and styling for Typography. |
+| `gradient` | `boolean` | Fills the text with a gradient (via `background-clip: text`) instead of a flat color. Composes with any `variant`. |
| `children` | `React.ReactNode` | Content rendered inside the component when the component supports composition. |
| `className` | `string` | Adds a class to the root slot for product-level styling hooks. |
| `sx` | `SxProps` | Applies local style overrides while still using the active Open UI Kit theme. |
diff --git a/docs/public/static/images/cards/welcome-card.jpg b/docs/public/static/images/cards/welcome-card.jpg
new file mode 100644
index 00000000..0e81de32
Binary files /dev/null and b/docs/public/static/images/cards/welcome-card.jpg differ
diff --git a/docs/src/open-ui-kit-core-docs.ts b/docs/src/open-ui-kit-core-docs.ts
index 555f02a5..c7b7801f 100644
--- a/docs/src/open-ui-kit-core-docs.ts
+++ b/docs/src/open-ui-kit-core-docs.ts
@@ -10,6 +10,7 @@ import { useTheme as useMuiTheme } from "@mui/material/styles";
import { CssBaseline, ThemeProvider as MuiThemeProvider } from "@mui/material";
import {
iocDocsTheme,
+ midnightDocsTheme,
OpenUiKitTokenCssVars,
openUiKitDarkDocsTheme,
openUiKitLightDocsTheme,
@@ -124,6 +125,7 @@ export type { CardDescriptionProps } from "../../packages/open-ui-kit/src/compon
export { default as CardSubheader } from "../../packages/open-ui-kit/src/components/card/components/card-subheader";
export type { CardSubheaderProps } from "../../packages/open-ui-kit/src/components/card/components/card-subheader";
export {
+ ThemeMode,
useTheme,
useThemeMode,
} from "../../packages/open-ui-kit/src/theme-provider/theme-provider";
@@ -279,6 +281,24 @@ function useDocsMode(rootRef: React.RefObject) {
return docsMode;
}
+/**
+ * Docs equivalent of `resolveBuiltInTheme` in the package's ThemeProvider. Kept
+ * in the same shape so a new ThemeMode is obviously missing from one of them.
+ */
+function resolveDocsTheme(mode: ThemeMode) {
+ if (mode === ThemeMode.IoC) {
+ return iocDocsTheme;
+ }
+
+ if (mode === ThemeMode.Midnight) {
+ return midnightDocsTheme;
+ }
+
+ return mode === ThemeMode.Dark
+ ? openUiKitDarkDocsTheme
+ : openUiKitLightDocsTheme;
+}
+
export function ThemeProvider({
defaultMode,
customTheme,
@@ -287,13 +307,7 @@ export function ThemeProvider({
const rootRef = React.useRef(null);
const docsMode = useDocsMode(rootRef);
const mode = defaultMode ?? docsMode;
- const theme =
- customTheme ??
- (mode === ThemeMode.IoC
- ? iocDocsTheme
- : mode === ThemeMode.Dark
- ? openUiKitDarkDocsTheme
- : openUiKitLightDocsTheme);
+ const theme = customTheme ?? resolveDocsTheme(mode);
return React.createElement(
"div",
diff --git a/docs/src/openUiKitDocsTheme.ts b/docs/src/openUiKitDocsTheme.ts
index f196956d..151c95b5 100644
--- a/docs/src/openUiKitDocsTheme.ts
+++ b/docs/src/openUiKitDocsTheme.ts
@@ -4,12 +4,14 @@ import * as React from "react";
import { darkTheme } from "../../packages/open-ui-kit/src/theme/dark/dark-theme";
import { iocTheme } from "../../packages/open-ui-kit/src/theme/ioc/ioc-theme";
import { lightTheme } from "../../packages/open-ui-kit/src/theme/light/light-theme";
+import { midnightTheme } from "../../packages/open-ui-kit/src/theme/midnight/midnight-theme";
type OpenUiKitTokenVars = Record;
const lightTokenVars = lightTheme.palette.vars as OpenUiKitTokenVars;
const darkTokenVars = darkTheme.palette.vars as OpenUiKitTokenVars;
const iocTokenVars = iocTheme.palette.vars as OpenUiKitTokenVars;
+const midnightTokenVars = midnightTheme.palette.vars as OpenUiKitTokenVars;
function toCssVarName(token: string) {
return `--oui-${token}`;
@@ -40,6 +42,7 @@ export const openUiKitTokenCssVarStyles = {
"[data-mui-color-scheme='dark'], .mode-dark":
toCssVarDeclarations(darkTokenVars),
".mode-ioc": toCssVarDeclarations(iocTokenVars),
+ ".mode-midnight": toCssVarDeclarations(midnightTokenVars),
};
function createOpenUiKitDocsTheme(baseTheme: Theme) {
@@ -53,6 +56,7 @@ function createOpenUiKitDocsTheme(baseTheme: Theme) {
export const openUiKitLightDocsTheme = createOpenUiKitDocsTheme(lightTheme);
export const openUiKitDarkDocsTheme = createOpenUiKitDocsTheme(darkTheme);
export const iocDocsTheme = createOpenUiKitDocsTheme(iocTheme);
+export const midnightDocsTheme = createOpenUiKitDocsTheme(midnightTheme);
export function OpenUiKitTokenCssVars() {
return React.createElement(GlobalStyles, {
diff --git a/packages/open-ui-kit/.storybook/assets/welcome-card.jpg b/packages/open-ui-kit/.storybook/assets/welcome-card.jpg
new file mode 100644
index 00000000..0e81de32
Binary files /dev/null and b/packages/open-ui-kit/.storybook/assets/welcome-card.jpg differ
diff --git a/packages/open-ui-kit/jest.verify.tmp.js b/packages/open-ui-kit/jest.verify.tmp.js
new file mode 100644
index 00000000..0c66f63f
--- /dev/null
+++ b/packages/open-ui-kit/jest.verify.tmp.js
@@ -0,0 +1,3 @@
+const base = require("./jest.config.js");
+module.exports = { ...base, rootDir: __dirname, collectCoverage: false,
+ transform: { "^.+\\.(ts|tsx|js|jsx)$": ["ts-jest", { tsconfig: "tsconfig.json", diagnostics: false }] } };
diff --git a/packages/open-ui-kit/src/charts/gauge-chart/__tests__/gauge-chart.test.tsx b/packages/open-ui-kit/src/charts/gauge-chart/__tests__/gauge-chart.test.tsx
index 96f79de5..e9fb5797 100644
--- a/packages/open-ui-kit/src/charts/gauge-chart/__tests__/gauge-chart.test.tsx
+++ b/packages/open-ui-kit/src/charts/gauge-chart/__tests__/gauge-chart.test.tsx
@@ -9,9 +9,22 @@ import "@testing-library/jest-dom";
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import { darkTheme } from "@/theme/dark/dark-theme";
import { lightTheme } from "@/theme/light/light-theme";
+import {
+ blue300,
+ blue500,
+ green500,
+ greyAlpha40,
+ lightOrange500,
+ midnightGradientStops,
+} from "@/theme/style/color-palette";
import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider";
import { GaugeChart } from "../gauge-chart";
-import { barShadow, gaugeWrapper } from "../styles";
+import {
+ barShadow,
+ gaugeWrapper,
+ getGaugeChartGradient,
+ type GaugeChartGradient,
+} from "../styles";
import type { ChartDataItem } from "../../common/types";
jest.mock("recharts", () => ({
@@ -49,6 +62,7 @@ jest.mock("recharts", () => ({
),
Pie: ({
+ activeShape,
children,
data,
dataKey,
@@ -58,7 +72,8 @@ jest.mock("recharts", () => ({
startAngle,
strokeWidth,
}: {
- children: ReactNode;
+ activeShape?: (props: Record) => ReactNode;
+ children?: ReactNode;
data: Array<{ fill: string; value: number }>;
dataKey: string;
endAngle: number;
@@ -68,18 +83,30 @@ jest.mock("recharts", () => ({
strokeWidth: number;
}) => (
+ {/* Recharts resolves the sector's geometry against the chart box before
+ handing it to `activeShape`; the mock stands in for that, centring
+ the 132px gauge the way `cx`/`cy` of `50%` would. */}
+ {activeShape?.({
+ cx: 66,
+ cy: 66,
+ endAngle,
+ fill: data[0]?.fill,
+ innerRadius,
+ outerRadius,
+ startAngle,
+ })}
{children}
),
@@ -221,6 +248,61 @@ describe("GaugeChart", () => {
});
});
+ it.each([
+ [100, "successBackgroundDefault"],
+ [76, "successBackgroundDefault"],
+ [75, "warningBackgroundDefault"],
+ [51, "warningBackgroundDefault"],
+ [50, "severeWarningBorderDefault"],
+ [26, "severeWarningBorderDefault"],
+ [25, "negativeBackgroundDefault"],
+ [0, "negativeBackgroundDefault"],
+ ] as const)(
+ "colors the arc from the value when no color is given (%i%% -> %s)",
+ (value, token) => {
+ renderGauge(false, { data: [{ name: "Score", value }] });
+
+ expect(screen.getByTestId("pie")).toMatchObject({
+ dataset: expect.objectContaining({
+ mainFill: lightTheme.palette.vars[token],
+ }),
+ });
+ },
+ );
+
+ it("bands the status ramp against maxValue rather than the raw value", () => {
+ // 30/40 is 75%, the warning band; the raw 30 would band to severe warning.
+ renderGauge(false, {
+ maxValue: 40,
+ data: [{ name: "Score", value: 30 }],
+ });
+
+ expect(screen.getByTestId("pie")).toMatchObject({
+ dataset: expect.objectContaining({
+ mainFill: lightTheme.palette.vars.warningBackgroundDefault,
+ }),
+ });
+ });
+
+ it("keeps an explicit data color ahead of the status ramp", () => {
+ // 25% would band to negative if the item did not name a color.
+ renderGauge(false, {
+ data: [
+ {
+ name: "Score",
+ value: 25,
+ color: lightTheme.palette.vars.accentADefault,
+ },
+ ],
+ });
+
+ expect(screen.getByTestId("pie")).toMatchObject({
+ dataset: expect.objectContaining({
+ mainFill: lightTheme.palette.vars.accentADefault,
+ }),
+ });
+ });
+
it("applies token-aware shadow and custom sizing", () => {
renderGauge(false, {
styleProps: {
@@ -255,4 +337,155 @@ describe("GaugeChart", () => {
expect(screen.getByText("Good")).toBeInTheDocument();
});
+
+ // Figma: `Gauge Chart` (274417:44466), one widget per `gradient-token`
+ // swatch, with the paired `Solid` swatch as the glow.
+ describe("gradient treatment", () => {
+ const variants: [GaugeChartGradient, string, string, number, string][] = [
+ [
+ "amber",
+ midnightGradientStops.gaugeArcAmber,
+ midnightGradientStops.gaugeArcAmber,
+ 0.78,
+ lightOrange500,
+ ],
+ [
+ "teal",
+ midnightGradientStops.gaugeArcTealStart,
+ midnightGradientStops.gaugeArcTealEnd,
+ 1,
+ green500,
+ ],
+ ["blue", midnightGradientStops.iconSubtractBlue, blue500, 1, blue300],
+ ];
+
+ it.each(variants)(
+ "resolves the %s label to its arc stops and glow",
+ (gradient, from, to, toOpacity, glow) => {
+ expect(getGaugeChartGradient(gradient)).toEqual({
+ from,
+ to,
+ toOpacity,
+ glow,
+ });
+ },
+ );
+
+ it("rings the ramped arc over an equal-weight track with a glow behind", () => {
+ const { container } = renderGauge(false, { variant: "teal" });
+
+ // The gradient treatment drops the dividers, but stays on the same
+ // PieChart wrapper and `Pie`s the default gauge uses.
+ expect(container.querySelectorAll("line")).toHaveLength(0);
+ expect(screen.getByTestId("pie-chart")).toBeInTheDocument();
+
+ const [track, arc] = screen.getAllByTestId("pie");
+
+ // The frame's 5.275px arc stroke, normalized from its 171.7px gauge to
+ // the default 132px one, is shared by the track — one line weight
+ // through the junction — and both rings are centred on a radius inset
+ // so the stroke's outer edge clears the svg viewport by a pixel instead
+ // of being shaved flat against it.
+ const strokeThickness = (132 * 5.275) / 171.704;
+ const arcRadius = (132 - strokeThickness) / 2 - 1;
+
+ expect(track.dataset.mainFill).toBe(greyAlpha40);
+ expect(Number(track.dataset.innerRadius)).toBeCloseTo(
+ arcRadius - strokeThickness / 2,
+ 2,
+ );
+ expect(Number(track.dataset.outerRadius)).toBeCloseTo(
+ arcRadius + strokeThickness / 2,
+ 2,
+ );
+ // The outer edge lands a pixel inside the 132px box on every side.
+ expect(Number(track.dataset.outerRadius)).toBeCloseTo(132 / 2 - 1, 2);
+ // The full 270° sweep: 225° clockwise to -45°.
+ expect(Number(track.dataset.startAngle)).toBe(225);
+ expect(Number(track.dataset.endAngle)).toBe(-45);
+
+ const gradientDef = container.querySelector("linearGradient");
+ expect(arc.dataset.mainFill).toBe(`url(#${gradientDef?.id})`);
+ expect(Number(arc.dataset.innerRadius)).toBeCloseTo(
+ arcRadius - strokeThickness / 2,
+ 2,
+ );
+ expect(Number(arc.dataset.outerRadius)).toBeCloseTo(
+ arcRadius + strokeThickness / 2,
+ 2,
+ );
+ // 75 of 100 fills three quarters of the 270° sweep.
+ expect(Number(arc.dataset.startAngle)).toBe(225);
+ expect(Number(arc.dataset.endAngle)).toBeCloseTo(225 - 0.75 * 270, 2);
+
+ // Each `Pie` paints its sector as a stroke down the middle of the ring,
+ // so both ends carry a round cap and the thickness cannot drift.
+ const point = (angle: number) => {
+ const radian = (angle * Math.PI) / 180;
+ return `${66 + arcRadius * Math.cos(radian)},${
+ 66 - arcRadius * Math.sin(radian)
+ }`;
+ };
+ // Swept clockwise (`sweep-flag` 1) from 225°, taking the large arc past
+ // a half turn.
+ const expectedPath = (sweep: number) =>
+ `M${point(225)}A${arcRadius},${arcRadius},0,${
+ sweep > 180 ? 1 : 0
+ },1,${point(225 - sweep)}`;
+
+ const trackPath = track.querySelector("path") as SVGPathElement;
+ const arcPath = arc.querySelector("path") as SVGPathElement;
+
+ expect(trackPath).toHaveAttribute("d", expectedPath(270));
+ expect(trackPath).toHaveAttribute("stroke", greyAlpha40);
+ expect(trackPath).toHaveAttribute("stroke-linecap", "round");
+ expect(Number(trackPath.getAttribute("stroke-width"))).toBeCloseTo(
+ strokeThickness,
+ 2,
+ );
+
+ expect(arcPath).toHaveAttribute("d", expectedPath(0.75 * 270));
+ expect(arcPath).toHaveAttribute("stroke", `url(#${gradientDef?.id})`);
+ expect(arcPath).toHaveAttribute("stroke-linecap", "round");
+ expect(Number(arcPath.getAttribute("stroke-width"))).toBeCloseTo(
+ strokeThickness,
+ 2,
+ );
+
+ const stopElements = gradientDef?.querySelectorAll("stop");
+ expect(stopElements?.[0]).toHaveAttribute(
+ "stop-color",
+ midnightGradientStops.gaugeArcTealStart,
+ );
+ expect(stopElements?.[1]).toHaveAttribute(
+ "stop-color",
+ midnightGradientStops.gaugeArcTealEnd,
+ );
+
+ const glow = container.querySelector("svg")
+ ?.previousElementSibling as HTMLElement;
+ expect(glow).toHaveStyle({ background: green500 });
+ expect(glow.style.filter).toContain("blur");
+ });
+
+ it("renders the value with a muted % suffix", () => {
+ renderGauge(false, { variant: "amber" });
+
+ expect(screen.getByText("75")).toBeInTheDocument();
+ expect(screen.getByText("%")).toBeInTheDocument();
+ });
+
+ it("drops the value arc at zero without dropping the track", () => {
+ const { container } = renderGauge(false, {
+ variant: "blue",
+ data: [{ name: "Score", value: 0, color: "#000" }],
+ });
+
+ // A zero sweep would otherwise leave a lone round cap sitting at 225°.
+ const pies = screen.getAllByTestId("pie");
+ expect(pies).toHaveLength(1);
+ expect(pies[0].dataset.mainFill).toBe(greyAlpha40);
+ expect(container.querySelectorAll("path")).toHaveLength(1);
+ });
+ });
});
diff --git a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx
index 6674fb0e..3b3f5c16 100644
--- a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx
+++ b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx
@@ -45,6 +45,12 @@ const meta: Meta = {
control: false,
description: "Optional width, height, and label-position overrides.",
},
+ variant: {
+ control: "select",
+ options: ["amber", "teal", "blue"],
+ description:
+ "Applies the design-approved gradient treatment: ramped 270° arc, ambient glow, and a % suffix.",
+ },
},
};
@@ -112,38 +118,26 @@ const CriticalTemplate = (args: Partial) => {
);
};
-const StatesTemplate = () => {
- const theme = useTheme();
-
- return (
-
- {[100, 75, 50, 25].map((value, index) => {
- const color =
- index === 0
- ? theme.palette.vars.successBackgroundDefault
- : index === 1
- ? theme.palette.vars.warningBackgroundDefault
- : theme.palette.vars.negativeBackgroundDefault;
-
- return (
-
-
-
- );
- })}
-
- );
-};
+/*
+ * Each gauge omits its data item's `color`, so the arc comes from the
+ * component's own status ramp: success at 100, warning at 75, severe warning
+ * at 50, negative at 25.
+ */
+const StatesTemplate = () => (
+
+ {[100, 75, 50, 25].map((value) => (
+
+
+
+ ))}
+
+);
const WithLabelTemplate = () => {
const theme = useTheme();
@@ -182,6 +176,59 @@ const CustomMaxTemplate = () => {
);
};
+/*
+ * Gradient treatment — Figma `Gauge Chart` (274417:44466).
+ *
+ * One entry per widget in the frame, with the frame's labels and values:
+ * each pairs a `gradient-token` arc swatch with the `Solid` glow swatch
+ * behind the value.
+ */
+const gradientVariants: {
+ label: string;
+ variant: NonNullable;
+ value: number;
+}[] = [
+ { label: "Trip Planner", variant: "amber", value: 50 },
+ { label: "Aether", variant: "teal", value: 82 },
+ { label: "E-Commerce App", variant: "blue", value: 67 },
+];
+
+const GradientTemplate = ({
+ label,
+ variant,
+ value,
+}: {
+ label: string;
+ variant: GaugeChartProps["variant"];
+ value: number;
+}) => {
+ const theme = useTheme();
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+ Overall Performance
+
+
+
+ );
+};
+
export const Default: Story = {
render: (args) => ,
args: {
@@ -189,6 +236,22 @@ export const Default: Story = {
},
};
+/**
+ * The three design-approved gauge ramps, one widget per `variant`: amber
+ * (`Gradient/Gauge-Arc-Amber`), teal (`Gradient/Gauge-Arc-Teal`), and blue
+ * (`Gradient/Icon-Subtract-Blue`), each glowing its paired solid behind the
+ * value.
+ */
+export const Gradient: Story = {
+ render: () => (
+
+ {gradientVariants.map((variant) => (
+
+ ))}
+
+ ),
+};
+
export const Warning: Story = {
render: (args) => ,
args: {
diff --git a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx
index 4db1070c..710acac1 100644
--- a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx
+++ b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx
@@ -6,11 +6,25 @@
import styled from "@emotion/styled";
import { Box, Typography, useTheme } from "@mui/material";
-import type { ReactNode } from "react";
+import { useId, type ReactNode } from "react";
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
-import { barShadow, boxStyle, gaugeLabel, gaugeWrapper } from "./styles";
+import {
+ barShadow,
+ boxStyle,
+ GAUGE_GRADIENT_TRACK_COLOR,
+ gaugeGlow,
+ gaugeGradientSuffix,
+ gaugeGradientValue,
+ gaugeLabel,
+ gaugeWrapper,
+ getGaugeChartGradient,
+ getGaugeStatusColor,
+ type GaugeChartGradient,
+} from "./styles";
import { ChartDataItem, ChartProps } from "../common/types";
+export type { GaugeChartGradient } from "./styles";
+
// Dividers Configuration
const NUM_DIVIDERS = 51;
const SMALL_DIVIDER_LENGTH = 2.2;
@@ -21,15 +35,110 @@ const DIVIDER_MARGIN_FROM_CHART = 10;
const START_ANGLE = 240;
const END_ANGLE = -60;
+/*
+ * Gradient treatment — Figma `Gauge Chart` (274417:44466).
+ *
+ * The frame's gauge is a 270° ring from the bottom-left (225°) clockwise to
+ * the bottom-right (-45°): a translucent track under a round-capped arc
+ * carrying the ramp. Both rings stay `Pie`s, so the treatment shares its
+ * rendering path with the default gauge, but each paints its sector through
+ * `renderGaugeArc` — see there for why a filled sector cannot carry the
+ * frame's round cap.
+ *
+ * The track deliberately shares the value arc's 5.275px stroke (the frame
+ * draws a 2.433px hairline instead) so the ring keeps one line weight through
+ * the junction, matching the default gauge's even ring. The stroke width is
+ * normalized to the frame's 171.7px gauge so it stays exact at any size.
+ *
+ * The ring's radius is derived from the box rather than taken from the frame:
+ * the frame's 84.18px radius plus half the stroke overflows its own 171.7px
+ * bounds (86.8 > 85.85), and an svg clips at its viewport — shaving the
+ * stroke flat wherever the ring meets the box edge (90°, 180°, 0°). Insetting
+ * the radius by half the stroke plus a pixel of padding keeps the full stroke
+ * inside the box all the way around.
+ */
+const GRADIENT_START_ANGLE = 225;
+const GRADIENT_SWEEP_ANGLE = 270;
+const GRADIENT_STROKE_RATIO = 5.275 / 171.704;
+const GRADIENT_EDGE_PADDING = 1;
+
+/** The sector geometry recharts hands a `Pie`'s shape renderer. */
+interface GaugeArcShapeProps {
+ cx: number;
+ cy: number;
+ innerRadius: number;
+ outerRadius: number;
+ startAngle: number;
+ endAngle: number;
+ fill: string;
+}
+
+/**
+ * Draws a `Pie`'s sector as a stroke down the middle of the ring. The sector
+ * still owns the geometry; only the way it is painted changes, because a
+ * filled sector cannot carry a round cap — recharts approximates one from
+ * `cornerRadius` by trimming the outer edge by `asin(cr / mid)` and the inner
+ * edge by `asin(cr / (inner - cr))`, two different angles joined by a straight
+ * line, which leaves the flat, slightly skewed end the frame does not have.
+ * A stroke carries the thickness and both round caps exactly, and neither can
+ * drift between the ring's edges along the arc.
+ */
+const renderGaugeArc = (props: unknown) => {
+ // Recharts types a shape renderer's props as `unknown`; what it passes is
+ // the resolved sector.
+ const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } =
+ props as GaugeArcShapeProps;
+
+ const radius = (innerRadius + outerRadius) / 2;
+ const point = (angle: number) => {
+ const radian = (angle * Math.PI) / 180;
+ return `${cx + radius * Math.cos(radian)},${cy - radius * Math.sin(radian)}`;
+ };
+ // The gauge fills from its start angle downwards, which is clockwise on
+ // screen — SVG's `sweep-flag` of 1.
+ const largeArc = startAngle - endAngle > 180 ? 1 : 0;
+
+ return (
+
+ );
+};
+
const StyledResponsiveContainer = styled(ResponsiveContainer)`
display: flex;
justify-content: center;
align-items: center;
`;
-export interface GaugeChartProps extends ChartProps {
+/**
+ * A gauge's single metric. Unlike the shared `ChartDataItem`, `color` is
+ * optional — omit it and the default gauge derives the arc color from the
+ * value through `getGaugeStatusColor`.
+ */
+export interface GaugeChartDataItem extends Omit {
+ color?: string;
+}
+
+export interface GaugeChartProps extends Omit {
+ /**
+ * Single metric to plot. Widened rather than narrowed to
+ * `GaugeChartDataItem[]` because `ExtendedChartProps` intersects these props
+ * with the other charts', where the shared union still has to be accepted.
+ */
+ data: ChartProps["data"] | GaugeChartDataItem[];
/** Highest target value used to calculate how much of the gauge arc is filled. */
maxValue?: number;
+ /**
+ * Applies the gradient treatment: a 270° ring with the named ramp filling
+ * the value arc, an ambient glow behind the value, and a muted `%` suffix.
+ * Replaces the default arc, dividers, and the data item's `color`.
+ */
+ variant?: GaugeChartGradient;
/** Optional content shown below the numeric value inside the gauge. */
customLabelComponent?: ReactNode;
/** Optional dimensional overrides for compact or expanded gauge layouts. */
@@ -46,18 +155,27 @@ export interface GaugeChartProps extends ChartProps {
export const GaugeChart = ({
data,
maxValue = 100,
+ variant, // prop for gradiant gauge chart variant eg: amber, teal, blue
customLabelComponent,
styleProps,
}: GaugeChartProps) => {
const theme = useTheme();
+ // `useId` wraps its value in colons, which are not valid in a `url(#...)`
+ // reference.
+ const gradientId = `gauge-gradient-${useId().replace(/:/g, "")}`;
- const [valueItem] = data as ChartDataItem[];
+ const [valueItem] = data as GaugeChartDataItem[];
const clampedValue = Math.min(valueItem.value, maxValue);
+ // Scoped to the PieChart below: the gradient treatment paints its arc from
+ // the variant's ramp and never reads the data item's color.
+ const arcColor =
+ valueItem.color ??
+ getGaugeStatusColor(theme, (clampedValue / maxValue) * 100);
const gaugeData = [
// Main Bar
{
value: (clampedValue / maxValue) * 100,
- fill: valueItem.color,
+ fill: arcColor,
},
// Background Bar
{
@@ -114,6 +232,81 @@ export const GaugeChart = ({
>
);
+ if (variant) {
+ const config = getGaugeChartGradient(variant);
+ const strokeThickness = width * GRADIENT_STROKE_RATIO;
+ const arcRadius =
+ (Math.min(width, height) - strokeThickness) / 2 - GRADIENT_EDGE_PADDING;
+ const valueSweep = (clampedValue / maxValue) * GRADIENT_SWEEP_ANGLE;
+
+ return (
+
+
+
+
+
+ {/* Figma runs the ramp horizontally across the value arc's own
+ bounding box, which is what objectBoundingBox units give the
+ stroked arc. */}
+
+
+
+
+
+
+ {valueSweep > 0 && (
+
+ )}
+
+
+ {Math.round(valueItem.value)}
+ %
+
+
+ {customLabelComponent && customLabelComponent}
+
+
+
+ );
+ }
+
return (
diff --git a/packages/open-ui-kit/src/charts/gauge-chart/styles.ts b/packages/open-ui-kit/src/charts/gauge-chart/styles.ts
index e9cf38dd..efabac4b 100644
--- a/packages/open-ui-kit/src/charts/gauge-chart/styles.ts
+++ b/packages/open-ui-kit/src/charts/gauge-chart/styles.ts
@@ -6,6 +6,15 @@
import type { CSSProperties } from "react";
import type { Theme } from "@mui/material/styles";
+import {
+ blue300,
+ blue500,
+ green500,
+ grey200,
+ greyAlpha40,
+ lightOrange500,
+ midnightGradientStops as stops,
+} from "@/theme/style/color-palette";
export const gaugeWrapper = ({
height,
@@ -35,3 +44,117 @@ export const boxStyle = {
left: "50%",
transform: "translateX(-50%)",
};
+
+/**
+ * Arc color for the default gauge when the data item does not name one — the
+ * status ramp the `States` widget paints across its 100 / 75 / 50 / 25 gauges.
+ *
+ * Each band runs from its threshold up to the next, so the ramp holds for any
+ * `value` / `maxValue` pair rather than only those four readings. The bounds
+ * sit one point above each widget reading so that 75, 50, and 25 land in the
+ * band below — the widget's own colors — rather than topping out their band:
+ *
+ * | Filled | Token | Widget reading |
+ * | ------- | ------------------------------- | -------------- |
+ * | `>= 76` | `Success/Background/Default` | 100 |
+ * | `>= 51` | `Warning/Background/Default` | 75 |
+ * | `>= 26` | `Severe-Warning/Border/Default` | 50 |
+ * | `< 26` | `Negative/Background/Default` | 25 |
+ *
+ * The gradient treatment replaces the arc with its own ramp, so this applies
+ * to the default gauge only.
+ */
+export const getGaugeStatusColor = (theme: Theme, filledPercent: number) => {
+ const { vars } = theme.palette;
+
+ if (filledPercent >= 76) return vars.successBackgroundDefault;
+ if (filledPercent >= 51) return vars.warningBackgroundDefault;
+ if (filledPercent >= 26) return vars.severeWarningBorderDefault;
+
+ return vars.negativeBackgroundDefault;
+};
+
+/**
+ * Design-approved gauge ramps for the gradient treatment.
+ *
+ * Each key is the `gradient-token` swatch label on the matching widget in
+ * Figma `Gauge Chart` (274417:44466), and the paired `Solid` swatch is the
+ * ambient glow behind the value:
+ *
+ * | Key | Figma token | Arc stops | Glow |
+ * | ------- | ---------------------------- | ---------------------------------- | ---------------- |
+ * | `amber` | `Gradient/Gauge-Arc-Amber` | FFAE4C 100% → FFAE4C 78% | `lightOrange500` |
+ * | `teal` | `Gradient/Gauge-Arc-Teal` | 29FCC4 → 00AF2F | `green500` |
+ * | `blue` | `Gradient/Icon-Subtract-Blue`| 5096FF → `blue500` | `blue300` |
+ */
+export type GaugeChartGradient = "amber" | "teal" | "blue";
+
+/** Track ring under the gradient arc: the frame's `3C4551` at 40%. */
+export const GAUGE_GRADIENT_TRACK_COLOR = greyAlpha40;
+
+/**
+ * Arc stops and glow for a gauge ramp. The stops are raw palette values
+ * rather than the `gradientGaugeArc*` theme vars because the arc is an SVG
+ * stroke — a CSS gradient string cannot feed `` stops — and
+ * the ramp itself is Midnight-only, so design has not diverged it per theme.
+ */
+export const getGaugeChartGradient = (
+ gradient: GaugeChartGradient,
+): { from: string; to: string; toOpacity: number; glow: string } => {
+ switch (gradient) {
+ case "teal":
+ return {
+ from: stops.gaugeArcTealStart,
+ to: stops.gaugeArcTealEnd,
+ toOpacity: 1,
+ glow: green500,
+ };
+ case "blue":
+ return {
+ from: stops.iconSubtractBlue,
+ to: blue500,
+ toOpacity: 1,
+ glow: blue300,
+ };
+ case "amber":
+ default:
+ return {
+ from: stops.gaugeArcAmber,
+ to: stops.gaugeArcAmber,
+ toOpacity: 0.78,
+ glow: lightOrange500,
+ };
+ }
+};
+
+/**
+ * Ambient glow behind the gauge value — Figma `Ellipse 1940`: an 82 × 57.5
+ * ellipse with a 65px gaussian blur, centered ~44px below the ring center at
+ * the frame's 171.7px gauge. All lengths scale with the gauge width.
+ */
+export const gaugeGlow = (color: string, width: number): CSSProperties => ({
+ position: "absolute",
+ left: "50%",
+ top: "50%",
+ width: `${width * (164 / 171.704)}px`,
+ height: `${width * (115 / 171.704)}px`,
+ transform: `translate(-50%, calc(-50% + ${width * (44 / 171.704)}px))`,
+ borderRadius: "50%",
+ background: color,
+ filter: `blur(${width * (65.019 / 171.704)}px)`,
+ pointerEvents: "none",
+});
+
+/** Gauge value in the gradient treatment: 62.4px Inter Medium at 171.7px. */
+export const gaugeGradientValue = (width: number): CSSProperties => ({
+ fontSize: `${width * (62.418 / 171.704)}px`,
+ fontWeight: 500,
+ lineHeight: 1.1,
+});
+
+/** The `%` suffix: 41.6px at 171.7px, in the frame's muted `grey200`. */
+export const gaugeGradientSuffix = (width: number): CSSProperties => ({
+ fontSize: `${width * (41.612 / 171.704)}px`,
+ fontWeight: 500,
+ color: grey200,
+});
diff --git a/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx b/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx
index 86a226a9..3408a20e 100644
--- a/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx
+++ b/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx
@@ -14,9 +14,28 @@ import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import { darkTheme } from "@/theme/dark/dark-theme";
import { lightTheme } from "@/theme/light/light-theme";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
+import {
+ blue500,
+ blueAlpha40,
+ lightAlphaOrange40,
+ midnightGradientStops,
+ night700,
+ purpleAlpha40,
+} from "@/theme/style/color-palette";
import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider";
+import CustomGradientRadar from "../components/custom-gradient-radar";
import { SpiderChart } from "../components/spider-chart";
-import type { ExtendedDataPoint, RadarType } from "../types/spider-chart.types";
+import {
+ getSpiderChartGradient,
+ SPIDER_GRADIENT_DOT_RADIUS,
+ SPIDER_GRADIENT_STROKE_WIDTH,
+} from "../styles/spider-chart.styles";
+import type {
+ ExtendedDataPoint,
+ RadarType,
+ SpiderChartGradient,
+} from "../types/spider-chart.types";
jest.mock("recharts", () => ({
ResponsiveContainer: ({
@@ -68,12 +87,16 @@ jest.mock("recharts", () => ({
dataKey,
fill,
name,
+ shape,
+ stroke,
strokeWidth,
}: {
color: string;
dataKey: string;
fill: string;
name: string;
+ shape: unknown;
+ stroke?: string;
strokeWidth: number;
}) => (
({
data-data-key={dataKey}
data-fill={fill}
data-name={name}
+ // Recharts clones the shape element with the Radar props, so the dot
+ // geometry is asserted where it is authored: on the element.
+ data-dot-radius={
+ isValidElement(shape)
+ ? String((shape.props as { dotRadius?: number }).dotRadius ?? "")
+ : ""
+ }
+ data-dot-fill={
+ isValidElement(shape)
+ ? ((shape.props as { dotFill?: string }).dotFill ?? "")
+ : ""
+ }
+ data-stroke={stroke ?? ""}
data-stroke-width={strokeWidth}
data-testid="radar-series"
/>
@@ -234,6 +270,241 @@ describe("SpiderChart", () => {
});
});
+ // Figma: `Spider Chart` (274417:44533), one widget per `gradient-token`
+ // swatch. Stroke and dot colors are the widget's own data polygon and
+ // vertex rings.
+ describe("gradient treatment", () => {
+ const variants: [SpiderChartGradient, string, string, string, string][] = [
+ [
+ "pinkPurple",
+ midnightTheme.palette.gradients.gradientDataVizPinkPurple,
+ midnightTheme.palette.vars.infoBorderDefault,
+ purpleAlpha40,
+ midnightTheme.palette.vars.infoBorderDefault,
+ ],
+ [
+ "cyanBlue",
+ midnightTheme.palette.gradients.gradientDataVizCyanBlue,
+ midnightTheme.palette.vars.accentHDefault,
+ blueAlpha40,
+ midnightTheme.palette.vars.accentHDefault,
+ ],
+ [
+ "orangeGold",
+ midnightTheme.palette.gradients.gradientDataVizOrangeGold,
+ midnightTheme.palette.vars.warningBorderDefault,
+ lightAlphaOrange40,
+ midnightTheme.palette.vars.warningBorderDefault,
+ ],
+ [
+ "blueDark",
+ midnightTheme.palette.gradients.gradientDataVizBlueDark,
+ midnightTheme.palette.vars.interactivePrimaryDefaultActive,
+ "rgba(185, 171, 239, 0.76)",
+ // The one variant whose dot rings diverge from the outline color: the
+ // frame rings them a step lighter (558BFF against the outline's
+ // 1469CC in Midnight).
+ midnightTheme.palette.vars.interactivePrimaryDefaultDefault,
+ ],
+ ];
+
+ it.each(variants)(
+ "resolves the %s label to its theme gradient and accents",
+ (gradient, background, stroke, dotFill, dotStroke) => {
+ expect(getSpiderChartGradient(midnightTheme, gradient)).toEqual({
+ background,
+ stroke,
+ dotFill,
+ dotStroke,
+ });
+ },
+ );
+
+ it("fills, outlines and dots the radar from the named ramp", () => {
+ renderSpiderChart(false, {
+ radars: [
+ {
+ name: "Concierge Agent",
+ dataKey: "variableA",
+ gradient: "cyanBlue",
+ },
+ ],
+ });
+
+ expect(screen.getByTestId("radar-series")).toMatchObject({
+ dataset: expect.objectContaining({
+ color: lightTheme.palette.gradients.gradientDataVizCyanBlue,
+ stroke: lightTheme.palette.vars.accentHDefault,
+ strokeWidth: String(SPIDER_GRADIENT_STROKE_WIDTH),
+ dotRadius: String(SPIDER_GRADIENT_DOT_RADIUS),
+ dotFill: blueAlpha40,
+ }),
+ });
+ });
+
+ it("lets explicit background, stroke and dot props win over the ramp", () => {
+ renderSpiderChart(false, {
+ radars: [
+ {
+ name: "Coverage",
+ dataKey: "variableA",
+ gradient: "cyanBlue",
+ background: "linear-gradient(90deg, red 0%, blue 100%)",
+ stroke: "#ff0000",
+ dot: false,
+ },
+ ],
+ });
+
+ expect(screen.getByTestId("radar-series")).toMatchObject({
+ dataset: expect.objectContaining({
+ color: "linear-gradient(90deg, red 0%, blue 100%)",
+ stroke: "#ff0000",
+ dotRadius: "0",
+ }),
+ });
+ });
+
+ it("leaves radars without a gradient unstroked and undotted", () => {
+ renderSpiderChart();
+
+ expect(screen.getByTestId("radar-series")).toMatchObject({
+ dataset: expect.objectContaining({
+ stroke: "",
+ strokeWidth: "0",
+ dotRadius: "",
+ }),
+ });
+ });
+ });
+
+ // The suite mocks recharts, so the shape never renders through `Radar`.
+ describe("gradient radar shape", () => {
+ const points = [
+ { x: 0, y: 0 },
+ { x: 10, y: 0 },
+ { x: 10, y: 10 },
+ ];
+ const path = "M0 0 L10 0 L10 10Z";
+
+ const renderShape = (
+ props: Partial> = {},
+ ) =>
+ render(
+ ,
+ );
+
+ it("clips the ramp to the polygon, outlines it and rings each vertex", () => {
+ const { container } = renderShape();
+
+ const clip = container.querySelector("clipPath");
+ expect(clip?.querySelector("path")).toHaveAttribute("d", path);
+
+ const foreignObject = container.querySelector("foreignObject");
+ expect(foreignObject).toHaveAttribute(
+ "clip-path",
+ `url(#${clip?.getAttribute("id")})`,
+ );
+ // The ramp spans the polygon's bounding box, matching Figma's
+ // object-bounding-box gradient fill.
+ expect(foreignObject).toHaveAttribute("x", "0");
+ expect(foreignObject).toHaveAttribute("y", "0");
+ expect(foreignObject).toHaveAttribute("width", "10");
+ expect(foreignObject).toHaveAttribute("height", "10");
+ expect(foreignObject?.querySelector("div")).toHaveStyle({
+ background: midnightTheme.palette.gradients.gradientDataVizCyanBlue,
+ });
+
+ const outline = container.querySelector("svg > path");
+ expect(outline).toHaveAttribute("d", path);
+ expect(outline).toHaveAttribute("stroke", night700);
+ expect(outline).toHaveAttribute(
+ "stroke-width",
+ String(SPIDER_GRADIENT_STROKE_WIDTH),
+ );
+ expect(outline).toHaveAttribute("fill", "none");
+
+ const dots = container.querySelectorAll("circle");
+ expect(dots).toHaveLength(points.length);
+ expect(dots[0]).toHaveAttribute("cx", "0");
+ expect(dots[0]).toHaveAttribute("cy", "0");
+ expect(dots[0]).toHaveAttribute("r", String(SPIDER_GRADIENT_DOT_RADIUS));
+ expect(dots[0]).toHaveAttribute("fill", blueAlpha40);
+ expect(dots[0]).toHaveAttribute("stroke", night700);
+ expect(dots[0]).toHaveAttribute(
+ "stroke-width",
+ String(SPIDER_GRADIENT_STROKE_WIDTH),
+ );
+ });
+
+ it("falls back to solid outline-colored dots without a dotFill", () => {
+ const { container } = renderShape({ dotFill: undefined });
+
+ expect(container.querySelector("circle")).toHaveAttribute(
+ "fill",
+ night700,
+ );
+ });
+
+ // Blue-dark rings its dots in the ramp's 3B82F6 stop, not the outline
+ // color.
+ it("lets dotStroke diverge from the outline color", () => {
+ const { container } = renderShape({
+ stroke: blue500,
+ dotStroke: midnightGradientStops.dataVizBlue,
+ });
+
+ expect(container.querySelector("svg > path")).toHaveAttribute(
+ "stroke",
+ blue500,
+ );
+ expect(container.querySelector("circle")).toHaveAttribute(
+ "stroke",
+ midnightGradientStops.dataVizBlue,
+ );
+ });
+
+ it("keeps the outline but drops the dots at a zero radius", () => {
+ const { container } = renderShape({ dotRadius: 0 });
+
+ expect(container.querySelector("svg > path")).toBeInTheDocument();
+ expect(container.querySelectorAll("circle")).toHaveLength(0);
+ });
+
+ it("renders nothing without points", () => {
+ const { container } = renderShape({ points: [] });
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ // Four gradient radars share one page in the Figma frame, so a constant
+ // clip id would let them resolve each other's polygon.
+ it("gives each instance its own clip id", () => {
+ const { container } = render(
+ <>
+
+
+ >,
+ );
+
+ const ids = [...container.querySelectorAll("clipPath")].map((clip) =>
+ clip.getAttribute("id"),
+ );
+
+ expect(ids).toHaveLength(2);
+ expect(new Set(ids).size).toBe(2);
+ expect(ids.every((id) => id && !id.includes(":"))).toBe(true);
+ });
+ });
+
it("uses a safe domain when data has no numeric values", () => {
renderSpiderChart(false, {
data: [{ subject: "Empty" }],
diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-gradient-radar.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-gradient-radar.tsx
new file mode 100644
index 00000000..ba416bf5
--- /dev/null
+++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-gradient-radar.tsx
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useId } from "react";
+
+/**
+ * Radar polygon for the gradient treatment — Figma `Spider Chart`
+ * (274417:44533).
+ *
+ * Three layers, painted bottom up:
+ * 1. the data-viz ramp, clipped to the polygon. A CSS gradient cannot be an
+ * SVG `fill`, so the ramp is painted by a `foreignObject` div and masked
+ * with a `clipPath`, the same technique as `custom-conical-gradient` —
+ * except the div is sized to the polygon's bounding box, not the chart.
+ * Figma fills the vector with an object-bounding-box gradient (pink for
+ * roughly the top fifth of the shape, purple below), so the ramp must
+ * span the shape itself or the polygon only ever shows the middle slice
+ * of the chart-wide ramp.
+ * 2. the outline, a stroked copy of the same path.
+ * 3. a ring on each data vertex: a translucent `dotFill` disc under a
+ * `dotStroke` ring (the outline color unless the frame diverges, as
+ * blue-dark does) at the outline's stroke width, exactly the frame's
+ * Ellipse nodes.
+ *
+ * Recharts clones this element with the `Radar` props, so every value below
+ * arrives from the `Radar` element rather than from the caller — which is why
+ * the rings are keyed on `dotRadius`/`dotFill` rather than a `dot` flag:
+ * `dot` is a `Radar` prop of its own, and Recharts would draw a second set.
+ *
+ * The clip id is derived from `useId` rather than a constant, so several
+ * gradient radars on one page — a series comparison, or the four variants of
+ * the frame side by side — do not resolve each other's clip path.
+ */
+const CustomGradientRadar = ({
+ points = [],
+ color,
+ stroke,
+ strokeWidth = 0,
+ dotRadius = 0,
+ dotFill,
+ dotStroke,
+}: {
+ points?: { x: number; y: number }[];
+ color?: string;
+ stroke?: string;
+ strokeWidth?: number;
+ dotRadius?: number;
+ dotFill?: string;
+ dotStroke?: string;
+}) => {
+ // `useId` wraps its value in colons, which are not valid in a `url(#...)`
+ // reference.
+ const clipId = `spider-gradient-radar-${useId().replace(/:/g, "")}`;
+
+ if (!points.length) return null;
+
+ const path =
+ points.map((p, i) => (i ? `L${p.x} ${p.y}` : `M${p.x} ${p.y}`)).join(" ") +
+ "Z";
+
+ const xs = points.map((p) => p.x);
+ const ys = points.map((p) => p.y);
+ const minX = Math.min(...xs);
+ const minY = Math.min(...ys);
+
+ return (
+
+ );
+};
+
+export default CustomGradientRadar;
diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx
index 72eca621..4ce42f0c 100644
--- a/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx
+++ b/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx
@@ -15,12 +15,18 @@ import {
} from "recharts";
import { useTheme, type Theme } from "@mui/material/styles";
import CustomConicalGradient from "./custom-conical-gradient";
+import CustomGradientRadar from "./custom-gradient-radar";
import CustomLines from "./custom-lines";
import CustomPolarGrid from "./custom-polar-grid";
import CustomLabels from "./custom-radar-labels";
import CustomRadarTick from "./custom-radar-tick";
import CustomTooltip from "./custom-tooltip";
-import { StyledRadarChart } from "../styles/spider-chart.styles";
+import {
+ getSpiderChartGradient,
+ SPIDER_GRADIENT_DOT_RADIUS,
+ SPIDER_GRADIENT_STROKE_WIDTH,
+ StyledRadarChart,
+} from "../styles/spider-chart.styles";
import {
ExtendedDataPoint,
SpiderChartProps,
@@ -129,21 +135,51 @@ export const SpiderChart = ({
.map((_, i) => i * angleStep + computedAngleOffset)}
/>
- {radars.map((radar, index) => (
-
- ))}
+ {radars.map((radar, index) => {
+ // The gradient treatment only supplies defaults: an explicit
+ // `background`, `stroke` or `dot` on the radar still wins.
+ const gradient = radar.gradient
+ ? getSpiderChartGradient(theme, radar.gradient)
+ : undefined;
+ const stroke = radar.stroke ?? gradient?.stroke;
+ const showDots = radar.dot ?? Boolean(gradient);
+
+ return (
+
+ ) : (
+ CustomConicalGradient
+ ))
+ }
+ />
+ );
+ })}
{showTooltip && (
= {
},
radars: {
control: false,
- description: "Radar series definitions and token colors.",
+ description:
+ "Radar series definitions and token colors. Set `gradient` on a series for the design-approved data-viz ramps.",
},
scale: {
control: "number",
@@ -187,3 +189,78 @@ export const CustomTooltipContent: Story = {
export const WithoutTooltip: Story = {
render: () => ,
};
+
+/*
+ * Gradient treatment — Figma `Spider Chart` (274417:44533).
+ *
+ * The frame scores one agent per widget on the same six axes, and pairs each
+ * widget with the `gradient-token` swatch its radar is filled from. The four
+ * stories below keep that pairing: the agent names and axes are the frame's,
+ * and each `gradient` key is the swatch label.
+ */
+const agentData: ExtendedDataPoint[] = [
+ { subject: "Cost", variableA: 82 },
+ { subject: "Tool Utilization Accuracy", variableA: 74 },
+ { subject: "Response Completeness", variableA: 61 },
+ { subject: "Intent Recognition Accuracy", variableA: 88 },
+ { subject: "Answer Relevancy", variableA: 70 },
+ { subject: "Groundedness", variableA: 79 },
+];
+
+// Offsets follow the chart's sorted axis order (Answer Relevancy at the top,
+// clockwise). The two long labels land on the left, where start-anchored text
+// would run into the polygon, so they are pulled out and past the grid corner.
+const agentLabelOffsets = [
+ { cx: 34, cy: 10 }, // Answer Relevancy
+ { cx: -4, cy: 12 }, // Cost
+ { cx: -10, cy: -22 }, // Groundedness
+ { cx: -30, cy: 0 }, // Intent Recognition Accuracy
+ { cx: -48, cy: 28 }, // Response Completeness
+ { cx: -48, cy: -26 }, // Tool Utilization Accuracy
+];
+
+const gradientVariants: { label: string; gradient: SpiderChartGradient }[] = [
+ { label: "Concierge Agent", gradient: "pinkPurple" },
+ { label: "Scheduling Agent", gradient: "cyanBlue" },
+ { label: "Moderator Agent", gradient: "orangeGold" },
+ { label: "Itinerary Planner", gradient: "blueDark" },
+];
+
+const GradientVariant = ({
+ label,
+ gradient,
+}: {
+ label: string;
+ gradient: SpiderChartGradient;
+}) => (
+
+ ({ color: theme.palette.vars.baseTextDefault })}
+ >
+ {label}
+
+
+
+
+
+);
+
+/**
+ * The four design-approved data-viz ramps. Each fills the radar with its
+ * `Gradient/Data-Viz-*` token, outlines it in the ramp's paired accent, and
+ * rings every data vertex in the same accent.
+ */
+export const Gradient: Story = {
+ render: () => (
+
+ {gradientVariants.map((variant) => (
+
+ ))}
+
+ ),
+};
diff --git a/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts b/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts
index 30f69580..0f63b460 100644
--- a/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts
+++ b/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts
@@ -4,8 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { styled } from "@mui/material";
+import { alpha, styled, type Theme } from "@mui/material";
import type { ComponentType, HTMLAttributes } from "react";
+import {
+ blueAlpha40,
+ lightAlphaOrange40,
+ purpleAlpha40,
+} from "@/theme/style/color-palette";
+import type { SpiderChartGradient } from "../types/spider-chart.types";
export const StyledTooltip = styled("div")(({ theme }) => ({
width: "max-content",
@@ -16,6 +22,81 @@ export const StyledTooltip = styled("div")(({ theme }) => ({
padding: "2px 8px",
})) as ComponentType>;
+/**
+ * Outline width and vertex-dot geometry for the gradient treatment.
+ *
+ * Figma `Spider Chart` (274417:44533) is drawn at 1.333× (the widget border
+ * reads 1.333px): every data polygon and dot ring carries a 2.678px stroke,
+ * i.e. 2px at 1×. The dots read 9.27px across, i.e. 6.95px at 1× — a ring
+ * whose inner radius is 2.5 under the shared 2px stroke.
+ */
+export const SPIDER_GRADIENT_STROKE_WIDTH = 2;
+export const SPIDER_GRADIENT_DOT_RADIUS = 2.5;
+
+/**
+ * Gradient treatment — Figma `Spider Chart` (274417:44533).
+ *
+ * The frame pairs each Summary widget with a `gradient-token` swatch. The
+ * swatch documents the fill ramp — all four labels already exist as theme
+ * gradients — while the outline and dot colors are read off the widget's own
+ * data polygon and vertex rings, and every one is a named palette value:
+ *
+ * | Ramp | Outline (`stroke`) | Dot fill | Dot ring |
+ * | ------------ | ---------------------------------- | ----------------------------- | -------- |
+ * | `pinkPurple` | `infoBorderDefault` | `purpleAlpha40` (B76DFF66) | outline |
+ * | `cyanBlue` | `accentHDefault` | `blueAlpha40` (0051AF66) | outline |
+ * | `orangeGold` | `warningBorderDefault` | `lightAlphaOrange40` | outline |
+ * | `blueDark` | `interactivePrimaryDefaultActive` | B9ABEF @ 76%, no palette name | `interactivePrimaryDefaultDefault` |
+ *
+ * Blue-dark is the one variant whose dot rings are not the outline color. The
+ * frame (OXP `Itinerary Planner`) rings them a step lighter than the outline —
+ * 558BFF against 1469CC in Midnight — so the two take the Default and Active
+ * ends of the same Interactive/Primary ramp rather than one shared value.
+ *
+ * The outlines are theme vars, so they retone with the theme rather than
+ * staying pinned to the Midnight frame. The dot fills are still fixed palette
+ * alphas: design has not diverged those per theme.
+ */
+export const getSpiderChartGradient = (
+ theme: Theme,
+ gradient: SpiderChartGradient,
+): { background: string; stroke: string; dotFill: string; dotStroke: string } => {
+ const gradients = theme.palette.gradients;
+ const { vars } = theme.palette;
+
+ switch (gradient) {
+ case "cyanBlue":
+ return {
+ background: gradients.gradientDataVizCyanBlue,
+ stroke: vars.accentHDefault,
+ dotFill: blueAlpha40,
+ dotStroke: vars.accentHDefault,
+ };
+ case "orangeGold":
+ return {
+ background: gradients.gradientDataVizOrangeGold,
+ stroke: vars.warningBorderDefault,
+ dotFill: lightAlphaOrange40,
+ dotStroke: vars.warningBorderDefault,
+ };
+ case "blueDark":
+ return {
+ background: gradients.gradientDataVizBlueDark,
+ stroke: vars.interactivePrimaryDefaultActive,
+ dotFill: alpha("#b9abef", 0.76),
+ dotStroke: vars.interactivePrimaryDefaultDefault,
+ };
+ case "pinkPurple":
+ default:
+ return {
+ background: gradients.gradientDataVizPinkPurple,
+ stroke: vars.infoBorderDefault,
+ dotFill: purpleAlpha40,
+ dotStroke: vars.infoBorderDefault,
+ };
+ }
+};
+
export const StyledRadarChart = styled("div")({
width: "100%",
height: "100%",
diff --git a/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts b/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts
index 2445f4dd..98987a9d 100644
--- a/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts
+++ b/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts
@@ -18,6 +18,26 @@ export type ExtendedDataPoint = {
variableA?: number;
} & DataPoint;
+/**
+ * Design-approved data-viz ramps for a radar series.
+ *
+ * Each key is the `gradient-token` label on the matching widget in Figma
+ * `Spider Chart` (274417:44533), camelCased the same way `GradientVarsType`
+ * camelCases the Figma path:
+ *
+ * | Key | Figma token | Theme token |
+ * | ------------ | ------------------------------- | ---------------------------- |
+ * | `pinkPurple` | `Gradient/Data-Viz-Pink-Purple` | `gradientDataVizPinkPurple` |
+ * | `cyanBlue` | `Gradient/Data-Viz-Cyan-Blue` | `gradientDataVizCyanBlue` |
+ * | `orangeGold` | `Gradient/Data-Viz-Orange-Gold` | `gradientDataVizOrangeGold` |
+ * | `blueDark` | `Gradient/Data-Viz-Blue-Dark` | `gradientDataVizBlueDark` |
+ */
+export type SpiderChartGradient =
+ | "pinkPurple"
+ | "cyanBlue"
+ | "orangeGold"
+ | "blueDark";
+
export type RadarType = {
/** Legend and tooltip name for the radar series. */
name: string;
@@ -27,6 +47,20 @@ export type RadarType = {
fill?: string;
/** CSS gradient or tokenized background used by the custom radar shape. */
background?: string;
+ /**
+ * Applies the gradient treatment: the named data-viz ramp fills the radar
+ * area, the ramp's paired accent draws the outline, and each data vertex
+ * gets a ring in the same accent over a translucent fill. Sets
+ * `background`, `stroke` and `dot` unless those are given explicitly.
+ */
+ gradient?: SpiderChartGradient;
+ /**
+ * Outline color for the radar polygon. Defaults to the accent paired with
+ * `gradient`; without either, the polygon has no outline.
+ */
+ stroke?: string;
+ /** Renders a ring at each data vertex, stroked in the outline color. */
+ dot?: boolean;
/** Optional Recharts shape override for the radar polygon. */
shape?: ReactElement;
};
diff --git a/packages/open-ui-kit/src/colors/colors.mdx b/packages/open-ui-kit/src/colors/colors.mdx
index 9fe4298c..7f46b08e 100644
--- a/packages/open-ui-kit/src/colors/colors.mdx
+++ b/packages/open-ui-kit/src/colors/colors.mdx
@@ -1,6 +1,9 @@
import { Meta } from "@storybook/addon-docs/blocks";
import { DocsHeader } from "storybook/components/docs-header.stories";
-import { ColorPaletteSection, paletteToSwatches } from "./color-palette-section";
+import {
+ ColorPaletteSection,
+ paletteToSwatches,
+} from "./color-palette-section";
import {
surfaceLightPalette,
surfaceDarkPalette,
@@ -174,6 +177,7 @@ import {
+
diff --git a/packages/open-ui-kit/src/components/activity-timeline/__tests__/activity-timeline.test.tsx b/packages/open-ui-kit/src/components/activity-timeline/__tests__/activity-timeline.test.tsx
index 4e8518f5..aea6afcf 100644
--- a/packages/open-ui-kit/src/components/activity-timeline/__tests__/activity-timeline.test.tsx
+++ b/packages/open-ui-kit/src/components/activity-timeline/__tests__/activity-timeline.test.tsx
@@ -10,6 +10,7 @@ import "@testing-library/jest-dom";
import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider";
import { darkTheme } from "@/theme/dark/dark-theme";
import { lightTheme } from "@/theme/light/light-theme";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
import { ActivityTimeline } from "../components/activity-timeline";
import { ActivityTimelineDot } from "../components/activity-timeline-dot";
import { getActivityTimelineDotStyle } from "../styles";
@@ -201,6 +202,127 @@ describe("ActivityTimeline", () => {
});
});
+ describe("gradient variant", () => {
+ const keyEvents = [
+ {
+ status: ActivityTimelineStepStatus.Complete,
+ title: "Newest",
+ time: "11:40",
+ },
+ {
+ status: ActivityTimelineStepStatus.InProgress,
+ title: "Second",
+ time: "8:45",
+ },
+ {
+ status: ActivityTimelineStepStatus.Neutral,
+ title: "Third",
+ time: "7:42",
+ },
+ {
+ status: ActivityTimelineStepStatus.Error,
+ title: "Fourth",
+ time: "7:02",
+ },
+ {
+ status: ActivityTimelineStepStatus.Inactive,
+ title: "Oldest",
+ time: "6:15",
+ },
+ ];
+
+ const renderKeyEvents = (steps = keyEvents) =>
+ render(
+
+
+ ,
+ );
+
+ const slotOpacity = (title: string) =>
+ screen.getByText(title).closest(".MuiTimelineOppositeContent-root");
+
+ it("renders a time beside every step", () => {
+ renderKeyEvents();
+ expect(screen.getByText("11:40")).toBeInTheDocument();
+ expect(screen.getByText("6:15")).toBeInTheDocument();
+ });
+
+ // Figma holds every step above the oldest two at full strength, then drops
+ // to 50% and 30% — it is not a ramp spread across the whole list.
+ it("fades only the oldest two steps", () => {
+ renderKeyEvents();
+
+ expect(slotOpacity("Newest")).toHaveStyle({ opacity: "1" });
+ expect(slotOpacity("Second")).toHaveStyle({ opacity: "1" });
+ expect(slotOpacity("Third")).toHaveStyle({ opacity: "1" });
+ expect(slotOpacity("Fourth")).toHaveStyle({ opacity: "0.5" });
+ expect(slotOpacity("Oldest")).toHaveStyle({ opacity: "0.3" });
+ });
+
+ it("keeps the newest step solid in a two-step list", () => {
+ renderKeyEvents(keyEvents.slice(0, 2));
+
+ expect(slotOpacity("Newest")).toHaveStyle({ opacity: "1" });
+ expect(slotOpacity("Second")).toHaveStyle({ opacity: "0.3" });
+ });
+
+ // One rail for the list, so the ramp does not restart at every dot.
+ it("draws no per-step connector", () => {
+ const { container } = renderKeyEvents();
+ expect(
+ container.querySelectorAll(".MuiTimelineConnector-root"),
+ ).toHaveLength(0);
+ });
+
+ it("leaves the default variant on per-step connectors", () => {
+ const { container } = renderWithTheme();
+ expect(
+ container.querySelectorAll(".MuiTimelineConnector-root").length,
+ ).toBeGreaterThan(0);
+ });
+
+ const rail = (container: HTMLElement) =>
+ container.querySelector(".MuiTimeline-root")?.className;
+
+ // A lone step has nothing to connect, so it gets no dangling line — the
+ // default variant leaves a single step without a connector too.
+ it("drops the rail for a single step", () => {
+ const { container } = renderKeyEvents(keyEvents.slice(0, 1));
+ expect(rail(container)).not.toBe(rail(renderKeyEvents().container));
+ });
+
+ it("renders an empty step list without throwing", () => {
+ expect(() => renderKeyEvents([])).not.toThrow();
+ });
+
+ it("keeps the time column and the rail on one width", () => {
+ const { container } = renderKeyEvents();
+ const time = container.querySelectorAll(".MuiTimelineContent-root")[0];
+ expect(time).toHaveStyle({ flex: "0 0 48px" });
+ });
+
+ it("carries the Figma rail ramp on the Midnight theme", () => {
+ expect(midnightTheme.palette.gradients.gradientCardHighlightRadial).toBe(
+ "linear-gradient(180deg, #ffffff 0%, rgba(153, 153, 153, 0) 100%)",
+ );
+ });
+
+ // Figma's dot export ends the ramp one box-width from the corner, so the
+ // glow must not fall back to the CSS default of `farthest-corner`.
+ it("closes the dot glow on the far side, not the far corner", () => {
+ const g = midnightTheme.palette.gradients;
+ for (const glow of [
+ g.gradientGlowGreen,
+ g.gradientGlowOrange,
+ g.gradientGlowRed,
+ ]) {
+ expect(
+ glow.startsWith("radial-gradient(circle farthest-side at 0% 0%,"),
+ ).toBe(true);
+ }
+ });
+ });
+
describe("dark theme token coverage", () => {
it("renders status steps in dark mode without throwing", () => {
renderWithTheme(, true);
diff --git a/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx b/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx
index c44e38d9..52d4a8af 100644
--- a/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx
+++ b/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx
@@ -6,10 +6,11 @@
import DoneIcon from "@mui/icons-material/Done";
import CloseIcon from "@mui/icons-material/Close";
-import { CircularProgress, useTheme, type BoxProps } from "@mui/material";
+import { Box, CircularProgress, useTheme, type BoxProps } from "@mui/material";
import { ActivityTimelineStepStatus } from "../types";
import { getActivityTimelineDotStyle } from "../styles";
-import { StyledTimelineDotRoot } from "./elements";
+import { getStepDotColor, getStepGlow } from "../utils/utils";
+import { GLOW_DOT_SIZE, StyledTimelineDotRoot } from "./elements";
export interface ActivityTimelineDotProps extends BoxProps {
/** Uses percent-driven progress rendering instead of status icons. */
@@ -18,12 +19,15 @@ export interface ActivityTimelineDotProps extends BoxProps {
percent?: number;
/** Visual state for the dot. */
status?: ActivityTimelineStepStatus;
+ /** Renders a small solid dot with a radial glow (gradient variant). */
+ glow?: boolean;
}
export const ActivityTimelineDot = ({
automaticProgress = false,
percent,
status = ActivityTimelineStepStatus.Inactive,
+ glow = false,
...props
}: ActivityTimelineDotProps) => {
const theme = useTheme();
@@ -33,6 +37,24 @@ export const ActivityTimelineDot = ({
const isInProgress =
effectiveStatus === ActivityTimelineStepStatus.InProgress;
+ if (glow) {
+ // Gradient statuses fill the dot with the radial glow; the rest are solid.
+ const dotFill =
+ getStepGlow(status, theme) ?? getStepDotColor(status, theme);
+ return (
+
+
+
+ );
+ }
+
return (
{
const theme = useTheme();
const isMedium = size === "medium";
+ // Older steps fade down the list.
+ const stepOpacity = useCallback(
+ (stepIdx: number): number => {
+ const fromOldest = steps.length - 1 - stepIdx;
+ // The newest step stays solid however short the list is.
+ if (stepIdx === 0 || fromOldest >= OLDEST_STEP_OPACITY.length) {
+ return 1;
+ }
+ return OLDEST_STEP_OPACITY[fromOldest];
+ },
+ [steps.length],
+ );
+
const setPercent = useCallback(
(stepIdx: number): number => {
if (steps.length <= 1) {
@@ -40,6 +61,55 @@ export const ActivityTimeline = ({
[steps.length],
);
+ if (variant === "gradient") {
+ return (
+ 1}
+ sx={[
+ ...(Array.isArray(props.sx) ? props.sx : props.sx ? [props.sx] : []),
+ ]}
+ >
+ {steps.map((step, index) => (
+ // Fade each slot, not the item, so the rail keeps its own ramp.
+
+
+
+ {step.titleStartIcon}
+ {step.title}
+
+
+
+
+
+
+ {step.time && (
+
+ {step.time}
+
+ )}
+
+
+ ))}
+
+ );
+ }
+
return (
({
},
})) as ComponentType;
+const SEPARATOR_TOP_MARGIN = 2;
+const DOT_ROOT_TOP_MARGIN = -1;
+
export const StyledTimelineSeparator = styled(TimelineSeparator)(() => ({
alignItems: "center",
- marginTop: "2px",
+ marginTop: `${SEPARATOR_TOP_MARGIN}px`,
})) as ComponentType;
export const StyledTimelineConnector = styled(TimelineConnector, {
@@ -55,7 +60,75 @@ export const StyledTimelineDotRoot = styled(Box)(() => ({
alignItems: "center",
display: "inline-flex",
justifyContent: "center",
- margin: "-1px 0px",
+ margin: `${DOT_ROOT_TOP_MARGIN}px 0px`,
position: "relative",
zIndex: 1,
})) as ComponentType;
+
+/** Gradient-variant geometry, from Figma `Key Events` (274455:53816). */
+export const GLOW_DOT_SIZE = 7.142;
+const TIME_COLUMN_WIDTH = 48;
+const RAIL_WIDTH = 1;
+const FIRST_DOT_CENTER =
+ SEPARATOR_TOP_MARGIN + DOT_ROOT_TOP_MARGIN + GLOW_DOT_SIZE / 2;
+
+/*
+ * Gradient variant: one rail for the whole list, not a connector per step.
+ *
+ * Figma draws the line as a single vector (274455:53823) with a white ->
+ * transparent stroke, so the fade has to run unbroken from the first dot to the
+ * bottom. Per-item connectors would restart the ramp at every dot.
+ *
+ * The rail is pinned to the dot axis — the time column plus half a dot in from
+ * the inline end — because that is where the separator sits between the two
+ * content slots. It stays behind the dots, which paint at `zIndex: 1`.
+ *
+ * `showRail` is off for a single step: there is nothing to connect, and the
+ * default variant leaves a lone step without a connector too.
+ */
+export const StyledGradientTimeline = styled(StyledTimeline, {
+ shouldForwardProp: (prop) => prop !== "showRail",
+})<{ showRail?: boolean }>(({ theme, showRail }) => ({
+ position: "relative",
+ ...(showRail && {
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ top: `${FIRST_DOT_CENTER}px`,
+ bottom: 0,
+ // Logical, not `right`, so the rail follows the flex row in RTL.
+ insetInlineEnd: `${TIME_COLUMN_WIDTH + GLOW_DOT_SIZE / 2 - RAIL_WIDTH / 2}px`,
+ width: `${RAIL_WIDTH}px`,
+ background: theme.palette.gradients?.gradientCardHighlightRadial,
+ // Figma runs the ramp over 1065.96 on a 720.86 rail, so the line still
+ // has about a third of its opacity left at the bottom, not zero.
+ backgroundSize: "100% 147.9%",
+ backgroundRepeat: "no-repeat",
+ zIndex: 0,
+ },
+ }),
+})) as ComponentType;
+
+// Event text sits to the left of the line.
+export const StyledTimelineOppositeContent = styled(TimelineOppositeContent)(
+ () => ({
+ flex: 1,
+ // `minWidth: 0` keeps every row the same width, so the line stays on one axis.
+ minWidth: 0,
+ margin: 0,
+ padding: "0 16px 40px 0",
+ textAlign: "left",
+ overflowWrap: "anywhere",
+ }),
+) as ComponentType;
+
+// Time sits to the right of the line, at a fixed width so the line stays aligned.
+// The rail above is positioned off this width, so the two must not drift apart.
+export const StyledTimelineTimeContent = styled(TimelineContent)(() => ({
+ flex: `0 0 ${TIME_COLUMN_WIDTH}px`,
+ minWidth: 0,
+ margin: 0,
+ padding: "0 0 40px 12px",
+ textAlign: "left",
+ whiteSpace: "nowrap",
+})) as ComponentType;
diff --git a/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx b/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx
index f5f294de..0fedd15d 100644
--- a/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx
+++ b/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx
@@ -159,6 +159,34 @@ const completeProgressSteps: ActivityTimelineStep[] = [
},
];
+const keyEventsSteps: ActivityTimelineStep[] = [
+ {
+ status: ActivityTimelineStepStatus.Complete,
+ title: "Treated 29h booking as within 24h window",
+ time: "11:40",
+ },
+ {
+ status: ActivityTimelineStepStatus.InProgress,
+ title: 'cited "silver members can cancel anytime" — not in policy',
+ time: "8:45",
+ },
+ {
+ status: ActivityTimelineStepStatus.Neutral,
+ title: "confirmed $585 / charged $1200 — cabin upgrade applied to both pax",
+ time: "7:42",
+ },
+ {
+ status: ActivityTimelineStepStatus.Error,
+ title: "changed destination LGA → JFK on flight modification",
+ time: "7:02",
+ },
+ {
+ status: ActivityTimelineStepStatus.Inactive,
+ title: "London alert",
+ time: "7:02",
+ },
+];
+
const StoryPanel = ({ children }: { children: ReactNode }) => (
{children}
);
@@ -281,3 +309,15 @@ export const AutomaticProgress: Story = {
),
};
+
+export const Gradient: Story = {
+ name: "Gradient (Key Events)",
+ render: () => (
+
+
+ Key Events
+
+
+
+ ),
+};
diff --git a/packages/open-ui-kit/src/components/activity-timeline/types/index.ts b/packages/open-ui-kit/src/components/activity-timeline/types/index.ts
index 7c5ba222..b3947ca7 100644
--- a/packages/open-ui-kit/src/components/activity-timeline/types/index.ts
+++ b/packages/open-ui-kit/src/components/activity-timeline/types/index.ts
@@ -28,6 +28,8 @@ export interface ActivityTimelineStep {
content?: ReactNode;
/** Expands accordion content by default when content is provided. */
defaultExpanded?: boolean;
+ /** Time label shown to the right of the line. Gradient variant only. */
+ time?: string;
}
export interface ActivityTimelineProps extends Omit<
@@ -38,6 +40,12 @@ export interface ActivityTimelineProps extends Omit<
automaticProgress?: boolean;
/** Controls the timeline title typography and vertical spacing. */
size?: "large" | "medium";
- /** Ordered steps rendered in the activity timeline. */
+ /**
+ * `gradient` renders the "Key Events" style: glowing status dots on a single
+ * line that fades down the list, with each step's `time` beside it. Steps run
+ * newest first, and the oldest two dim to 50% and 30%.
+ */
+ variant?: "default" | "gradient";
+ /** Ordered steps rendered in the activity timeline, newest first. */
steps: ActivityTimelineStep[];
}
diff --git a/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts b/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts
index 1a5565f1..16e0c283 100644
--- a/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts
+++ b/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts
@@ -24,3 +24,41 @@ export const setStepColor = (
return theme.palette.vars?.interactiveTertiaryActive;
}
};
+
+/** Solid center color for a gradient-variant dot, by status. */
+export const getStepDotColor = (
+ status: ActivityTimelineStepStatus,
+ theme: Theme,
+): string => {
+ switch (status) {
+ case ActivityTimelineStepStatus.Complete:
+ return theme.palette.vars?.excellentIconDefault;
+ case ActivityTimelineStepStatus.InProgress:
+ return theme.palette.vars?.warningIconDefault;
+ case ActivityTimelineStepStatus.Error:
+ return theme.palette.vars?.negativeIconDefault;
+ case ActivityTimelineStepStatus.Neutral:
+ return theme.palette.vars?.controlIconMedium;
+ default:
+ // Figma's inactive dot (274455:53846) is pure white, not the off-white
+ // `controlIconDefault` the rest of the timeline uses for text.
+ return theme.palette.vars?.controlIconStrong;
+ }
+};
+
+/** Radial glow fill for a gradient dot, or `undefined` when it renders solid. */
+export const getStepGlow = (
+ status: ActivityTimelineStepStatus,
+ theme: Theme,
+): string | undefined => {
+ switch (status) {
+ case ActivityTimelineStepStatus.Complete:
+ return theme.palette.gradients?.gradientGlowGreen;
+ case ActivityTimelineStepStatus.InProgress:
+ return theme.palette.gradients?.gradientGlowOrange;
+ case ActivityTimelineStepStatus.Error:
+ return theme.palette.gradients?.gradientGlowRed;
+ default:
+ return undefined;
+ }
+};
diff --git a/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx b/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx
index d3bb344d..afe3ca66 100644
--- a/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx
+++ b/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx
@@ -9,6 +9,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import { ImageGrid } from "@/custom-icons";
import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
import { Button } from "../components/button";
const renderButton = (
@@ -57,17 +58,99 @@ describe("Button", () => {
).not.toThrow();
});
+ it("renders gradient variant without throwing", () => {
+ expect(() =>
+ renderButton({ variant: "gradient", children: "Gradient" }),
+ ).not.toThrow();
+ });
+
it("renders outlined variant without throwing", () => {
expect(() =>
renderButton({ variant: "outlined", children: "Outlined" }),
).not.toThrow();
});
+ it("renders gradientOutlined variant without throwing", () => {
+ expect(() =>
+ renderButton({
+ variant: "gradientOutlined",
+ children: "Gradient Outlined",
+ }),
+ ).not.toThrow();
+ });
+
it("renders tertariary variant without throwing", () => {
expect(() =>
renderButton({ variant: "tertariary", children: "Tertiary" }),
).not.toThrow();
});
+
+ it("applies the MUI variant class for the gradient variants", () => {
+ const { getByRole, unmount } = renderButton({
+ variant: "gradient",
+ children: "Gradient",
+ });
+ expect(getByRole("button").className).toContain("MuiButton-gradient");
+ unmount();
+
+ const ring = renderButton({
+ variant: "gradientOutlined",
+ children: "Gradient Outlined",
+ });
+ expect(ring.getByRole("button").className).toContain(
+ "MuiButton-gradientOutlined",
+ );
+ ring.unmount();
+ });
+ });
+
+ // The button gradients are deliberately shared across every theme rather than
+ // overridden per theme, so the variants must look the same everywhere.
+ describe("gradient variants render on every theme", () => {
+ const GRADIENT_VARIANTS = ["gradient", "gradientOutlined"] as const;
+ const MODES = [
+ ThemeMode.Light,
+ ThemeMode.Dark,
+ ThemeMode.IoC,
+ ThemeMode.Midnight,
+ ];
+
+ const renderIn = (
+ mode: ThemeMode,
+ variant: (typeof GRADIENT_VARIANTS)[number],
+ ) =>
+ render(
+
+
+ ,
+ );
+
+ it("paints a gradient fill on every theme", () => {
+ for (const mode of MODES) {
+ const { getByRole, unmount } = renderIn(mode, "gradient");
+ const background = getComputedStyle(getByRole("button")).background;
+ expect({
+ mode,
+ gradient: background.includes("linear-gradient"),
+ }).toEqual({ mode, gradient: true });
+ unmount();
+ }
+ });
+
+ it("stays visible on every theme", () => {
+ for (const mode of MODES) {
+ for (const variant of GRADIENT_VARIANTS) {
+ const { getByRole, unmount } = renderIn(mode, variant);
+ const display = getComputedStyle(getByRole("button")).display;
+ expect({ mode, variant, hidden: display === "none" }).toEqual({
+ mode,
+ variant,
+ hidden: false,
+ });
+ unmount();
+ }
+ }
+ });
});
describe("sizes", () => {
@@ -257,6 +340,60 @@ describe("Button", () => {
});
});
+ // Figma: `Icon Button AI` (274421:47620), the `Button - Dictation` control.
+ describe("icon-only gradient variant (Icon Button AI)", () => {
+ it("carries both classes the treatment is keyed on", () => {
+ renderButton({ variant: "gradient", children: });
+
+ const button = screen.getByRole("button");
+ // The style hangs off `.MuiButton-gradient.OuiButton-iconOnly`, so a
+ // gradient button only picks it up once it is also detected icon-only.
+ expect(button).toHaveClass("MuiButton-gradient");
+ expect(button).toHaveClass("OuiButton-iconOnly");
+ });
+
+ it("does not apply to a gradient button with a label", () => {
+ renderButton({ variant: "gradient", children: "Save" });
+
+ expect(screen.getByRole("button")).not.toHaveClass("OuiButton-iconOnly");
+ });
+
+ it("renders on every theme without throwing", () => {
+ for (const mode of [
+ ThemeMode.Light,
+ ThemeMode.Dark,
+ ThemeMode.Midnight,
+ ThemeMode.IoC,
+ ]) {
+ expect(() =>
+ render(
+
+
+ ,
+ ),
+ ).not.toThrow();
+ }
+ });
+
+ it("resolves to the icon-button tokens, not the primary fill", () => {
+ // Both already existed in the theme; the variant introduces no new
+ // gradient, it just reaches for the icon-button pair instead.
+ const g = midnightTheme.palette.gradients;
+
+ expect(g.gradientIconButtonBlue).toBe(
+ "linear-gradient(180deg, #043abc 0%, #113ca1 54.12%, #011d62 120.67%)",
+ );
+ expect(g.gradientIconButtonBlueGlow).toBe(
+ "linear-gradient(90deg, #3974ff 0%, rgba(57, 116, 255, 0) 100%)",
+ );
+ expect(g.gradientIconButtonBlue).not.toBe(
+ g.gradientGlobalButtonPrimaryFill,
+ );
+ });
+ });
+
describe("light theme token coverage", () => {
it("renders all variants in light mode without throwing", () => {
const variants = [
diff --git a/packages/open-ui-kit/src/components/button/components/elements.tsx b/packages/open-ui-kit/src/components/button/components/elements.tsx
index 62ab4ebf..e29a821f 100644
--- a/packages/open-ui-kit/src/components/button/components/elements.tsx
+++ b/packages/open-ui-kit/src/components/button/components/elements.tsx
@@ -11,270 +11,401 @@ import {
styled,
} from "@mui/material";
-export const StyledButton = styled(MuiButton)(({ theme }) => ({
- color: theme.palette.vars.baseTextInverse,
- textTransform: "none",
- transition: "none",
- borderRadius: "4px",
- width: "max-content",
- maxWidth: "100%",
- whiteSpace: "normal",
- overflowWrap: "break-word",
- textAlign: "center",
- alignItems: "center",
- height: "auto",
- "& .MuiButton-startIcon": {
- marginLeft: "0px",
- },
- "& .MuiButton-endIcon": {
- marginRight: "0px",
- },
- "&.OuiButton-iconOnly": {
+export const StyledButton = styled(MuiButton)(({ theme }) => {
+ return {
+ color: theme.palette.vars.baseTextInverse,
+ textTransform: "none",
+ transition: "none",
+ borderRadius: "4px",
+ width: "max-content",
+ maxWidth: "100%",
+ whiteSpace: "normal",
+ overflowWrap: "break-word",
+ textAlign: "center",
+ alignItems: "center",
+ height: "auto",
+ "& .MuiButton-startIcon": {
+ marginLeft: "0px",
+ },
+ "& .MuiButton-endIcon": {
+ marginRight: "0px",
+ },
+ "&.OuiButton-iconOnly": {
+ "&.MuiButton-sizeLarge": {
+ padding: "8px",
+ minWidth: "40px",
+ width: "40px",
+ height: "40px",
+ minHeight: "40px",
+ },
+ "&.MuiButton-sizeMedium": {
+ padding: "6px",
+ minWidth: "32px",
+ width: "32px",
+ height: "32px",
+ minHeight: "32px",
+ },
+ "&.MuiButton-sizeSmall": {
+ padding: "2px",
+ minWidth: "24px",
+ width: "24px",
+ height: "24px",
+ minHeight: "24px",
+ },
+ },
"&.MuiButton-sizeLarge": {
- padding: "8px",
- minWidth: "40px",
- width: "40px",
- height: "40px",
+ fontFamily: "Inter, sans-serif",
+ fontWeight: 600,
+ fontSize: "16px",
+ lineHeight: "125%",
minHeight: "40px",
+ height: "auto",
+ padding: "10px 16px",
+ },
+ "&.OuiButton-hasIcon.MuiButton-sizeLarge": {
+ padding: "8px 16px",
},
"&.MuiButton-sizeMedium": {
- padding: "6px",
- minWidth: "32px",
- width: "32px",
- height: "32px",
+ fontFamily: "Inter, sans-serif",
+ fontWeight: 600,
+ fontSize: "14px",
+ lineHeight: "125%",
minHeight: "32px",
+ height: "auto",
+ padding: "7px 16px",
},
"&.MuiButton-sizeSmall": {
- padding: "2px",
- minWidth: "24px",
- width: "24px",
- height: "24px",
+ fontFamily: "Inter, sans-serif",
+ fontWeight: 600,
+ fontSize: "14px",
+ lineHeight: "125%",
minHeight: "24px",
+ height: "auto",
+ padding: "3px 12px",
},
- },
- "&.MuiButton-sizeLarge": {
- fontFamily: "Inter, sans-serif",
- fontWeight: 600,
- fontSize: "16px",
- lineHeight: "125%",
- minHeight: "40px",
- height: "auto",
- padding: "10px 16px",
- },
- "&.OuiButton-hasIcon.MuiButton-sizeLarge": {
- padding: "8px 16px",
- },
- "&.MuiButton-sizeMedium": {
- fontFamily: "Inter, sans-serif",
- fontWeight: 600,
- fontSize: "14px",
- lineHeight: "125%",
- minHeight: "32px",
- height: "auto",
- padding: "7px 16px",
- },
- "&.MuiButton-sizeSmall": {
- fontFamily: "Inter, sans-serif",
- fontWeight: 600,
- fontSize: "14px",
- lineHeight: "125%",
- minHeight: "24px",
- height: "auto",
- padding: "3px 12px",
- },
- "&.MuiButton-sizeLarge svg": {
- fontSize: "24px",
- },
- "&.MuiButton-sizeMedium svg, &.MuiButton-sizeSmall svg": {
- fontSize: "20px",
- },
- "&.MuiButton-primarySizeLarge, &.MuiButton-primarySizeMedium": {
- paddingRight: "16px",
- paddingLeft: "16px",
- "&:active": {
- paddingRight: "15px",
- paddingLeft: "15px",
+ "&.MuiButton-sizeLarge svg": {
+ fontSize: "24px",
},
- },
- "&.MuiButton-primarySizeSmall:active": {
- paddingRight: "11px",
- paddingLeft: "11px",
- },
- // Primary
- "&.MuiButton-primary": {
- background: theme.palette.vars.interactivePrimaryDefaultDefault,
- "&.Mui-disabled": {
- background: theme.palette.vars.interactivePrimaryDefaultDisabled,
- color: theme.palette.vars.interactivePrimaryWeakDefault,
- opacity: 0.35,
+ "&.MuiButton-sizeMedium svg, &.MuiButton-sizeSmall svg": {
+ fontSize: "20px",
},
- "&:hover": {
- background: theme.palette.vars.interactivePrimaryDefaultHover,
+ "&.MuiButton-primarySizeLarge, &.MuiButton-primarySizeMedium": {
+ paddingRight: "16px",
+ paddingLeft: "16px",
+ "&:active": {
+ paddingRight: "15px",
+ paddingLeft: "15px",
+ },
},
- "&:active": {
- background: theme.palette.vars.interactivePrimaryDefaultActive,
- border: `1px solid ${theme.palette.vars.interactivePrimaryDefaultDefault}`,
+ "&.MuiButton-primarySizeSmall:active": {
+ paddingRight: "11px",
+ paddingLeft: "11px",
},
- "&:focus-visible": {
- outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
- outlineOffset: "2px",
- },
- "&.MuiButton-loading": {
- opacity: 1,
+ // Primary
+ "&.MuiButton-primary": {
background: theme.palette.vars.interactivePrimaryDefaultDefault,
- },
- },
- // Secondary
- "&.MuiButton-secondary": {
- background: theme.palette.vars.interactiveSecondaryDefaultDefault,
- "&.Mui-disabled": {
- background: theme.palette.vars.interactiveSecondaryDefaultDisabled,
- color: theme.palette.vars.interactiveInverseTextDefault,
- opacity: 0.35,
- },
- "&:hover": {
- background: theme.palette.vars.interactiveSecondaryDefaultHover,
- },
- "&:active": {
- background: theme.palette.vars.interactiveSecondaryDefaultActive,
- border: `1px solid ${theme.palette.vars.interactiveSecondaryDefaultDefault}`,
- },
- "&:focus-visible": {
- outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
- outlineOffset: "2px",
+ "&.Mui-disabled": {
+ background: theme.palette.vars.interactivePrimaryDefaultDisabled,
+ color: theme.palette.vars.interactivePrimaryWeakDefault,
+ opacity: 0.35,
+ },
+ "&:hover": {
+ background: theme.palette.vars.interactivePrimaryDefaultHover,
+ },
+ "&:active": {
+ background: theme.palette.vars.interactivePrimaryDefaultActive,
+ border: `1px solid ${theme.palette.vars.interactivePrimaryDefaultDefault}`,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ background: theme.palette.vars.interactivePrimaryDefaultDefault,
+ },
+ },
+ // Secondary
+ "&.MuiButton-secondary": {
background: theme.palette.vars.interactiveSecondaryDefaultDefault,
- },
- "&.MuiButton-loading": {
- opacity: 1,
- background: theme.palette.vars.interactiveSecondaryDefaultDefault,
- },
- },
- // Outlined
- "&.MuiButton-outlined": {
- border: `2px solid ${theme.palette.vars.interactiveTertiaryDefault}`,
- background: "none",
- color: theme.palette.vars.interactiveTextInDefault,
- "&:hover": {
- border: `2px solid ${theme.palette.vars.interactiveTertiaryHover}`,
- color: theme.palette.vars.interactiveTextInHover,
- },
- "&:active": {
- border: `2px solid ${theme.palette.vars.interactiveTertiaryActive}`,
- color: theme.palette.vars.interactiveTextInActive,
- },
- "&:focus-visible": {
- outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
- outlineOffset: "2px",
- },
- "&.Mui-disabled": {
- border: `2px solid ${theme.palette.vars.interactiveTertiaryDisabled}`,
- color: theme.palette.vars.baseTextWeak,
- opacity: 0.3,
- },
- "&.MuiButton-loading": {
- opacity: 1,
+ "&.Mui-disabled": {
+ background: theme.palette.vars.interactiveSecondaryDefaultDisabled,
+ color: theme.palette.vars.interactiveInverseTextDefault,
+ opacity: 0.35,
+ },
+ "&:hover": {
+ background: theme.palette.vars.interactiveSecondaryDefaultHover,
+ },
+ "&:active": {
+ background: theme.palette.vars.interactiveSecondaryDefaultActive,
+ border: `1px solid ${theme.palette.vars.interactiveSecondaryDefaultDefault}`,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ background: theme.palette.vars.interactiveSecondaryDefaultDefault,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ background: theme.palette.vars.interactiveSecondaryDefaultDefault,
+ },
+ },
+ // Gradient — background fill
+ // Figma: `Gradient/Global-Button-Primary/Fill` (274405:44106).
+ // Figma specifies only the resting state; hover/active are derived by
+ // brightness so the ramp stays intact in every theme that defines one.
+ "&.MuiButton-gradient": {
+ background: theme.palette.gradients.gradientGlobalButtonPrimaryFill,
+ color: theme.palette.vars.baseTextStrong,
+ border: "none",
+ "&:hover": {
+ background: theme.palette.gradients.gradientGlobalButtonPrimaryFill,
+ filter: "brightness(1.08)",
+ },
+ "&:active": {
+ background: theme.palette.gradients.gradientGlobalButtonPrimaryFill,
+ filter: "brightness(0.92)",
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ },
+ "&.Mui-disabled": {
+ background: theme.palette.gradients.gradientGlobalButtonPrimaryFill,
+ color: theme.palette.vars.baseTextStrong,
+ opacity: 0.35,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ background: theme.palette.gradients.gradientGlobalButtonPrimaryFill,
+ color: theme.palette.vars.baseTextStrong,
+ },
+ },
+ // Gradient — icon-only ("Icon Button AI")
+ // Figma: `Icon Button AI` (274421:47620), the `Button - Dictation` control.
+ //
+ // The icon-only form is its own design, not the text button scaled down:
+ // a pill-round control filled with `Gradient/Icon-Button-Blue` rather than
+ // the primary fill, ringed by `Gradient/Icon-Button-Blue-Glow`. Both
+ // tokens already exist in the theme, so this adds no new gradient.
+ //
+ // It wins over the `.MuiButton-gradient` rules above on specificity (two
+ // classes to their one), so ordering in this object does not matter, and
+ // the nested states override their single-class counterparts the same way.
+ "&.MuiButton-gradient.OuiButton-iconOnly": {
+ position: "relative",
+ background: theme.palette.gradients.gradientIconButtonBlue,
+ // Figma uses a 58px radius on a 32px control — fully round at every size.
+ borderRadius: "50%",
+ border: "none",
+ color: theme.palette.vars.baseTextStrong,
+ // The ring is a gradient, so it cannot be a `border-color`; same
+ // mask-composite technique as `gradientOutlined` below, at 1px.
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "1px",
+ background: theme.palette.gradients.gradientIconButtonBlueGlow,
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ },
+ "&:hover": {
+ background: theme.palette.gradients.gradientIconButtonBlue,
+ filter: "brightness(1.08)",
+ },
+ "&:active": {
+ background: theme.palette.gradients.gradientIconButtonBlue,
+ filter: "brightness(0.92)",
+ },
+ "&.Mui-disabled": {
+ background: theme.palette.gradients.gradientIconButtonBlue,
+ color: theme.palette.vars.baseTextStrong,
+ opacity: 0.35,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ background: theme.palette.gradients.gradientIconButtonBlue,
+ color: theme.palette.vars.baseTextStrong,
+ },
+ },
+ // Outlined
+ "&.MuiButton-outlined": {
border: `2px solid ${theme.palette.vars.interactiveTertiaryDefault}`,
+ background: "none",
color: theme.palette.vars.interactiveTextInDefault,
- },
- },
- // Tertiary
- "&.MuiButton-tertariary": {
- background: "none",
- color: theme.palette.vars.interactivePrimaryDefaultDefault,
- "&:hover": {
- color: theme.palette.vars.interactivePrimaryDefaultHover,
- },
- "&:active": {
- color: theme.palette.vars.interactivePrimaryDefaultActive,
- },
- "&:focus-visible": {
- outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
- outlineOffset: "2px",
- },
- "&.Mui-disabled": {
- color: theme.palette.vars.interactivePrimaryDefaultDisabled,
- },
- "&.MuiButton-loading": {
- opacity: 1,
+ "&:hover": {
+ border: `2px solid ${theme.palette.vars.interactiveTertiaryHover}`,
+ color: theme.palette.vars.interactiveTextInHover,
+ },
+ "&:active": {
+ border: `2px solid ${theme.palette.vars.interactiveTertiaryActive}`,
+ color: theme.palette.vars.interactiveTextInActive,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ },
+ "&.Mui-disabled": {
+ border: `2px solid ${theme.palette.vars.interactiveTertiaryDisabled}`,
+ color: theme.palette.vars.baseTextWeak,
+ opacity: 0.3,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ border: `2px solid ${theme.palette.vars.interactiveTertiaryDefault}`,
+ color: theme.palette.vars.interactiveTextInDefault,
+ },
+ },
+ // Gradient — border ring
+ // Figma: `Gradient/Global-Button-Primary/Border-Glow` (I274405:38087;25258:78851).
+ //
+ // A gradient cannot be assigned to `border-color`, and `border-image` ignores
+ // `border-radius`. The mask-composite ring below keeps the interior genuinely
+ // transparent (so it works on any surface) and follows the rounded corners.
+ "&.MuiButton-gradientOutlined": {
+ position: "relative",
+ border: "none",
+ background: "none",
+ color: theme.palette.vars.baseTextDefault,
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "2px",
+ background:
+ theme.palette.gradients.gradientGlobalButtonPrimaryBorderGlow,
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ },
+ "&:hover": {
+ color: theme.palette.vars.baseTextStrong,
+ "&::before": { filter: "brightness(1.12)" },
+ },
+ "&:active": {
+ color: theme.palette.vars.baseTextStrong,
+ "&::before": { filter: "brightness(0.9)" },
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ },
+ "&.Mui-disabled": {
+ color: theme.palette.vars.baseTextWeak,
+ opacity: 0.3,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ color: theme.palette.vars.baseTextDefault,
+ },
+ },
+ // Tertiary
+ "&.MuiButton-tertariary": {
+ background: "none",
color: theme.palette.vars.interactivePrimaryDefaultDefault,
- },
- },
- // Negative color — primary
- "&.MuiButton-primaryNegative": {
- background: theme.palette.vars.negativeBackgroundDefault,
- "&.Mui-disabled": {
- opacity: 0.35,
- background: theme.palette.vars.negativeBackgroundDisabled,
- color: theme.palette.vars.negativeTextInDefault,
- },
- "&:hover": {
- color: theme.palette.vars.baseTextInverse,
- background: theme.palette.vars.negativeBackgroundHover,
- },
- "&:active": {
- background: theme.palette.vars.negativeBackgroundActive,
- border: `1px solid ${theme.palette.vars.negativeBorderDefault}`,
- },
- "&:focus-visible": {
- outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
- outlineOffset: "2px",
- },
- "&.MuiButton-loading": {
- opacity: 1,
- color: theme.palette.vars.baseTextInverse,
+ "&:hover": {
+ color: theme.palette.vars.interactivePrimaryDefaultHover,
+ },
+ "&:active": {
+ color: theme.palette.vars.interactivePrimaryDefaultActive,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ },
+ "&.Mui-disabled": {
+ color: theme.palette.vars.interactivePrimaryDefaultDisabled,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ color: theme.palette.vars.interactivePrimaryDefaultDefault,
+ },
+ },
+ // Negative color — primary
+ "&.MuiButton-primaryNegative": {
background: theme.palette.vars.negativeBackgroundDefault,
- },
- },
- // Negative color — outlined
- "&.MuiButton-outlinedNegative": {
- border: `2px solid ${theme.palette.vars.negativeBorderDefault}`,
- background: "none",
- color: theme.palette.vars.negativeBackgroundActive,
- "&:hover": {
- border: `2px solid ${theme.palette.vars.negativeBackgroundHover}`,
- color: theme.palette.vars.negativeBackgroundHover,
- },
- "&:active": {
- border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`,
- color: theme.palette.vars.negativeBackgroundActive,
- },
- "&:focus-visible": {
- outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
- outlineOffset: "2px",
- color: theme.palette.vars.negativeBackgroundActive,
- border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`,
- },
- "&.Mui-disabled": {
- border: `2px solid ${theme.palette.vars.negativeBackgroundDisabled}`,
- color: theme.palette.vars.negativeBackgroundDisabled,
- opacity: 0.35,
- },
- "&.MuiButton-loading": {
- opacity: 1,
+ "&.Mui-disabled": {
+ opacity: 0.35,
+ background: theme.palette.vars.negativeBackgroundDisabled,
+ color: theme.palette.vars.negativeTextInDefault,
+ },
+ "&:hover": {
+ color: theme.palette.vars.baseTextInverse,
+ background: theme.palette.vars.negativeBackgroundHover,
+ },
+ "&:active": {
+ background: theme.palette.vars.negativeBackgroundActive,
+ border: `1px solid ${theme.palette.vars.negativeBorderDefault}`,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ color: theme.palette.vars.baseTextInverse,
+ background: theme.palette.vars.negativeBackgroundDefault,
+ },
+ },
+ // Negative color — outlined
+ "&.MuiButton-outlinedNegative": {
border: `2px solid ${theme.palette.vars.negativeBorderDefault}`,
+ background: "none",
color: theme.palette.vars.negativeBackgroundActive,
- },
- },
- // Negative color — tertiary
- "&.MuiButton-tertariaryNegative": {
- background: "none",
- color: theme.palette.vars.negativeTextDefault,
- "&:hover": {
- color: theme.palette.vars.negativeBackgroundHover,
- },
- "&:active": {
- color: theme.palette.vars.negativeBackgroundActive,
- },
- "&:focus-visible": {
- outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
- outlineOffset: "2px",
- },
- "&.Mui-disabled": {
- color: theme.palette.vars.negativeBackgroundDisabled,
- },
- "&.MuiButton-loading": {
- opacity: 1,
+ "&:hover": {
+ border: `2px solid ${theme.palette.vars.negativeBackgroundHover}`,
+ color: theme.palette.vars.negativeBackgroundHover,
+ },
+ "&:active": {
+ border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`,
+ color: theme.palette.vars.negativeBackgroundActive,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ color: theme.palette.vars.negativeBackgroundActive,
+ border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`,
+ },
+ "&.Mui-disabled": {
+ border: `2px solid ${theme.palette.vars.negativeBackgroundDisabled}`,
+ color: theme.palette.vars.negativeBackgroundDisabled,
+ opacity: 0.35,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ border: `2px solid ${theme.palette.vars.negativeBorderDefault}`,
+ color: theme.palette.vars.negativeBackgroundActive,
+ },
+ },
+ // Negative color — tertiary
+ "&.MuiButton-tertariaryNegative": {
+ background: "none",
color: theme.palette.vars.negativeTextDefault,
- },
- },
-})) as ComponentType;
+ "&:hover": {
+ color: theme.palette.vars.negativeBackgroundHover,
+ },
+ "&:active": {
+ color: theme.palette.vars.negativeBackgroundActive,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.vars.excellentBorderActive}`,
+ outlineOffset: "2px",
+ },
+ "&.Mui-disabled": {
+ color: theme.palette.vars.negativeBackgroundDisabled,
+ },
+ "&.MuiButton-loading": {
+ opacity: 1,
+ color: theme.palette.vars.negativeTextDefault,
+ },
+ },
+ };
+}) as ComponentType;
diff --git a/packages/open-ui-kit/src/components/button/stories/button.stories.tsx b/packages/open-ui-kit/src/components/button/stories/button.stories.tsx
index 35eeefbc..0d2bd1f0 100644
--- a/packages/open-ui-kit/src/components/button/stories/button.stories.tsx
+++ b/packages/open-ui-kit/src/components/button/stories/button.stories.tsx
@@ -53,7 +53,14 @@ const meta: Meta = {
},
variant: {
control: "radio",
- options: ["primary", "secondary", "outlined", "tertariary"],
+ options: [
+ "primary",
+ "secondary",
+ "gradient",
+ "outlined",
+ "gradientOutlined",
+ "tertariary",
+ ],
},
},
decorators: [
@@ -241,7 +248,9 @@ const sizes = ["large", "medium", "small"] as const;
const variants: Array<{ label: string; variant: ButtonVariant }> = [
{ label: "Primary", variant: "primary" },
{ label: "Secondary", variant: "secondary" },
+ { label: "Gradient", variant: "gradient" },
{ label: "Outlined", variant: "outlined" },
+ { label: "Gradient Outlined", variant: "gradientOutlined" },
{ label: "Tertiary", variant: "tertariary" },
];
@@ -258,6 +267,14 @@ export const Secondary: Story = {
render: (args) => ,
};
+export const Gradient: Story = {
+ args: {
+ ...defaultArgs,
+ variant: "gradient",
+ },
+ render: (args) => ,
+};
+
export const Outlined: Story = {
args: {
...defaultArgs,
@@ -266,6 +283,14 @@ export const Outlined: Story = {
render: (args) => ,
};
+export const GradientOutlined: Story = {
+ args: {
+ ...defaultArgs,
+ variant: "gradientOutlined",
+ },
+ render: (args) => ,
+};
+
export const Tertiary: Story = {
args: {
...defaultArgs,
@@ -334,6 +359,19 @@ export const IconOnly: Story = {
render: (args) => ,
};
+/**
+ * Icon-only carries its own gradient treatment
+ */
+export const IconButtonAI: Story = {
+ name: "Icon Button AI",
+ args: {
+ ...defaultArgs,
+ children: ,
+ variant: "gradient",
+ },
+ render: (args) => ,
+};
+
export const Disabled: Story = {
args: {
...defaultArgs,
diff --git a/packages/open-ui-kit/src/components/button/types/index.ts b/packages/open-ui-kit/src/components/button/types/index.ts
index 539ce7d0..ccef06c8 100644
--- a/packages/open-ui-kit/src/components/button/types/index.ts
+++ b/packages/open-ui-kit/src/components/button/types/index.ts
@@ -9,7 +9,7 @@ import type { ButtonProps as MuiButtonProps } from "@mui/material";
export interface ButtonProps extends Omit {
/** Button label, icon, or composed content rendered inside the control. */
children?: MuiButtonProps["children"];
- /** Visual style for the action: primary, secondary, outlined, or tertariary. */
+ /** Visual style for the action: primary, secondary, gradient, outlined, gradientOutlined, or tertariary. */
variant?: MuiButtonProps["variant"];
/** Button scale. Large min height is 40px, medium is 32px, and small is 24px. Width grows with label text until constrained, then height grows. */
size?: MuiButtonProps["size"];
diff --git a/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx b/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx
index c99ba830..9abafea1 100644
--- a/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx
+++ b/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx
@@ -7,6 +7,7 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
+import type { Theme } from "@mui/material/styles";
import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider";
import { darkTheme } from "@/theme/dark/dark-theme";
import { lightTheme } from "@/theme/light/light-theme";
@@ -17,14 +18,32 @@ import {
CardActions,
CardActionArea,
} from "../components/card";
+import CardAlertHeader from "../components/card-alert-header";
import CardDescription from "../components/card-description";
import CardSubheader from "../components/card-subheader";
import {
cardActiveStyles,
+ cardAlertAccent,
+ cardAlertAccentVar,
+ cardAlertStyles,
+ cardConnectorStyles,
cardDisabledStyles,
+ cardGlassStyles,
+ cardGlowStyles,
+ cardImageSideFade,
+ cardImageStyles,
cardInteractiveStyles,
cardRootStyles,
} from "../styles";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
+import {
+ cardAlertShadow,
+ cardConnectorBlur,
+ cardConnectorShadow,
+ cardGlassBlur,
+ cardGlassShadow,
+ cardInsightGlow,
+} from "@/theme/style/color-palette";
const renderCard = (ui: React.ReactElement, dark = false) =>
render(
@@ -297,4 +316,471 @@ describe("Card", () => {
).not.toThrow();
});
});
+
+ // Figma: `Card/Basic Interactive` (274405:44327), border token
+ // `Gradient/Panel-Exec-Border`.
+ describe("glow treatment", () => {
+ it("paints the border as a mask-composite ring, not a border property", () => {
+ const styles = cardGlowStyles(midnightTheme);
+ const ring = styles["&::before"] as Record;
+
+ // A gradient cannot be a border-color, so the border must be removed and
+ // the ramp drawn by the ring instead.
+ expect(styles.border).toBe("none");
+ expect(ring.background).toBe(
+ midnightTheme.palette.gradients.gradientPanelExecBorder,
+ );
+ expect(ring.padding).toBe("1px");
+ expect(ring.maskComposite).toBe("exclude");
+ // Must follow the card's 8px radius rather than a hardcoded value.
+ expect(ring.borderRadius).toBe("inherit");
+ // Must not sit above the card's non-positioned children.
+ expect(ring.zIndex).toBe(0);
+ expect(ring.pointerEvents).toBe("none");
+ });
+
+ it("applies the blue glow shadow", () => {
+ expect(cardGlowStyles(midnightTheme).boxShadow).toBe(cardInsightGlow);
+ expect(cardInsightGlow).toBe("0px 4px 17px rgba(10, 96, 255, 0.4)");
+ });
+
+ it("resolves the border to the measured diagonal ramp", () => {
+ // Sampling the rendered Figma card showed a 135deg diagonal, despite the
+ // swatch being labelled "Radial".
+ const border = midnightTheme.palette.gradients.gradientPanelExecBorder;
+ expect(border).toContain("linear-gradient(135deg");
+ expect(border).not.toContain("radial-gradient");
+ });
+
+ it("renders without throwing and keeps `glow` off the DOM node", () => {
+ expect(() =>
+ renderCard(
+
+
+ ,
+ ),
+ ).not.toThrow();
+
+ expect(screen.getByTestId("glow-card")).not.toHaveAttribute("glow");
+ });
+ });
+
+ // Figma: `Alerts Card` (274421:47415) — critical (274421:47325) and warning
+ // (274421:47332). That group is scaled 0.869x, so every measurement below is
+ // the reported value divided through.
+ describe("alert treatment", () => {
+ it("shares one surface and geometry across both severities", () => {
+ for (const severity of ["critical", "warning"] as const) {
+ const styles = cardAlertStyles(midnightTheme, severity);
+
+ expect(styles.background).toBe("rgba(255, 255, 255, 0.05)");
+ expect(styles.borderRadius).toBe("24px");
+ expect(styles.padding).toBe("20px");
+ expect(styles.gap).toBe("4px");
+ expect(styles.boxShadow).toBe(cardAlertShadow);
+ // A rounded overflow clip is antialiased at the corners and eats into
+ // the gradient ring's own antialiasing, thinning the arcs. Nothing in
+ // an alert card overflows, so the clip is left off.
+ expect(styles.overflow).toBeUndefined();
+ }
+
+ // It must differ from the default card, not just repeat it.
+ expect(cardRootStyles(midnightTheme).borderRadius).toBe("8px");
+ });
+
+ it("gives only critical the rainbow gradient ring", () => {
+ const critical = cardAlertStyles(midnightTheme, "critical");
+ const warning = cardAlertStyles(midnightTheme, "warning");
+ const ring = critical["&::before"] as Record;
+
+ // The swatch is labelled `Global-Border/Fade`, but its stops are the
+ // rainbow ramp — see the note in `midnight-gradient-vars.ts`.
+ expect(ring.background).toBe(
+ midnightTheme.palette.gradients.gradientGlobalBorderRainbow,
+ );
+ expect(critical.border).toBe("none");
+ // The frame's card is mirrored, so the ramp renders orange -> blue while
+ // the token is authored blue -> orange. Sampled at the rendered top edge.
+ expect(ring.transform).toBe("scaleX(-1)");
+ expect(ring.padding).toBe("1px");
+ expect(ring.maskComposite).toBe("exclude");
+ expect(ring.borderRadius).toBe("inherit");
+ expect(ring.zIndex).toBe(0);
+
+ // The design context for the warning card reports no border at all.
+ expect(warning["&::before"]).toBeUndefined();
+ expect(warning.border).toBeUndefined();
+ });
+
+ it("resolves the ring to the four stops the swatch documents", () => {
+ const ramp = midnightTheme.palette.gradients.gradientGlobalBorderRainbow;
+
+ expect(ramp).toContain("#0a60ff");
+ expect(ramp).toContain("#02c8ff");
+ expect(ramp).toContain("#ff007f");
+ expect(ramp).toContain("#ff9000");
+ });
+
+ it("publishes the accent colour for CardAlertHeader to read", () => {
+ expect(cardAlertAccent("critical")).toBe("#eb4651");
+ expect(cardAlertAccent("warning")).toBe("#ffae4c");
+
+ // Passed down as a custom property so the severity is declared once, on
+ // the card, rather than repeated on the header.
+ expect(
+ cardAlertStyles(midnightTheme, "critical")[cardAlertAccentVar],
+ ).toBe("#eb4651");
+ });
+
+ it("renders the meta row and keeps `alert` off the DOM node", () => {
+ renderCard(
+
+ CRITICAL ALERT
+
+ ,
+ );
+
+ const card = screen.getByTestId("alert-card");
+ expect(card).not.toHaveAttribute("alert");
+ expect(card).toHaveAttribute("data-card-alert", "critical");
+ expect(screen.getByText("CRITICAL ALERT")).toBeInTheDocument();
+ expect(screen.getByText("4m ago")).toBeInTheDocument();
+ });
+
+ it("omits the timestamp when none is given", () => {
+ renderCard(
+
+ WARNING
+ ,
+ );
+
+ expect(screen.getByText("WARNING")).toBeInTheDocument();
+ expect(screen.queryByText("3h ago")).not.toBeInTheDocument();
+ });
+
+ it("does not mark cards without an alert", () => {
+ renderCard(Content);
+
+ expect(screen.getByTestId("plain")).not.toHaveAttribute(
+ "data-card-alert",
+ );
+ });
+ });
+
+ // Figma: `Section 3` (274455:54313). The card there exports as one SVG whose
+ // defs carry all three `Graph-Connector` gradients verbatim, so these are
+ // exact rather than sampled.
+ describe("connector treatment", () => {
+ it("stacks the glow over the fill, with the glow painted first", () => {
+ const styles = cardConnectorStyles(midnightTheme);
+ const g = midnightTheme.palette.gradients;
+
+ // CSS paints the first background layer on top, matching the export's
+ // paint order: linear fill, then the radial glow above it.
+ expect(styles.background).toBe(
+ `${g.gradientGraphConnectorGlow}, ${g.gradientGraphConnectorFill}`,
+ );
+ expect(styles.backdropFilter).toBe(`blur(${cardConnectorBlur})`);
+ expect(styles.boxShadow).toBe(cardConnectorShadow);
+ // The export's path turns its corner over 6px in both axes.
+ expect(styles.borderRadius).toBe("6px");
+ });
+
+ it("anchors the glow at the bottom edge of the card", () => {
+ // translate(107.5 211) on a 215x207 path = 50% 100%; the -90deg rotated
+ // (176.14, 182.947) scale gives 85% radii in both axes.
+ expect(midnightTheme.palette.gradients.gradientGraphConnectorGlow).toBe(
+ "radial-gradient(85% 85% at 50% 100%, rgba(199, 211, 234, 0.064) 0%, rgba(199, 211, 234, 0.008) 100%)",
+ );
+ });
+
+ it("uses the card-accurate fill angle, not the swatch reading", () => {
+ // The export's fill vector (0,4) -> (116.6,256.4) is 155.21deg. Figma
+ // normalises gradient transforms to the layer box, so the 120x60 swatch
+ // squeezed the same token to the 166.51deg previously recorded.
+ const fill = midnightTheme.palette.gradients.gradientGraphConnectorFill;
+
+ expect(fill).toContain("linear-gradient(155.21deg");
+ expect(fill).not.toContain("166.51deg");
+ // 0.16 fill-opacity baked in: 0.22 -> 0.035, 0.10 -> 0.016.
+ expect(fill).toContain("rgba(199, 211, 234, 0.035) 0%");
+ expect(fill).toContain("rgba(199, 211, 234, 0.016) 100%");
+ });
+
+ it("edges the card with the gradient stroke as a ring", () => {
+ const styles = cardConnectorStyles(midnightTheme);
+ const ring = styles["&::after"] as Record;
+
+ expect(styles.border).toBe("none");
+ expect(ring.background).toBe(
+ midnightTheme.palette.gradients.gradientGraphConnectorStroke,
+ );
+ expect(ring.padding).toBe("1px");
+ expect(ring.maskComposite).toBe("exclude");
+ // Same corner-thinning reason as the alert treatment.
+ expect(styles.overflow).toBeUndefined();
+ });
+
+ it("renders without throwing and keeps `connector` off the DOM node", () => {
+ expect(() =>
+ renderCard(
+
+
+ ,
+ ),
+ ).not.toThrow();
+
+ expect(screen.getByTestId("connector-card")).not.toHaveAttribute(
+ "connector",
+ );
+ });
+ });
+
+ // Figma: `Glass Card` (274490:55387). Every value below is read straight off
+ // the frame's SVG exports (nodes 274490:55140 and 274490:55139), at the
+ // mockup's 0.8928 scale divided out — none of it is pixel-sampled.
+ describe("glass treatment", () => {
+ it("fills with the glass gradient token over a backdrop blur", () => {
+ const styles = cardGlassStyles(midnightTheme);
+
+ expect(styles.background).toBe(
+ midnightTheme.palette.gradients.gradientCardGlassBg,
+ );
+ // Without the blur it is a flat translucent panel, not glass. The 40px
+ // comes from the export's own `backdrop-filter: blur(35.71px)` / 0.8928.
+ expect(styles.backdropFilter).toBe(`blur(${cardGlassBlur})`);
+ expect(cardGlassBlur).toBe("40px");
+ expect(styles.boxShadow).toBe(cardGlassShadow);
+ });
+
+ it("anchors the fill radial at the top-right corner, 40% -> 5% white", () => {
+ const fill = midnightTheme.palette.gradients.gradientCardGlassBg;
+
+ expect(fill).toBe(
+ "radial-gradient(100% 100% at 100% 0%, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.05) 100%)",
+ );
+ });
+
+ it("layers the Glow-Teal flair behind the content", () => {
+ const styles = cardGlassStyles(midnightTheme);
+ const flair = styles["&::before"] as Record;
+
+ expect(flair.background).toBe(
+ midnightTheme.palette.gradients.gradientDashboardCardFillCyanPurple,
+ );
+ // The token itself: cyan 37% at the top of the streak, periwinkle 73%
+ // at the bottom, straight from the export's gradient def. The -83.247%
+ // anchor is not decoration: Figma defines the ramp over the layer box
+ // (y -19.1782 -> 102.235) but only paints the crescent inside it
+ // (y 35.9785 -> 102.235), so the visible top edge is already 45.429% of
+ // the way to periwinkle and never shows pure cyan.
+ expect(midnightTheme.palette.gradients.gradientDashboardCardFillCyanPurple).toBe(
+ "linear-gradient(180deg, rgba(0, 187, 255, 0.37) -83.247%, rgba(161, 166, 254, 0.73) 100%)",
+ );
+ // A bottom-anchored crescent: flat along the bottom edge, arcing up
+ // mid-card and falling toward both ends — the dome comes from the
+ // elliptical top radii. Peak and bleed are solved from the export path's
+ // cubic (t=0.50983 -> y=35.9785) against the 188-tall card.
+ expect(flair.inset).toBe("66.111% 0% -1.354% 0%");
+ expect(flair.borderRadius).toBe("50% 50% 0 0 / 100% 100% 0 0");
+ // The flair's OWN layer blur only, from the export's
+ // `feGaussianBlur stdDeviation="17.9891"` / 0.8928 = 20.15 — Figma
+ // reports the layer-blur radius as 35.978 and halves it on the way out,
+ // exactly as it does for the backdrop blur.
+ //
+ // The surface's 40px backdrop blur is deliberately NOT folded in here.
+ // In Figma that pass runs after the flair is composited over an opaque
+ // backdrop, so it softens edges; applying it to the still-translucent
+ // flair instead spreads alpha and drains the glow, since a ~45px blur
+ // is comparable to the ~66px-tall crescent's own height.
+ expect(flair.filter).toBe("blur(20.15px)");
+ // The group opacity, on top of the alpha already in the stops.
+ expect(flair.opacity).toBe(0.73);
+ expect(flair.pointerEvents).toBe("none");
+ expect(styles["& > *"]).toEqual({ position: "relative", zIndex: 1 });
+ });
+
+ it("draws the hairline as a ring that fades out at the bottom", () => {
+ const styles = cardGlassStyles(midnightTheme);
+ const ring = styles["&::after"] as Record;
+
+ // The border is a vertical ramp now, so it cannot be a `border-color`.
+ expect(styles.border).toBe("none");
+ expect(ring.background).toBe(
+ midnightTheme.palette.gradients.gradientCardGlassBorder,
+ );
+ expect(midnightTheme.palette.gradients.gradientCardGlassBorder).toBe(
+ "linear-gradient(180deg, rgba(255, 255, 255, 0.3) 10%, rgba(241, 241, 241, 0.3) 75%, rgba(153, 153, 153, 0) 100%)",
+ );
+ expect(ring.padding).toBe("1px");
+ expect(ring.maskComposite).toBe("exclude");
+ expect(ring.borderRadius).toBe("inherit");
+ // SVG path outer corner radius 19.64 / 0.8928.
+ expect(styles.borderRadius).toBe("22px");
+ });
+
+ it("renders without throwing and keeps `glass` off the DOM node", () => {
+ expect(() =>
+ renderCard(
+
+
+ ,
+ ),
+ ).not.toThrow();
+
+ expect(screen.getByTestId("glass-card")).not.toHaveAttribute("glass");
+ });
+ });
+
+ // Figma: `Welcome Card` (274405:44234) in the `Card with image` frame
+ // (274417:44476). Fill tokens `Gradient/Welcome-Card-BG-Dark` and
+ // `Gradient/Overlay-Black-Fade-In`.
+ describe("image treatment", () => {
+ const image = "/assets/img.png";
+
+ it("layers the photo and the scrim as separate pseudo-elements", () => {
+ const styles = cardImageStyles(midnightTheme, image);
+ const photo = styles["&::before"] as Record;
+ const scrim = styles["&::after"] as Record;
+
+ // The base gradient is the card's own background; the photo sits above it
+ // at half strength, and the scrim above that.
+ expect(styles.background).toBe(
+ midnightTheme.palette.gradients.gradientWelcomeCardBgDark,
+ );
+ expect(photo.backgroundImage).toBe(`url("${image}")`);
+ expect(photo.backgroundSize).toBe("cover");
+ // CSS cannot set per-layer opacity, which is why these are two elements.
+ expect(photo.opacity).toBe(0.5);
+ // Two scrims, vertical painted over horizontal.
+ expect(scrim.background).toBe(
+ `${midnightTheme.palette.gradients.gradientOverlayBlackFadeIn}, ${cardImageSideFade}`,
+ );
+
+ // Neither layer may swallow clicks or paint over the content.
+ expect(photo.pointerEvents).toBe("none");
+ expect(scrim.pointerEvents).toBe("none");
+ expect(photo.zIndex).toBe(0);
+ expect(scrim.zIndex).toBe(0);
+ expect(styles["& > *"]).toEqual({ position: "relative", zIndex: 1 });
+ });
+
+ it("carries the larger geometry the design uses for this surface", () => {
+ const styles = cardImageStyles(midnightTheme, image);
+
+ expect(styles.borderRadius).toBe("20px");
+ expect(styles.padding).toBe("24px");
+ expect(styles.gap).toBe("16px");
+ // Required so the two layers are clipped to the 20px radius.
+ expect(styles.overflow).toBe("hidden");
+ expect(styles.backdropFilter).toBe("blur(60px)");
+
+ // It must differ from the default card, not just repeat it.
+ expect(cardRootStyles(midnightTheme).borderRadius).toBe("8px");
+ expect(cardRootStyles(midnightTheme).padding).toBe("16px");
+ });
+
+ // Figma `Rectangle 10` (274405:44237). It exports as SVG, so it never
+ // appears in the generated design context and has to be read off that
+ // export: stops at 25.15% and 50.81% of the card width.
+ it("fades the surface out horizontally by the midpoint", () => {
+ expect(cardImageSideFade).toContain("linear-gradient(90deg");
+ expect(cardImageSideFade).toContain("#060b26 25.15%");
+ expect(cardImageSideFade).toContain("rgba(6, 11, 38, 0) 50.81%");
+
+ // It ramps out of the same colour the base gradient starts from, so the
+ // left of the card reads as one flat surface.
+ expect(
+ midnightTheme.palette.gradients.gradientWelcomeCardBgDark,
+ ).toContain("#060b26");
+ });
+
+ it("resolves the fills to the Figma gradient tokens in Midnight", () => {
+ const g = midnightTheme.palette.gradients;
+
+ expect(g.gradientOverlayBlackFadeIn).toBe(
+ "linear-gradient(180deg, rgba(0, 0, 0, 0.65) 52.404%, rgba(102, 102, 102, 0) 100%)",
+ );
+ expect(g.gradientWelcomeCardBgDark).toContain("#060b26 64.87%");
+ });
+
+ it("keeps `image` off the DOM node and marks the card for text inheritance", () => {
+ expect(() =>
+ renderCard(
+
+
+ ,
+ ),
+ ).not.toThrow();
+
+ const card = screen.getByTestId("image-card");
+ expect(card).not.toHaveAttribute("image");
+ expect(card).toHaveAttribute("data-card-image", "true");
+ });
+
+ it("does not mark cards without an image", () => {
+ renderCard(Content);
+
+ expect(screen.getByTestId("plain-card")).not.toHaveAttribute(
+ "data-card-image",
+ );
+ });
+
+ /*
+ * Tertiary actions on this surface read as text over the photo scrim, so
+ * they take the Interactive/Text In ramp rather than the variant's
+ * Interactive/Primary blue, which the scrim leaves sitting too dark.
+ */
+ describe("tertiary action colour", () => {
+ const tertiaryRule = (theme: Theme) =>
+ cardImageStyles(theme, image)[
+ "& .MuiButton-root.MuiButton-tertariary"
+ ] as Record | string>;
+
+ it.each([
+ ["midnight", midnightTheme],
+ ["light", lightTheme],
+ ["dark", darkTheme],
+ ])("puts the whole %s ramp on Interactive/Text In", (_mode, theme) => {
+ const rule = tertiaryRule(theme);
+ const { vars } = theme.palette;
+
+ expect(rule.color).toBe(vars.interactiveTextInDefault);
+ expect(rule["&:hover"]).toEqual({
+ color: vars.interactiveTextInHover,
+ });
+ expect(rule["&:active"]).toEqual({
+ color: vars.interactiveTextInActive,
+ });
+ });
+
+ it("moves every state off the variant's Interactive/Primary blue", () => {
+ const rule = tertiaryRule(midnightTheme);
+ const { vars } = midnightTheme.palette;
+
+ // Leaving hover or active behind would make the button jump colour
+ // ramps mid-interaction.
+ expect(rule.color).not.toBe(vars.interactivePrimaryDefaultDefault);
+ expect(rule["&:hover"]).not.toEqual({
+ color: vars.interactivePrimaryDefaultHover,
+ });
+ expect(rule["&:active"]).not.toEqual({
+ color: vars.interactivePrimaryDefaultActive,
+ });
+ });
+
+ it("names the button root so the rule outweighs the variant's own", () => {
+ const selectors = Object.keys(cardImageStyles(midnightTheme, image));
+
+ /*
+ * The variant styles itself with `&.MuiButton-tertariary` — the styled
+ * class plus the variant class, two classes. A descendant selector
+ * naming only the variant class would tie, leaving the winner to
+ * emotion's injection order; `.MuiButton-root` takes this to three.
+ */
+ expect(selectors).toContain("& .MuiButton-root.MuiButton-tertariary");
+ expect(selectors).not.toContain("& .MuiButton-tertariary");
+ });
+ });
+ });
});
diff --git a/packages/open-ui-kit/src/components/card/components/card-alert-header.tsx b/packages/open-ui-kit/src/components/card/components/card-alert-header.tsx
new file mode 100644
index 00000000..05a54a68
--- /dev/null
+++ b/packages/open-ui-kit/src/components/card/components/card-alert-header.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Typography } from "@mui/material";
+import type { CardAlertHeaderProps } from "../types";
+import { cardAlertAccentVar } from "../styles";
+import { StyledCardAlertHeader } from "./elements";
+
+/**
+ * Meta row for an alert card: severity label on the left, timestamp on the
+ * right. Figma: `Alerts Card` (274421:47415).
+ *
+ * The label colour comes from the `--card-alert-accent` custom property that
+ * `cardAlertStyles` sets on the parent card, so the severity is declared once
+ * on `` rather than threaded through here as a second prop.
+ */
+const CardAlertHeader = ({
+ children,
+ timestamp,
+ ...props
+}: CardAlertHeaderProps) => (
+
+
+ {children}
+
+ {timestamp ? (
+ ({ color: theme.palette.vars.baseTextWeak })}
+ >
+ {timestamp}
+
+ ) : null}
+
+);
+
+export default CardAlertHeader;
diff --git a/packages/open-ui-kit/src/components/card/components/card.tsx b/packages/open-ui-kit/src/components/card/components/card.tsx
index 25587cc9..41637316 100644
--- a/packages/open-ui-kit/src/components/card/components/card.tsx
+++ b/packages/open-ui-kit/src/components/card/components/card.tsx
@@ -24,11 +24,24 @@ const mergeSx = (sx: SxProps | undefined): SxProps => [
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
];
-export const Card = ({ disabled = false, sx, ...props }: CardProps) => (
+export const Card = ({
+ alert,
+ disabled = false,
+ image,
+ sx,
+ ...props
+}: CardProps) => (
);
diff --git a/packages/open-ui-kit/src/components/card/components/elements.tsx b/packages/open-ui-kit/src/components/card/components/elements.tsx
index 8cb7fe39..cf87b6d7 100644
--- a/packages/open-ui-kit/src/components/card/components/elements.tsx
+++ b/packages/open-ui-kit/src/components/card/components/elements.tsx
@@ -16,26 +16,75 @@ import {
CardContentProps as MuiCardContentProps,
CardActions as MuiCardActions,
CardActionsProps as MuiCardActionsProps,
+ Stack,
+ StackProps,
Typography,
TypographyProps,
styled,
} from "@mui/material";
+import type { CardAlertSeverity } from "../types";
import {
cardActiveStyles,
+ cardAlertStyles,
+ cardConnectorStyles,
cardDisabledStyles,
+ cardGlassStyles,
+ cardGlowStyles,
+ cardImageStyles,
cardInteractiveStyles,
cardRootStyles,
} from "../styles";
export const StyledCard = styled(MuiCard, {
- shouldForwardProp: (prop) => prop !== "disabled",
-})<{ disabled?: boolean }>(({ theme }) => ({
+ shouldForwardProp: (prop) =>
+ prop !== "alert" &&
+ prop !== "connector" &&
+ prop !== "disabled" &&
+ prop !== "glass" &&
+ prop !== "glow" &&
+ prop !== "image",
+})<{
+ alert?: CardAlertSeverity;
+ connector?: boolean;
+ disabled?: boolean;
+ glass?: boolean;
+ glow?: boolean;
+ image?: string;
+}>(({ theme, alert, connector, glass, glow, image }) => ({
...cardRootStyles(theme),
- "&:hover": {
- backgroundColor: theme.palette.vars.baseBackgroundWeak,
- },
+ // The decorative treatments are documented as mutually exclusive. They are
+ // still applied in a fixed order so a card that sets more than one gets a
+ // predictable result rather than whichever spread happened to land last.
+ ...(image ? cardImageStyles(theme, image) : {}),
+ ...(glass ? cardGlassStyles(theme) : {}),
+ ...(connector ? cardConnectorStyles(theme) : {}),
+ ...(alert ? cardAlertStyles(theme, alert) : {}),
+ ...(glow ? cardGlowStyles(theme) : {}),
+ // Hover deliberately leaves the background alone. The interactive card gets
+ // its hover feedback from the `controlBorderActive` border that
+ // `StyledCardActionArea` applies, and the decorative treatments paint
+ // translucent or gradient backgrounds that a hover colour would show through.
'&[aria-disabled="true"]': cardDisabledStyles(theme),
-})) as ComponentType;
+})) as ComponentType<
+ MuiCardProps & {
+ alert?: CardAlertSeverity;
+ connector?: boolean;
+ disabled?: boolean;
+ glass?: boolean;
+ glow?: boolean;
+ image?: string;
+ }
+>;
+
+// Meta row at the top of an alert card: severity label left, timestamp right.
+// Figma: `Alerts Card` (274421:47415).
+export const StyledCardAlertHeader = styled(Stack)(() => ({
+ alignItems: "center",
+ alignSelf: "stretch",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ width: "100%",
+})) as ComponentType;
export const StyledCardActionArea = styled(MuiCardActionArea)(({ theme }) => ({
borderRadius: "8px",
@@ -73,6 +122,10 @@ export const StyledCardHeader = styled(MuiCardHeader)(({ theme }) => ({
...theme.typography.captionMedium,
color: theme.palette.vars.baseTextMedium,
},
+ '[data-card-image="true"] & .MuiCardHeader-title, [data-card-image="true"] & .MuiCardHeader-subheader':
+ {
+ color: "inherit",
+ },
'[aria-disabled="true"] & .MuiCardHeader-title, [aria-disabled="true"] & .MuiCardHeader-subheader':
{
color: theme.palette.vars.baseTextDisabled,
@@ -92,6 +145,9 @@ export const StyledCardActions = styled(MuiCardActions)(() => ({
export const StyledCardDescription = styled(Typography)(({ theme }) => ({
color: theme.palette.vars.baseTextDefault,
+ '[data-card-image="true"] &': {
+ color: "inherit",
+ },
'[aria-disabled="true"] &': {
color: theme.palette.vars.baseTextDisabled,
},
@@ -99,6 +155,9 @@ export const StyledCardDescription = styled(Typography)(({ theme }) => ({
export const StyledCardSubheader = styled(Typography)(({ theme }) => ({
color: theme.palette.vars.baseTextMedium,
+ '[data-card-image="true"] &': {
+ color: "inherit",
+ },
'[aria-disabled="true"] &': {
color: theme.palette.vars.baseTextDisabled,
},
diff --git a/packages/open-ui-kit/src/components/card/index.ts b/packages/open-ui-kit/src/components/card/index.ts
index dc7969a4..e32ecefe 100644
--- a/packages/open-ui-kit/src/components/card/index.ts
+++ b/packages/open-ui-kit/src/components/card/index.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
+export { default as CardAlertHeader } from "./components/card-alert-header";
export { default as CardDescription } from "./components/card-description";
export { default as CardSubheader } from "./components/card-subheader";
export {
@@ -16,6 +17,8 @@ export {
export type {
CardActionAreaProps,
CardActionsProps,
+ CardAlertHeaderProps,
+ CardAlertSeverity,
CardContentProps,
CardDescriptionProps,
CardHeaderProps,
diff --git a/packages/open-ui-kit/src/components/card/stories/card.stories.tsx b/packages/open-ui-kit/src/components/card/stories/card.stories.tsx
index fecd52fe..1e05536f 100644
--- a/packages/open-ui-kit/src/components/card/stories/card.stories.tsx
+++ b/packages/open-ui-kit/src/components/card/stories/card.stories.tsx
@@ -17,6 +17,7 @@ import {
Card,
CardActionArea,
CardActions,
+ CardAlertHeader,
CardContent,
CardDescription,
CardHeader,
@@ -48,10 +49,31 @@ const meta: Meta = {
disabled: false,
},
argTypes: {
+ alert: {
+ control: "select",
+ options: [undefined, "warning", "critical"],
+ description:
+ "Applies the alert treatment. `critical` adds the rainbow gradient border; `warning` has no border.",
+ },
disabled: {
control: "boolean",
description: "Applies the disabled card treatment.",
},
+ connector: {
+ control: "boolean",
+ description:
+ "Applies the graph-connector treatment: fill and glow gradients over a backdrop blur, edged with the matching gradient stroke.",
+ },
+ glass: {
+ control: "boolean",
+ description:
+ "Applies the frosted-glass treatment. Needs imagery or a patterned surface behind it to refract.",
+ },
+ image: {
+ control: "text",
+ description:
+ "Background image URL. Layers the photo over `Gradient/Welcome-Card-BG-Dark` and under a `Gradient/Overlay-Black-Fade-In` scrim. Mutually exclusive with `glow`.",
+ },
sx: {
control: false,
},
@@ -77,6 +99,12 @@ type Story = StoryObj;
const cardWidth = 318;
const horizontalCardWidth = 820;
+const imageCardWidth = 432;
+const alertCardWidth = 435;
+// The Figma card is 215 wide; widened here so the title is not quite so ragged.
+const connectorCardWidth = 280;
+// Figma `Welcome Card` (274405:44234) hero, photo by Braden Collum (Unsplash).
+const cardImage = "/assets/welcome-card.jpg";
const CardStats = () => (
(
>
);
+const ConnectorCardContent = () => (
+ <>
+ ({ color: theme.palette.vars.baseTextMedium })}
+ >
+ Divergent Planning Paths
+
+
+
+
+ The Itinerary Planner and Schedule Planner increasingly disagree on the
+ ordering of the same set of activities.
+
+
+ >
+);
+
const ImportCardContent = () => (
<>
@@ -195,6 +241,96 @@ export const Interactive: Story = {
),
};
+/**
+ * The two gradient-bordered surfaces, side by side: `glow` draws the blue
+ * ring with its glow, `connector` the softer graph-canvas treatment.
+ */
+export const Glow: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+ ),
+};
+
+export const CriticalAlert: Story = {
+ args: {
+ alert: "critical",
+ sx: { width: alertCardWidth },
+ children: (
+ <>
+ CRITICAL ALERT
+
+
+
+ The system detected a revision loop during itinerary optimization
+ within a high-density semantic cluster (“Travel itineraries to
+ cities”). The agent initially produced an itinerary that violated
+ walking constraints, triggering an optimization cycle before
+ producing the final output.
+
+
+ >
+ ),
+ },
+};
+
+export const WarningAlert: Story = {
+ args: {
+ alert: "warning",
+ sx: { width: alertCardWidth },
+ children: (
+ <>
+ WARNING
+
+
+
+ Initial itinerary violated constraints and required a revision pass.
+ Consider improving constraint conditioning upstream.
+
+
+ >
+ ),
+ },
+};
+
+export const Glass: Story = {
+ args: {
+ children: ,
+ glass: true,
+ sx: { minHeight: 172, width: cardWidth },
+ },
+};
+
+export const WithImage: Story = {
+ args: {
+ image: cardImage,
+ sx: { minHeight: 172, width: imageCardWidth },
+ children: (
+ <>
+
+ Explain
+
+ Uncover the “why” behind your app’s behavior. Get
+ clear, AI-powered explanations for events, anomalies, or performance
+ changes.
+
+
+
+ } size="small" variant="tertariary">
+ Get Started
+
+
+ >
+ ),
+ },
+};
+
export const Active: Story = {
args: {
children: ,
diff --git a/packages/open-ui-kit/src/components/card/styles/index.ts b/packages/open-ui-kit/src/components/card/styles/index.ts
index 93bd37a8..39f01453 100644
--- a/packages/open-ui-kit/src/components/card/styles/index.ts
+++ b/packages/open-ui-kit/src/components/card/styles/index.ts
@@ -5,6 +5,19 @@
*/
import type { CSSObject, Theme } from "@mui/material/styles";
+import { alpha } from "@mui/material/styles";
+import type { CardAlertSeverity } from "../types";
+import {
+ alertCriticalText,
+ alertWarningText,
+ cardAlertShadow,
+ cardConnectorBlur,
+ cardConnectorShadow,
+ cardGlassBlur,
+ cardGlassShadow,
+ cardInsightGlow,
+ midnightGradientStops,
+} from "@/theme/style/color-palette";
export const cardRootStyles = (theme: Theme): CSSObject => ({
alignItems: "flex-start",
@@ -41,6 +54,370 @@ export const cardDisabledStyles = (theme: Theme): CSSObject => ({
},
});
+/**
+ * Glow treatment: gradient border + blue glow.
+ *
+ * Figma: `Card/Basic Interactive` (274405:44327), border token
+ * `Gradient/Panel-Exec-Border`.
+ *
+ * The border is a 1px gradient ring rather than a `border`, because a gradient
+ * cannot be assigned to `border-color` and `border-image` ignores
+ * `border-radius`. The mask-composite pseudo-element is the same technique used
+ * by the Button `gradientOutlined` variant; it follows the 8px radius and keeps
+ * the card's own background intact.
+ */
+export const cardGlowStyles = (theme: Theme): CSSObject => ({
+ position: "relative",
+ border: "none",
+ boxShadow: cardInsightGlow,
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "1px",
+ background: theme.palette.gradients.gradientPanelExecBorder,
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ // The ring is positioned, so without this it would paint above the card's
+ // non-positioned children.
+ zIndex: 0,
+ },
+});
+
+/** CSS custom property carrying the alert accent down to `CardAlertHeader`. */
+export const cardAlertAccentVar = "--card-alert-accent";
+
+/** Severity label colour. Figma: `Alerts Card` (274421:47415). */
+export const cardAlertAccent = (severity: CardAlertSeverity): string =>
+ severity === "critical" ? alertCriticalText : alertWarningText;
+
+/**
+ * Alert treatment.
+ *
+ * Figma: `Alerts Card` (274421:47415) — critical (274421:47325) and warning
+ * (274421:47332). That whole group is scaled 0.869x on the canvas, so every
+ * length the design context reports has been divided through: the 20.863px
+ * radius is 24px, 17.386px of padding is 20px, and so on.
+ *
+ * The two severities share one surface and differ in exactly two ways: critical
+ * carries the rainbow gradient border, and the accent colour the header picks
+ * up. Warning has no border at all — the design context for 274421:47332
+ * reports no border property, where 274421:47325 reports one.
+ *
+ * The border is a 1px mask-composite ring rather than a `border`, for the same
+ * reason as `cardGlowStyles`: a gradient cannot be assigned to
+ * `border-color`, and `border-image` ignores `border-radius`.
+ *
+ * The gradient itself needs no new token. The swatch in the frame is labelled
+ * `Gradient/Global-Border/Fade`, but its stops are #0a60ff -> #02c8ff ->
+ * #ff007f -> #ff9000 — the rainbow ramp already in the theme as
+ * `gradientGlobalBorderRainbow`, whose comment in `midnight-gradient-vars.ts`
+ * records this exact mislabelling.
+ */
+export const cardAlertStyles = (
+ theme: Theme,
+ severity: CardAlertSeverity,
+): CSSObject => ({
+ // The same white the glass fill ramps out of, so no new colour is introduced.
+ background: alpha(midnightGradientStops.glassWhite, 0.05),
+ borderRadius: "24px",
+ boxShadow: cardAlertShadow,
+ color: theme.palette.vars.baseTextStrong,
+ gap: "4px",
+ // Deliberately no `overflow: hidden`, even though Figma marks the frame
+ // `overflow-clip`. A rounded overflow clip is antialiased at the corners, and
+ // multiplying that partial alpha by the ring's own partial alpha at the same
+ // pixels roughly halves it — the arcs rendered at about 40% of the strength
+ // of the straight edges, which are pixel-aligned and so lose nothing. The
+ // card holds only text, so there is nothing to clip.
+ padding: "20px",
+ position: "relative",
+ [cardAlertAccentVar]: cardAlertAccent(severity),
+ // Inter Bold 20/30. No theme variant matches — `h6` is Sharp Sans at 20/28 —
+ // so the alert scopes its own title style rather than bending a shared one.
+ "& .MuiCardHeader-title": {
+ color: "inherit",
+ fontFamily: "Inter, sans-serif",
+ fontSize: "20px",
+ fontWeight: 700,
+ lineHeight: "30px",
+ },
+ ...(severity === "critical"
+ ? {
+ border: "none",
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "1px",
+ background: theme.palette.gradients.gradientGlobalBorderRainbow,
+ // The card in the frame is mirrored — every text node in it carries
+ // Figma's `rotate-180` flip artifact, which is also why the meta row
+ // comes last in the layer order but renders first. That flip reverses
+ // the border ramp too: sampling the rendered top edge gives orange at
+ // 5% across, pink at 16%, cyan at 67% and blue at 88%, an exact
+ // reversal of the token's blue -> cyan -> pink -> orange.
+ //
+ // Mirroring the ring reproduces that without forking the shared
+ // token, which other surfaces use in its canonical direction. The
+ // radius is uniform on all four corners, so the flip changes nothing
+ // but the ramp.
+ transform: "scaleX(-1)",
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ // The ring is positioned, so without this it would paint above the
+ // card's non-positioned children.
+ zIndex: 0,
+ },
+ }
+ : {}),
+});
+
+/**
+ * Graph-connector treatment.
+ *
+ * Figma: `Section 3` (274455:54313). The card there exports as a single SVG
+ * whose defs carry all three `Graph-Connector` gradients verbatim, so every
+ * value below is exact — geometry, alphas, blur and shadow alike.
+ *
+ * Two fills stack: the `Fill` linear ramp with the `Glow` radial painted over
+ * it, both already carrying the export's 0.16 `fill-opacity` in their tokens.
+ * CSS paints the first background layer on top, so the glow is listed first.
+ *
+ * The 1px stroke is a gradient, so it cannot be a `border-color`; it uses the
+ * same mask-composite ring as the other treatments, drawn on `::after` and
+ * with no `overflow: hidden` — see the alert treatment for why that clip
+ * thins the ring's corner arcs.
+ */
+export const cardConnectorStyles = (theme: Theme): CSSObject => ({
+ background: `${theme.palette.gradients.gradientGraphConnectorGlow}, ${theme.palette.gradients.gradientGraphConnectorFill}`,
+ backdropFilter: `blur(${cardConnectorBlur})`,
+ border: "none",
+ // The export's path turns its corner over 6px in both axes.
+ borderRadius: "6px",
+ boxShadow: cardConnectorShadow,
+ color: theme.palette.vars.baseTextStrong,
+ padding: "20px",
+ position: "relative",
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "1px",
+ background: theme.palette.gradients.gradientGraphConnectorStroke,
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ zIndex: 0,
+ },
+ "& > *": {
+ position: "relative",
+ zIndex: 1,
+ },
+});
+
+/**
+ * Frosted-glass treatment.
+ *
+ * Figma: `Glass Card` (274490:55387). Unlike the earlier revision of this
+ * frame, every layer here is EXACT — the card surface and its flair export as
+ * SVGs whose gradient defs, blur radii and corner radius can be read directly
+ * (all at the mockup's 0.8928 scale, divided out below and in the tokens).
+ *
+ * Layer stack, bottom to top:
+ * 1. `::before` — the `Dashboard-Card/Fill/Cyan-Purple` flair: a cyan-to-periwinkle
+ * crescent across the lower card, keeping the frame's own shape and its
+ * own 20.15px layer blur. In Figma it sits BEHIND the surface (flair z=2,
+ * glass surface z=3); a pseudo-element cannot get beneath its own
+ * element's background, so it paints above the fill instead. Near the
+ * card's bottom the radial is only ~5% white, so the inverted order costs
+ * little — and keeping the flair on its own element is what preserves the
+ * shape and blur.
+ * 2. The card's own background — the `Card-Glass-BG` radial, anchored at the
+ * top-right corner, over `backdrop-filter: blur(40px)`.
+ * 3. `::after` — the `Card-Glass-BORDER` hairline, a vertical ramp that fades
+ * out by the bottom edge. A gradient cannot be a `border-color`, so it is
+ * the same mask-composite ring as the `glow` treatment; drawn as
+ * `::after` so it paints above the flair.
+ *
+ * No `overflow: hidden`, for the same corner-thinning reason as the alert
+ * treatment — and the flair is designed to bleed past the card edge anyway.
+ */
+export const cardGlassStyles = (theme: Theme): CSSObject => ({
+ background: theme.palette.gradients.gradientCardGlassBg,
+ backdropFilter: `blur(${cardGlassBlur})`,
+ border: "none",
+ // SVG path outer corner radius 19.64 / 0.8928.
+ borderRadius: "22px",
+ boxShadow: cardGlassShadow,
+ color: theme.palette.vars.baseTextStrong,
+ position: "relative",
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ // The flair is a crescent, not a band: its flat bottom hugs the card's
+ // bottom edge (bleeding just past it) while its top edge arcs mid-card
+ // and falls toward both ends. A half-ellipse dome reproduces that arc;
+ // the blur softens it into the rendered glow.
+ //
+ // Solved off the export's own path rather than estimated. Its cubic
+ // `C327.375 20.2369, 172.023 18.837, 39.3263 83.4893` bottoms out at
+ // t = 0.50983, y = 35.9785, and the flat bottom sits at y = 102.235;
+ // mapped through the layer's render bounds against the 188-tall card,
+ // that is 66.111% down for the peak and 1.354% past the bottom edge.
+ // Full width, no horizontal bleed — the path spans the card's 434 exactly
+ // (433.882 of it, 0.03% short on the right).
+ inset: "66.111% 0% -1.354% 0%",
+ borderRadius: "50% 50% 0 0 / 100% 100% 0 0",
+ background: theme.palette.gradients.gradientDashboardCardFillCyanPurple,
+ // The layer's OWN blur, and only that: the export carries
+ // `feGaussianBlur stdDeviation="17.9891"` — half the 35.978 layer-blur
+ // radius Figma reports, the same halving the backdrop blur goes through —
+ // and 17.9891 / 0.8928 = 20.15px.
+ //
+ // Do NOT fold the surface's 40px backdrop blur in on top. In Figma the
+ // flair sits behind the surface, so the order is `blur(20.15) -> composite
+ // over an opaque backdrop -> blur(40)`, and that second pass only softens
+ // edges. Composing them into sqrt(20.15^2 + 40^2) = 44.788 instead blurs
+ // the flair while it is still translucent, spreading its ALPHA rather than
+ // its colour — and against a crescent only ~66px tall that drains the glow.
+ filter: "blur(20.15px)",
+ opacity: 0.73,
+ pointerEvents: "none",
+ zIndex: 0,
+ },
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "1px",
+ background: theme.palette.gradients.gradientCardGlassBorder,
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ zIndex: 0,
+ },
+ // Both layers are positioned; keep the card's content above them.
+ "& > *": {
+ position: "relative",
+ zIndex: 1,
+ },
+});
+
+/**
+ * Left-to-right fade across the photo — Figma `Rectangle 10` (274405:44237).
+ *
+ * This layer is missing from every `get_design_context` response for the card,
+ * because it exports as an SVG rather than as CSS. Read off that export, the
+ * ramp is horizontal (a 3deg tilt, dropped here) and its stops sit at 25.15%
+ * and 50.81% of the card width — the "till 50%" the design calls for. Figma
+ * declares the transparent stop as `#0e0d39`; at zero alpha it only affects
+ * interpolation, so the fade uses the surface colour it is ramping out of.
+ *
+ * Figma masks the photo to the right of the card and puts this ramp underneath
+ * it, running transparent -> opaque, so it backs the photo. Here the photo is a
+ * `cover` background that spans the whole card, so the ramp is oriented the
+ * other way and sits above the photo: it holds the flat surface colour across
+ * the left quarter, where the copy sits, and clears by the midpoint. Same
+ * rendered result, but it holds up for a background image of any size.
+ */
+export const cardImageSideFade = `linear-gradient(90deg, ${midnightGradientStops.welcomeCardStart} 25.15%, ${alpha(midnightGradientStops.welcomeCardStart, 0)} 50.81%)`;
+
+/**
+ * Background-image treatment.
+ *
+ * Figma: `Welcome Card` (274405:44234) inside the `Card with image` frame
+ * (274417:44476). Fill tokens `Gradient/Welcome-Card-BG-Dark` and
+ * `Gradient/Overlay-Black-Fade-In`.
+ *
+ * The design stacks the dark base gradient, the photo at 50% opacity, two
+ * scrims, then the content. The photo and the scrims need different opacities,
+ * and CSS cannot set per-layer opacity inside a single `background-image`, so
+ * they are painted as two pseudo-elements — `::before` for the photo,
+ * `::after` for both scrims. `overflow: hidden` clips them to the card radius.
+ *
+ * The scrims run in two directions: `Gradient/Overlay-Black-Fade-In` darkens
+ * the card from the top down, and `cardImageSideFade` holds the surface colour
+ * across the left, so the title and body copy always land on flat colour
+ * rather than on the picture.
+ *
+ * This surface also carries its own geometry (20px radius, 24px padding, 16px
+ * gap) rather than the 8px/16px/12px of `cardRootStyles`, because the design
+ * treats it as a larger promotional surface.
+ */
+export const cardImageStyles = (theme: Theme, image: string): CSSObject => ({
+ backdropFilter: "blur(60px)",
+ background: theme.palette.gradients.gradientWelcomeCardBgDark,
+ borderRadius: "20px",
+ color: theme.palette.vars.baseTextStrong,
+ gap: "16px",
+ overflow: "hidden",
+ padding: "24px",
+ position: "relative",
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ backgroundImage: `url("${image}")`,
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ backgroundSize: "cover",
+ opacity: 0.5,
+ pointerEvents: "none",
+ zIndex: 0,
+ },
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ // First layer paints on top: the vertical scrim over the horizontal one,
+ // matching the order the design stacks them in.
+ background: `${theme.palette.gradients.gradientOverlayBlackFadeIn}, ${cardImageSideFade}`,
+ pointerEvents: "none",
+ zIndex: 0,
+ },
+ // The two layers are positioned, so without this they would paint above the
+ // card's non-positioned children.
+ "& > *": {
+ position: "relative",
+ zIndex: 1,
+ },
+ /*
+ * Tertiary actions read as text on this surface, not as links on a page:
+ * `Interactive/Text In` rather than the variant's `Interactive/Primary`
+ * blue, which the scrim leaves sitting too dark against the photo.
+ *
+ * The variant's own rule is `&.MuiButton-tertariary` — the styled class plus
+ * the variant class, so two classes. Matching that from here with a
+ * descendant selector would only tie, leaving the winner up to emotion's
+ * injection order; naming `.MuiButton-root` as well takes it to three and
+ * settles it.
+ */
+ "& .MuiButton-root.MuiButton-tertariary": {
+ color: theme.palette.vars.interactiveTextInDefault,
+ "&:hover": {
+ color: theme.palette.vars.interactiveTextInHover,
+ },
+ "&:active": {
+ color: theme.palette.vars.interactiveTextInActive,
+ },
+ },
+});
+
export const cardSkeletonStyles = (theme: Theme): CSSObject => ({
backgroundColor: theme.palette.vars.baseBackgroundWeak,
"&.MuiSkeleton-wave::after": {
diff --git a/packages/open-ui-kit/src/components/card/types/index.ts b/packages/open-ui-kit/src/components/card/types/index.ts
index 5151aa21..8f500529 100644
--- a/packages/open-ui-kit/src/components/card/types/index.ts
+++ b/packages/open-ui-kit/src/components/card/types/index.ts
@@ -11,12 +11,54 @@ import type {
CardContentProps as MuiCardContentProps,
CardHeaderProps as MuiCardHeaderProps,
CardProps as MuiCardProps,
+ StackProps,
TypographyProps,
} from "@mui/material";
+/**
+ * Severity levels the alert card is designed for.
+ *
+ * Deliberately not the `Severity` enum from `@/common`: that has no `WARNING`
+ * member, and its other four levels have no design in `Alerts Card`
+ * (274421:47415).
+ */
+export type CardAlertSeverity = "warning" | "critical";
+
export interface CardProps extends MuiCardProps {
+ /**
+ * Applies the alert treatment: a translucent surface at the larger alert
+ * radius and padding. `critical` additionally draws the rainbow gradient
+ * border; `warning` has no border.
+ *
+ * Pair with `CardAlertHeader`, which picks up the matching accent colour.
+ */
+ alert?: CardAlertSeverity | undefined;
/** Applies the disabled card treatment and marks the grouped content unavailable. */
disabled?: boolean;
+ /**
+ * Applies the graph-connector treatment: the `Graph-Connector` fill and glow
+ * gradients over a backdrop blur, edged with the matching 1px gradient
+ * stroke. A tighter 6px radius than the other treatments.
+ */
+ connector?: boolean;
+ /** Applies the gradient border and blue glow treatment. */
+ glow?: boolean;
+ /**
+ * Applies the frosted-glass treatment: the `Gradient/Card-Glass-BG` fill over
+ * a backdrop blur, with a white hairline and a soft drop shadow.
+ *
+ * The fill is translucent, so the card picks up whatever is behind it.
+ */
+ glass?: boolean;
+ /**
+ * Background image URL. Applies the image treatment: the photo is layered
+ * over `Gradient/Welcome-Card-BG-Dark` and under a
+ * `Gradient/Overlay-Black-Fade-In` scrim, and the card switches to the
+ * larger radius and padding the design uses for these surfaces.
+ *
+ * Mutually exclusive with `glow`.
+ */
+ image?: string | undefined;
}
/** Clickable wrapper for interactive cards. */
@@ -40,3 +82,10 @@ export interface CardSubheaderProps extends TypographyProps {
/** Small supporting label, metadata, or date text. */
children: ReactNode;
}
+
+export interface CardAlertHeaderProps extends StackProps {
+ /** Severity label, rendered in the parent card's accent colour. */
+ children: ReactNode;
+ /** Right-aligned relative time, e.g. `4m ago`. */
+ timestamp?: ReactNode;
+}
diff --git a/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx
new file mode 100644
index 00000000..5695c652
--- /dev/null
+++ b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Meta, StoryObj } from "@storybook/react-vite";
+import { useTheme } from "@mui/material/styles";
+import { Box, Stack, Typography } from "@/components";
+import type { GradientVarsType } from "@/types/gradient-vars";
+import { DocsHeader } from "storybook/components/docs-header.stories";
+
+type TokenKey = keyof GradientVarsType;
+
+// Strokes sit on a border, so they are previewed as a ring rather than a fill.
+const STROKE_TOKENS: readonly TokenKey[] = [
+ "gradientCardGlassBg",
+ "gradientCardGlassBgSubtle",
+ "gradientGlobalBorderFade",
+ "gradientGlobalBorderRainbow",
+ "gradientGlobalButtonPrimaryBorderGlow",
+ "gradientDashboardGraphNodeBorder",
+ "gradientGraphConnectorStroke",
+ "gradientIconButtonBlueGlow",
+ "gradientCardHighlightRadial",
+];
+
+const RADIAL_TOKENS: readonly TokenKey[] = [
+ "gradientGlowOrange",
+ "gradientGlowGreen",
+ "gradientGlowRed",
+ "gradientGlowPinkShadow",
+ "gradientBackgroundGlowBlue",
+ "gradientPanelExecBorder",
+ "gradientPanelBorderBlueCyanDark",
+];
+
+function FillSwatch({ name, value }: { name: string; value: string }) {
+ const theme = useTheme();
+ return (
+
+
+
+ {name}
+
+
+ );
+}
+
+function StrokeSwatch({ name, value }: { name: string; value: string }) {
+ const theme = useTheme();
+ return (
+
+
+
+ {name}
+
+
+ );
+}
+
+function Group({
+ title,
+ note,
+ tokens,
+ variant,
+}: {
+ title: string;
+ note?: string | undefined;
+ tokens: [string, string][];
+ variant: "fill" | "stroke";
+}) {
+ const theme = useTheme();
+ const Swatch = variant === "stroke" ? StrokeSwatch : FillSwatch;
+ return (
+
+
+
+ {title}
+
+ {note ? (
+
+ {note}
+
+ ) : null}
+
+
+ {tokens.map(([name, value]) => (
+
+ ))}
+
+
+ );
+}
+
+const ThemeGradientsDemo = () => {
+ const theme = useTheme();
+ const entries = Object.entries(theme.palette.gradients) as [string, string][];
+
+ const strokes = entries.filter(([k]) =>
+ STROKE_TOKENS.includes(k as TokenKey),
+ );
+ const radials = entries.filter(([k]) =>
+ RADIAL_TOKENS.includes(k as TokenKey),
+ );
+ const fills = entries.filter(
+ ([k]) =>
+ !STROKE_TOKENS.includes(k as TokenKey) &&
+ !RADIAL_TOKENS.includes(k as TokenKey),
+ );
+
+ return (
+
+
+
+ Theme gradients
+
+
+ Read from theme.palette.gradients. Switch the theme in the
+ toolbar — every swatch re-resolves with no code branching. Values are
+ designed for Midnight; Light, Dark and IoC currently fall back to a
+ provisional base set.
+
+
+
+
+
+
+
+ );
+};
+
+const meta: Meta = {
+ title: "Foundations/Theme Gradients",
+ component: ThemeGradientsDemo,
+ parameters: {
+ actions: { argTypesRegex: null },
+ layout: "fullscreen",
+ docs: {
+ page: () => (
+
+ ),
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const All: Story = {
+ render: () => ,
+};
diff --git a/packages/open-ui-kit/src/components/icon/__tests__/custom-icons.test.ts b/packages/open-ui-kit/src/components/icon/__tests__/custom-icons.test.ts
index 3a91b097..e6545023 100644
--- a/packages/open-ui-kit/src/components/icon/__tests__/custom-icons.test.ts
+++ b/packages/open-ui-kit/src/components/icon/__tests__/custom-icons.test.ts
@@ -29,48 +29,40 @@ function getSource(filePath: string) {
return fs.readFileSync(filePath, "utf8");
}
-// A color written straight into the icon — hex, or any of the CSS color
-// functions. These are what must never appear: they survive a theme switch and
-// leave the mark painted for one mode only.
-const LITERAL_COLOR =
- /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\s*\(/i;
+function stripQuotes(value: string) {
+ return value.replace(/^['"]/, "").replace(/['"]$/, "").trim();
+}
-const QUOTED_STRING = /"[^"]*"|'[^']*'/g;
+// `none` and `currentColor` are the only paint keywords an icon may hardcode.
+function isPaintKeyword(value: string) {
+ const keyword = stripQuotes(value).toLowerCase();
-function unquote(value: string) {
- return value.slice(1, -1).trim().toLowerCase();
+ return keyword === "none" || keyword === "currentcolor";
}
-function isInheritedPaint(value: string) {
- return value === "none" || value === "currentcolor";
-}
+// A JSX expression that is a plain identifier or member access — `{tones.block}`,
+// `{vars.brandLogoSecondary}`, `{outshiftLogoGreen}` — resolves to a design token
+// or palette constant, which is how multi-tone marks (the Outshift logo, the
+// Dashboard navigation icon) paint shapes that cannot share one inherited color.
+const TOKEN_REFERENCE = /^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)*$/;
-// `fill`/`stroke` may either inherit (`currentColor`), paint nothing (`none`),
-// or resolve through the theme — a `vars.*` token, a palette constant, or a
-// prop carrying one of those. The multi-tone marks need that third form: the
-// Outshift logo paints four fixed brand colors, the Dashboard navigation icon
-// swaps a three-tone ramp between its selected and unselected states, and the
-// OrgSwitcher mark is two-tone. A caller only carries one inherited color, so
-// forcing those onto `currentColor` would flatten them.
-function isAcceptablePaint(rawValue: string) {
- const value = rawValue.trim();
-
- if (!value.startsWith("{")) {
- return isInheritedPaint(unquote(value));
- }
+function isAllowedPaint(value: string) {
+ const trimmed = value.trim();
- const expression = value.slice(1, -1);
+ if (trimmed.startsWith("{")) {
+ const expression = trimmed.replace(/^\{/, "").replace(/\}$/, "").trim();
+
+ // A string literal inside braces is still a hardcoded color.
+ return /^['"]/.test(expression)
+ ? isPaintKeyword(expression)
+ : TOKEN_REFERENCE.test(expression);
+ }
- return (
- !LITERAL_COLOR.test(expression) &&
- (expression.match(QUOTED_STRING) ?? []).every((literal) =>
- isInheritedPaint(unquote(literal)),
- )
- );
+ return isPaintKeyword(trimmed);
}
describe("custom icons", () => {
- it("keeps SVG fills and strokes off hardcoded colors", () => {
+ it("never hardcodes SVG fill and stroke colors", () => {
const badPaintAttributes = iconFiles.flatMap((filePath) => {
const source = getSource(filePath);
const matches = [
@@ -80,7 +72,7 @@ describe("custom icons", () => {
];
return matches
- .filter((match) => !isAcceptablePaint(match[1]))
+ .filter((match) => !isAllowedPaint(match[1]))
.map(
(match) => `${path.relative(customIconsDir, filePath)}: ${match[0]}`,
);
diff --git a/packages/open-ui-kit/src/components/index.ts b/packages/open-ui-kit/src/components/index.ts
index ebca688f..9c017f29 100644
--- a/packages/open-ui-kit/src/components/index.ts
+++ b/packages/open-ui-kit/src/components/index.ts
@@ -64,12 +64,12 @@ export * from "./view-switcher";
export * from "./scroll-area";
export * from "./pagination";
export * from "./widget";
+export * from "./typography";
// MUI exports — only primitives with no local wrapper
export {
Box,
Stack,
- Typography,
Grid,
IconButton,
Container,
diff --git a/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx b/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx
index 588d5ff0..621db608 100644
--- a/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx
+++ b/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx
@@ -10,8 +10,9 @@ import "@testing-library/jest-dom";
import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider";
import { darkTheme } from "@/theme/dark/dark-theme";
import { lightTheme } from "@/theme/light/light-theme";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
import { InputField } from "..";
-import { getInputFieldStyles } from "../styles";
+import { getInputFieldGlowStyles, getInputFieldStyles } from "../styles";
const renderInputField = (
props: React.ComponentProps,
@@ -66,6 +67,52 @@ describe("InputField", () => {
renderInputField({ label: "Label", type: "number", defaultValue: 1 }),
).not.toThrow();
});
+
+ });
+
+ describe("glow variant", () => {
+ it("renders without throwing", () => {
+ expect(() =>
+ renderInputField({ label: "Label", glow: true }),
+ ).not.toThrow();
+ expect(() =>
+ renderInputField({ glow: true, placeholder: "Placeholder text" }),
+ ).not.toThrow();
+ });
+
+ it("draws a borderless pill edged by the Input-Border-Blue ramp", () => {
+ expect(getInputFieldGlowStyles(midnightTheme)).toMatchObject({
+ "&& .MuiInput-root": expect.objectContaining({
+ border: "none",
+ borderRadius: "40px",
+ backgroundColor: "transparent",
+ "&::after": expect.objectContaining({
+ background:
+ midnightTheme.palette.gradients.gradientInputBorderBlue,
+ maskComposite: "exclude",
+ }),
+ }),
+ });
+ });
+
+ it("keeps the ring — rather than a solid border — on hover and focus", () => {
+ expect(
+ getInputFieldGlowStyles(midnightTheme)["&& .MuiInput-root"],
+ ).toMatchObject({
+ "&:hover, &.Mui-focused": { border: "none" },
+ });
+ });
+
+ it("uses the exact stops documented for Midnight: white to #0a60ff", () => {
+ expect(midnightTheme.palette.gradients.gradientInputBorderBlue).toBe(
+ "linear-gradient(90deg, #ffffff 0%, #0a60ff 100%)",
+ );
+ });
+
+ it("defines the token for every theme, not only Midnight", () => {
+ expect(lightTheme.palette.gradients.gradientInputBorderBlue).toBeTruthy();
+ expect(darkTheme.palette.gradients.gradientInputBorderBlue).toBeTruthy();
+ });
});
describe("token coverage", () => {
diff --git a/packages/open-ui-kit/src/components/input-field/components/elements.tsx b/packages/open-ui-kit/src/components/input-field/components/elements.tsx
index bfcd6681..7529e1d7 100644
--- a/packages/open-ui-kit/src/components/input-field/components/elements.tsx
+++ b/packages/open-ui-kit/src/components/input-field/components/elements.tsx
@@ -10,8 +10,11 @@ import {
type TextFieldProps as MuiTextFieldProps,
} from "@mui/material";
import type { ComponentType } from "react";
-import { getInputFieldStyles } from "../styles";
+import { getInputFieldGlowStyles, getInputFieldStyles } from "../styles";
-export const StyledInputField = styled(MuiTextField)(({ theme }) =>
- getInputFieldStyles(theme),
-) as ComponentType;
+export const StyledInputField = styled(MuiTextField, {
+ shouldForwardProp: (prop) => prop !== "glow",
+})<{ glow?: boolean }>(({ theme, glow }) => ({
+ ...getInputFieldStyles(theme),
+ ...(glow ? getInputFieldGlowStyles(theme) : {}),
+})) as ComponentType;
diff --git a/packages/open-ui-kit/src/components/input-field/components/input-field.tsx b/packages/open-ui-kit/src/components/input-field/components/input-field.tsx
index ef461d59..df41e028 100644
--- a/packages/open-ui-kit/src/components/input-field/components/input-field.tsx
+++ b/packages/open-ui-kit/src/components/input-field/components/input-field.tsx
@@ -13,9 +13,10 @@ const toSxArray = (sx: SxProps | undefined) =>
Array.isArray(sx) ? sx : sx ? [sx] : [];
export const InputField = React.forwardRef(
- ({ slotProps, sx, variant = "standard", ...props }, ref) => (
+ ({ glow, slotProps, sx, variant = "standard", ...props }, ref) => (
(
+
+
+
+ ),
+};
+
export const Focused: Story = {
args: {
defaultValue: "Entered text",
diff --git a/packages/open-ui-kit/src/components/input-field/styles/index.ts b/packages/open-ui-kit/src/components/input-field/styles/index.ts
index 7b028862..2197fbaf 100644
--- a/packages/open-ui-kit/src/components/input-field/styles/index.ts
+++ b/packages/open-ui-kit/src/components/input-field/styles/index.ts
@@ -160,6 +160,59 @@ export const getInputFieldStyles = (theme: Theme): CSSObject => ({
},
});
+/**
+ * Glow treatment: gradient border — Figma `Input Field` (274417:44475).
+ *
+ * A pill-shaped prompt field edged with `Gradient/Input-Border-Blue`, whose
+ * swatch reads "DARK original: FFFFFF -> 0A60FF". The token is new to the
+ * theme but introduces no new colour — both stops already existed.
+ *
+ * The ramp cannot be a `border-color`, so the border is drawn as a 1px
+ * mask-composite ring on the input root, matching how the other gradient
+ * borders in the kit are built. No `overflow: hidden`, which would thin the
+ * ring's arcs — and at this radius the arcs are most of the outline.
+ *
+ * Keyed on `&&` rather than `&` so it layers over `getInputFieldStyles`
+ * instead of replacing its `.MuiInput-root` block: the two objects are spread
+ * together, and a matching key would drop the base field's typography, height
+ * and underline resets wholesale.
+ */
+export const getInputFieldGlowStyles = (theme: Theme): CSSObject => ({
+ "&& .MuiInput-root": {
+ position: "relative",
+ border: "none",
+ // Figma's 40px radius on a 40px-tall field — a full pill.
+ borderRadius: "40px",
+ padding: "8px 16px",
+ backgroundColor: "transparent",
+
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "1px",
+ background: theme.palette.gradients.gradientInputBorderBlue,
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ // MUI's underline pseudo-elements are already neutralised above; this
+ // one is the ring, so it must not be caught by those resets.
+ borderBottom: "0 !important",
+ transform: "none !important",
+ zIndex: 0,
+ },
+
+ // The field keeps its ring on hover and focus rather than swapping to the
+ // control border tokens the standard field uses.
+ "&:hover, &.Mui-focused": {
+ border: "none",
+ },
+ },
+});
+
export const getStoryFocusedSx = (theme: Theme) =>
({
"& .MuiInput-root": {
diff --git a/packages/open-ui-kit/src/components/input-field/types/index.ts b/packages/open-ui-kit/src/components/input-field/types/index.ts
index e05d21f3..816faa70 100644
--- a/packages/open-ui-kit/src/components/input-field/types/index.ts
+++ b/packages/open-ui-kit/src/components/input-field/types/index.ts
@@ -19,4 +19,9 @@ export type InputFieldProps = MuiTextFieldProps & {
error?: MuiTextFieldProps["error"];
/** Custom MUI slot props, merged with the internal shrunk label behavior. */
slotProps?: MuiTextFieldProps["slotProps"];
+ /**
+ * Applies the glow treatment: a pill-shaped field edged with the
+ * `Input-Border-Blue` ramp, for prompt-style inputs.
+ */
+ glow?: boolean;
};
diff --git a/packages/open-ui-kit/src/components/toast/__tests__/toast.test.tsx b/packages/open-ui-kit/src/components/toast/__tests__/toast.test.tsx
index b6fb23de..e06e9243 100644
--- a/packages/open-ui-kit/src/components/toast/__tests__/toast.test.tsx
+++ b/packages/open-ui-kit/src/components/toast/__tests__/toast.test.tsx
@@ -12,12 +12,15 @@ import { darkTheme } from "@/theme/dark/dark-theme";
import { lightTheme } from "@/theme/light/light-theme";
import { Toaster } from "../components/toaster";
import { Toast } from "../components/toast";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
+import { toastGlow, toastGlowStrong } from "@/theme/style/color-palette";
import {
toastActionButtonStyle,
toastCloseButtonStyle,
toastCloseIconStyle,
toastContentStyle,
toastDescriptionStyle,
+ toastGlowStyle,
toastIconSlotStyle,
toastInnerStyle,
toastMessageSlotStyle,
@@ -305,4 +308,70 @@ describe("Toast", () => {
});
});
});
+
+ // Figma: `Toast message Glow` (274417:44480), its two `Text Card` instances.
+ describe("glow treatment", () => {
+ it("draws the border as a gradient ring, not a border property", () => {
+ const styles = toastGlowStyle(midnightTheme, true);
+ const ring = styles["&::before"] as Record;
+
+ // The section documents one gradient and points it at both instances.
+ // It already exists in the theme, so the treatment adds none.
+ expect(ring.background).toBe(
+ midnightTheme.palette.gradients.gradientGlobalBorderFade,
+ );
+ // A gradient cannot be a `border-color`, hence the ring.
+ expect(styles.border).toBe("none");
+ expect(ring.padding).toBe("1px");
+ expect(ring.maskComposite).toBe("exclude");
+ expect(ring.borderRadius).toBe("inherit");
+ // Would thin the ring's corner arcs.
+ expect(styles.overflow).toBeUndefined();
+ expect(styles["& > *"]).toEqual({ position: "relative", zIndex: 1 });
+ });
+
+ it("strengthens the glow when the toast has a header", () => {
+ expect(toastGlowStyle(midnightTheme, true).boxShadow).toBe(
+ toastGlowStrong,
+ );
+ expect(toastGlowStyle(midnightTheme, false).boxShadow).toBe(toastGlow);
+ // Same offset and blur; only the alpha differs between the instances.
+ expect(toastGlow).toBe("0px -1px 34px rgba(10, 96, 255, 0.2)");
+ expect(toastGlowStrong).toBe("0px -1px 34px rgba(10, 96, 255, 0.4)");
+ });
+
+ it("pads to the 18px the frame uses, not the standard toast padding", () => {
+ expect(toastGlowStyle(midnightTheme, true).padding).toBe("18px");
+ expect(toastRootStyle(midnightTheme, "default").padding).toBe(
+ "12px 16px",
+ );
+ });
+
+ it("renders both header states without throwing", () => {
+ expect(() =>
+ renderToast({
+ id: "glow-header",
+ glow: true,
+ title: "New Successful Reasoning Strategy",
+ description: "A new successful reasoning strategy cluster emerged.",
+ }),
+ ).not.toThrow();
+ expect(
+ screen.getByText("New Successful Reasoning Strategy"),
+ ).toBeInTheDocument();
+
+ // The helper defaults a title in, so clear it for the no-header case.
+ expect(() =>
+ renderToast({
+ id: "glow-no-header",
+ glow: true,
+ title: undefined,
+ description: "User queries naturally organize around travel.",
+ }),
+ ).not.toThrow();
+ expect(
+ screen.getByText("User queries naturally organize around travel."),
+ ).toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/open-ui-kit/src/components/toast/components/elements.tsx b/packages/open-ui-kit/src/components/toast/components/elements.tsx
index 8c0cfdb8..e55feab9 100644
--- a/packages/open-ui-kit/src/components/toast/components/elements.tsx
+++ b/packages/open-ui-kit/src/components/toast/components/elements.tsx
@@ -8,6 +8,7 @@ import { Alert, styled, Theme, type AlertProps } from "@mui/material";
import type { ComponentType } from "react";
import { ToastType } from "../types";
import {
+ toastGlowStyle,
toastIconSlotStyle,
toastMessageSlotStyle,
toastRootStyle,
@@ -21,28 +22,40 @@ import {
export const StyledToast = styled(Alert, {
shouldForwardProp: (prop) =>
- prop !== "type" && prop !== "hasTitle" && prop !== "hasAction",
-})<{ type?: ToastType; hasTitle?: boolean; hasAction?: boolean }>(
- ({ theme, type, hasTitle, hasAction }) => ({
- ...toastRootStyle(theme as Theme, type, hasTitle, hasAction),
- "& .MuiAlertTitle-root, & .MuiAlert-message": {
- margin: 0,
- },
- "& .MuiAlert-icon": {
- ...toastIconSlotStyle(theme as Theme, type),
- },
- "& .MuiAlert-action": {
- display: "none",
- },
- "& .MuiAlert-message": {
- ...toastMessageSlotStyle,
- },
- "& .MuiAlert-icon + .MuiAlert-message": {
- margin: 0,
- },
- }),
-) as ComponentType<
- AlertProps & { type?: ToastType; hasTitle?: boolean; hasAction?: boolean }
+ prop !== "type" &&
+ prop !== "hasTitle" &&
+ prop !== "hasAction" &&
+ prop !== "glow",
+})<{
+ type?: ToastType;
+ hasTitle?: boolean;
+ hasAction?: boolean;
+ glow?: boolean;
+}>(({ theme, type, hasTitle, hasAction, glow }) => ({
+ ...toastRootStyle(theme as Theme, type, hasTitle, hasAction),
+ ...(glow ? toastGlowStyle(theme as Theme, hasTitle) : {}),
+ "& .MuiAlertTitle-root, & .MuiAlert-message": {
+ margin: 0,
+ },
+ "& .MuiAlert-icon": {
+ ...toastIconSlotStyle(theme as Theme, type),
+ },
+ "& .MuiAlert-action": {
+ display: "none",
+ },
+ "& .MuiAlert-message": {
+ ...toastMessageSlotStyle,
+ },
+ "& .MuiAlert-icon + .MuiAlert-message": {
+ margin: 0,
+ },
+})) as ComponentType<
+ AlertProps & {
+ type?: ToastType;
+ hasTitle?: boolean;
+ hasAction?: boolean;
+ glow?: boolean;
+ }
>;
export const IconToast = ({ type }: { type?: ToastType }) => {
diff --git a/packages/open-ui-kit/src/components/toast/components/toast.tsx b/packages/open-ui-kit/src/components/toast/components/toast.tsx
index 575eaa3e..cd87ca9f 100644
--- a/packages/open-ui-kit/src/components/toast/components/toast.tsx
+++ b/packages/open-ui-kit/src/components/toast/components/toast.tsx
@@ -31,6 +31,7 @@ export const Toast = ({
action,
id,
customActions,
+ glow,
...props
}: ToastProps) => {
const [show, setShow] = React.useState(true);
@@ -58,6 +59,7 @@ export const Toast = ({
type={type}
hasTitle={Boolean(title)}
hasAction={Boolean(action)}
+ glow={glow}
icon={}
>
diff --git a/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx b/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx
index d8016a47..a9d4bbcd 100644
--- a/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx
+++ b/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx
@@ -35,6 +35,11 @@ const meta = {
},
title: { control: "text" },
description: { control: "text" },
+ glow: {
+ control: "boolean",
+ description:
+ "Applies the glow treatment: a `Global-Border/Fade` gradient border and a blue glow. Stronger when the toast also has a title.",
+ },
showCloseButton: { control: "boolean" },
useNativeClose: { control: "boolean" },
action: { control: false },
@@ -71,6 +76,41 @@ export const WithoutTitle: Story = {
},
};
+/**
+ * Toast message Glow — Figma `Toast message Glow` (274417:44480), with header.
+ *
+ * A `Global-Border/Fade` gradient border and a blue glow cast from behind the
+ * toast. With a header the glow is the stronger of the two values the frame
+ * documents.
+ */
+export const GlowWithHeader: Story = {
+ name: "Glow / With header",
+ args: {
+ id: "glow-with-header",
+ glow: true,
+ title: "New Successful Reasoning Strategy",
+ description:
+ "A new successful reasoning strategy cluster emerged this week.",
+ action: undefined,
+ },
+};
+
+/**
+ * Toast message Glow — the same treatment with no header, carrying the softer
+ * of the two glow values.
+ */
+export const GlowWithoutHeader: Story = {
+ name: "Glow / Without header",
+ args: {
+ id: "glow-without-header",
+ glow: true,
+ title: undefined,
+ description:
+ "User queries naturally organize around long-distance travel coordination and local route optimization, with inter-city planning emerging as the primary interaction theme.",
+ action: undefined,
+ },
+};
+
export const Success: Story = {
args: {
id: "success",
diff --git a/packages/open-ui-kit/src/components/toast/styles/index.ts b/packages/open-ui-kit/src/components/toast/styles/index.ts
index 2e72f23c..7060676d 100644
--- a/packages/open-ui-kit/src/components/toast/styles/index.ts
+++ b/packages/open-ui-kit/src/components/toast/styles/index.ts
@@ -6,6 +6,7 @@
import type { CSSObject, Theme } from "@mui/material";
import type { ToastType } from "../types";
+import { toastGlow, toastGlowStrong } from "@/theme/style/color-palette";
const isStatusToast = (type?: ToastType) => type && type !== "default";
@@ -77,6 +78,54 @@ export const toastRootStyle = (
};
};
+/**
+ * Glow treatment — Figma `Toast message Glow` (274417:44480).
+ *
+ * The section documents one gradient, `Gradient/Global-Border/Fade`, and points
+ * it at both toast instances. It is already in the theme as
+ * `gradientGlobalBorderFade`, so this adds no new gradient. It runs dim on the
+ * left to bright blue on the right, matching the instances.
+ *
+ * Two things carry the treatment: a 1px gradient border and a blue glow cast
+ * upward from behind the toast. The glow is the stronger of the two documented
+ * values when the toast has a header and the softer one when it does not —
+ * which is exactly how the two instances in the frame differ.
+ *
+ * The border is a mask-composite ring rather than a `border`, since a gradient
+ * cannot be assigned to `border-color`. No `overflow: hidden`, which would
+ * thin the ring's corner arcs.
+ */
+export const toastGlowStyle = (
+ theme: Theme,
+ hasTitle?: boolean,
+): CSSObject => ({
+ border: "none",
+ borderLeftWidth: 0,
+ // Figma pads these 18px rather than the 12/16 the standard toast uses.
+ padding: "18px",
+ position: "relative",
+ boxShadow: hasTitle ? toastGlowStrong : toastGlow,
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "inherit",
+ padding: "1px",
+ background: theme.palette.gradients.gradientGlobalBorderFade,
+ WebkitMask:
+ "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
+ WebkitMaskComposite: "xor",
+ maskComposite: "exclude",
+ pointerEvents: "none",
+ zIndex: 0,
+ },
+ // The ring is positioned, so lift the toast's own slots above it.
+ "& > *": {
+ position: "relative",
+ zIndex: 1,
+ },
+});
+
export const toastIconSlotStyle = (
theme: Theme,
type?: ToastType,
diff --git a/packages/open-ui-kit/src/components/toast/types/index.ts b/packages/open-ui-kit/src/components/toast/types/index.ts
index 0b23ecfa..2889dcd0 100644
--- a/packages/open-ui-kit/src/components/toast/types/index.ts
+++ b/packages/open-ui-kit/src/components/toast/types/index.ts
@@ -27,6 +27,12 @@ export interface ToastProps extends Omit<
type?: ToastType;
/** Optional bold title line rendered above the message. */
title?: string;
+ /**
+ * Applies the glow treatment: a `Global-Border/Fade` gradient border and a
+ * blue glow cast from behind the toast. The glow is stronger when the toast
+ * also has a `title`.
+ */
+ glow?: boolean;
/** Optional body message shown in the toast content area. */
description?: string;
/** Shows the close button when true. */
diff --git a/packages/open-ui-kit/src/components/typography/__tests__/typography.test.tsx b/packages/open-ui-kit/src/components/typography/__tests__/typography.test.tsx
new file mode 100644
index 00000000..679cddcf
--- /dev/null
+++ b/packages/open-ui-kit/src/components/typography/__tests__/typography.test.tsx
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
+import { Typography } from "..";
+
+const renderTypography = (ui: React.ReactElement) =>
+ render({ui});
+
+describe("Typography", () => {
+ describe("rendering", () => {
+ it("renders its children", () => {
+ renderTypography(Plain text);
+ expect(screen.getByText("Plain text")).toBeInTheDocument();
+ });
+
+ it("composes the gradient with any variant", () => {
+ expect(() =>
+ renderTypography(
+ <>
+
+ Heading
+
+
+ Body
+
+ >,
+ ),
+ ).not.toThrow();
+ });
+ });
+
+ describe("gradient fill", () => {
+ it("clips the Text-White-Blue ramp to the glyphs", () => {
+ renderTypography(Welcome Amy!);
+
+ expect(screen.getByText("Welcome Amy!")).toHaveStyle({
+ background: midnightTheme.palette.gradients.gradientTextWhiteBlue,
+ backgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ });
+ });
+
+ // Sizing the ramp to the text pins every string to the same colour, so a
+ // short line stops looking any different from a long one.
+ it("lets the ramp span the element, not the glyphs", () => {
+ renderTypography(Welcome Amy!);
+
+ const el = screen.getByText("Welcome Amy!");
+ expect(el).not.toHaveStyle({ width: "fit-content" });
+ expect(el).not.toHaveStyle({ backgroundRepeat: "no-repeat" });
+ expect(el).not.toHaveStyle({ boxDecorationBreak: "clone" });
+ });
+
+ // One ramp shared by every line is what gives a short trailing line its
+ // paler fill, so the element must stay block-level.
+ it("keeps the fill on a block element so lines share one ramp", () => {
+ renderTypography(
+
+ Welcome Amy!
+ ,
+ );
+
+ const el = screen.getByText("Welcome Amy!");
+ expect(el).toHaveClass("MuiTypography-h1");
+ expect(el).not.toHaveStyle({ display: "inline" });
+ expect(el.querySelector("span")).toBeNull();
+ });
+
+ it("leaves ungradiented text alone", () => {
+ renderTypography(Plain text);
+
+ const el = screen.getByText("Plain text");
+ expect(el.querySelector("span")).toBeNull();
+ expect(el).not.toHaveStyle({ WebkitTextFillColor: "transparent" });
+ });
+ });
+
+ describe("token coverage", () => {
+ // Matches the design's own CSS, middle stop included. That stop sits on
+ // the line between the two ends, so it is cosmetic.
+ it("carries the three stops Figma emits", () => {
+ expect(midnightTheme.palette.gradients.gradientTextWhiteBlue).toBe(
+ "linear-gradient(90deg, #ffffff 0%, #9dbcf7 51.28%, #3f7def 100%)",
+ );
+ });
+ });
+});
diff --git a/packages/open-ui-kit/src/components/typography/components/elements.tsx b/packages/open-ui-kit/src/components/typography/components/elements.tsx
new file mode 100644
index 00000000..c41540a0
--- /dev/null
+++ b/packages/open-ui-kit/src/components/typography/components/elements.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Typography as MuiTypography, styled } from "@mui/material";
+
+/**
+ * Typography with an optional gradient text fill.
+ *
+ * When the `gradient` prop is set, the glyphs are filled with a gradient via
+ * `background-clip: text` instead of a flat color. It is a prop, not a
+ * `variant`, so it composes with any `variant` (h1, subtitle, body, ...).
+ *
+ * `shouldForwardProp` keeps `gradient` off the DOM node so React does not warn
+ * about a non-boolean attribute. The cast back to `typeof MuiTypography`
+ * preserves the polymorphic `component` prop and every MUI variant, which a
+ * plain `styled()` call would otherwise drop. Figma: `Gradient/Text-White-Blue`.
+ *
+ * The ramp spans the element's box and nothing rescales it, so how far a string
+ * gets along it depends on how much of the line it fills — a short line stays
+ * white, a full one reaches `#3f7def`. Do not add `width: fit-content` or
+ * `background-size`: sizing the ramp to the text pins every string to the same
+ * colour whatever its length.
+ */
+export const StyledTypography = styled(MuiTypography, {
+ shouldForwardProp: (prop) => prop !== "gradient",
+})(({ theme, gradient }) => ({
+ ...(gradient && {
+ background: theme.palette.gradients.gradientTextWhiteBlue,
+ WebkitBackgroundClip: "text",
+ backgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ }),
+})) as typeof MuiTypography;
diff --git a/packages/open-ui-kit/src/components/typography/components/typography.tsx b/packages/open-ui-kit/src/components/typography/components/typography.tsx
new file mode 100644
index 00000000..b606c481
--- /dev/null
+++ b/packages/open-ui-kit/src/components/typography/components/typography.tsx
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { StyledTypography as Typography } from "./elements";
diff --git a/packages/open-ui-kit/src/components/typography/index.ts b/packages/open-ui-kit/src/components/typography/index.ts
new file mode 100644
index 00000000..6e449186
--- /dev/null
+++ b/packages/open-ui-kit/src/components/typography/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { Typography } from "./components/typography";
diff --git a/packages/open-ui-kit/src/components/typography/typography.stories.tsx b/packages/open-ui-kit/src/components/typography/stories/typography.stories.tsx
similarity index 65%
rename from packages/open-ui-kit/src/components/typography/typography.stories.tsx
rename to packages/open-ui-kit/src/components/typography/stories/typography.stories.tsx
index 9c555af9..72c0957a 100644
--- a/packages/open-ui-kit/src/components/typography/typography.stories.tsx
+++ b/packages/open-ui-kit/src/components/typography/stories/typography.stories.tsx
@@ -101,3 +101,48 @@ export const Example: Story = {
variant: "h1",
},
};
+
+/**
+ * The `gradient` prop fills the text with a gradient (`background-clip: text`)
+ * instead of a flat color. It is a boolean, so it composes with any `variant`.
+ * Figma: `Gradient/Text-White-Blue`.
+ *
+ * The ramp spans the line, so how far a string gets along it depends on how
+ * much of the line it fills — a word stays white, a full line reaches the blue.
+ */
+export const Gradient: Story = {
+ render: () => (
+
+
+
+ Welcome Amy!
+
+
+ Gradient heading
+
+
+ Gradient subtitle
+
+
+ Gradient body text — the fill works on any variant.
+
+
+ Gradient caption
+
+
+
+
+
+ Hi
+
+
+ Welcome back to your workspace, Amy
+
+
+ Your Overall Quality Score represents a composite evaluation of agent
+ performance across execution and output dimensions.
+
+
+
+ ),
+};
diff --git a/packages/open-ui-kit/src/index.ts b/packages/open-ui-kit/src/index.ts
index d2232e0e..f7f13384 100644
--- a/packages/open-ui-kit/src/index.ts
+++ b/packages/open-ui-kit/src/index.ts
@@ -18,7 +18,10 @@ export { lightVars } from "./theme/light/light-vars";
export { darkVars } from "./theme/dark/dark-vars";
export { iocVars } from "./theme/ioc/ioc-vars";
export { midnightVars } from "./theme/midnight/midnight-vars";
+export { baseGradientVars } from "./theme/style/gradient-vars-base";
+export { midnightGradientVars } from "./theme/midnight/midnight-gradient-vars";
export type { VarsType } from "./types/vars";
+export type { GradientVarsType } from "./types/gradient-vars";
export {
ThemeMode,
ThemeProvider,
diff --git a/packages/open-ui-kit/src/theme/dark/dark-theme.tsx b/packages/open-ui-kit/src/theme/dark/dark-theme.tsx
index 3be02186..aa5c4369 100644
--- a/packages/open-ui-kit/src/theme/dark/dark-theme.tsx
+++ b/packages/open-ui-kit/src/theme/dark/dark-theme.tsx
@@ -31,6 +31,7 @@ import {
Shadows,
} from "@mui/material";
import { darkVars } from "./dark-vars";
+import { baseGradientVars } from "@/theme/style/gradient-vars-base";
import {
buttonComponent,
inputComponents,
@@ -65,6 +66,7 @@ const palette: PaletteOptions = {
orange: orangePalette,
grey: greyPalette,
vars: darkVars,
+ gradients: baseGradientVars,
text: {
primary: darkVars.baseTextStrong,
secondary: darkVars.baseTextDefault,
diff --git a/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx b/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx
index 8247e559..738e505c 100644
--- a/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx
+++ b/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx
@@ -21,6 +21,7 @@ import {
Shadows,
} from "@mui/material";
import { iocVars } from "./ioc-vars";
+import { baseGradientVars } from "@/theme/style/gradient-vars-base";
import {
iocTealPalette,
iocBluePalette,
@@ -70,6 +71,7 @@ const palette: PaletteOptions = {
orange: orangePalette,
grey: greyPalette,
vars: iocVars,
+ gradients: baseGradientVars,
text: {
primary: iocTextPrimary,
secondary: iocTextSecondary,
@@ -142,3 +144,4 @@ const iocThemeOptions: ThemeOptions = {
export const iocTheme: Theme = createTheme(baseTheme, iocThemeOptions);
iocTheme.palette.vars = iocVars;
+iocTheme.palette.gradients = baseGradientVars;
diff --git a/packages/open-ui-kit/src/theme/light/light-theme.tsx b/packages/open-ui-kit/src/theme/light/light-theme.tsx
index 76a0bded..04b4a433 100644
--- a/packages/open-ui-kit/src/theme/light/light-theme.tsx
+++ b/packages/open-ui-kit/src/theme/light/light-theme.tsx
@@ -32,6 +32,7 @@ import {
import { commonMixins, breakpoints } from "@/theme/style/common";
import { typography } from "@/theme/style/typography";
import { lightVars } from "./light-vars";
+import { baseGradientVars } from "@/theme/style/gradient-vars-base";
import {
buttonComponent,
inputComponents,
@@ -66,6 +67,7 @@ const palette: PaletteOptions = {
orange: orangePalette,
grey: greyPalette,
vars: lightVars,
+ gradients: baseGradientVars,
text: {
primary: greyPalette[500],
secondary: greyPalette[50],
diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts
new file mode 100644
index 00000000..5035d892
--- /dev/null
+++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { alpha } from "@mui/material/styles";
+import type { GradientVarsType } from "@/types/gradient-vars";
+import {
+ baseGradientVars,
+ gradientButtonPrimaryBorderGlow,
+ gradientButtonPrimaryFill,
+ gradientCardInsightBorder,
+ gradientTextWhiteBlue,
+} from "@/theme/style/gradient-vars-base";
+import { gradientsRedPressed } from "@/theme/style/gradients";
+import {
+ blue300,
+ blue500,
+ midnightGradientStops as stops,
+ teal300,
+} from "@/theme/style/color-palette";
+
+/*
+ * Midnight gradient unique tokens
+ */
+export const midnightGradientVars: GradientVarsType = {
+ ...baseGradientVars,
+
+ // --- Fills ---------------------------------------------------------------
+
+ gradientGaugeArcAmber: `linear-gradient(90deg, ${stops.gaugeArcAmber} 0%, ${alpha(stops.gaugeArcAmber, 0.78)} 100%)`,
+ gradientGaugeArcTeal: `linear-gradient(90deg, ${stops.gaugeArcTealStart} 0%, ${stops.gaugeArcTealEnd} 100%)`,
+ gradientIconSubtractBlue: `linear-gradient(90deg, ${stops.iconSubtractBlue} 0%, ${blue500} 100%)`,
+
+ // TODO(verify): 170.415deg read from a 2:1 swatch.
+ gradientDataVizCyanTeal: `linear-gradient(170.415deg, ${alpha(stops.dataVizCyan, 0.6)} 13.912%, ${alpha(stops.dataVizTeal, 0.6)} 82.071%)`,
+ gradientDataVizCyanBlue: `linear-gradient(90deg, ${teal300} 0%, ${blue300} 100%)`,
+
+ gradientDataVizPinkMagenta: `linear-gradient(180deg, ${alpha(stops.dataVizPink, 0.7)} 0%, ${alpha(stops.dataVizMagenta, 0.7)} 100%)`,
+ // Figma labels this `Data-Viz-Pink-Magenta`, but the stops are orange.
+ gradientDataVizOrangeAmber: `linear-gradient(180deg, ${alpha(stops.dataVizOrange, 0.7)} 0%, ${alpha(stops.dataVizAmber, 0.7)} 100%)`,
+ // Figma labels this `Data-Viz-Pink-Magenta`; one stop, so it renders flat.
+ gradientDataVizPinkFlat: `linear-gradient(90deg, ${alpha(stops.dataVizPink, 0.2)} 0%, ${alpha(stops.dataVizPink, 0.2)} 50%)`,
+
+ gradientDataVizPinkPurple: `linear-gradient(180deg, ${alpha(stops.dataVizPink, 0.2)} 11.117%, ${alpha(stops.dataVizPurple, 0.2)} 111.54%)`,
+ gradientDataVizOrangeGold: `linear-gradient(180deg, ${alpha(stops.dataVizOrange, 0.2)} 11.117%, ${alpha(stops.dataVizGold, 0.2)} 111.54%)`,
+ gradientDataVizBlueDark: `linear-gradient(180deg, ${alpha(stops.dataVizBlue, 0.2)} 0%, ${alpha(blue300, 0.2)} 100%)`,
+
+ // TODO(verify): 10.276deg read from a 2:1 swatch.
+ gradientProgressBarTeal: `linear-gradient(10.276deg, ${alpha(stops.dataVizFadeGray, 0)} 32.561%, ${stops.dataVizMint} 69.418%)`,
+
+ gradientGraphFlow: `linear-gradient(90deg, ${alpha(stops.graphFlowGray, 0.2)} 0%, ${alpha(stops.graphFlowTeal, 0.2)} 50%)`,
+ gradientGraphFlowPink: `linear-gradient(90deg, ${alpha(stops.graphFlowGray, 0.2)} 0%, ${alpha(stops.dataVizPink, 0.2)} 50%)`,
+ // Single stop in Figma, so this is a flat fill rather than a ramp.
+ gradientGraphFlowTeal: alpha(stops.graphFlowTeal, 0.2),
+ // Figma reuses `Graph-Flow` for this distinct maroon ramp.
+ gradientGraphFlowMaroon: `linear-gradient(90deg, ${alpha(stops.graphFlowMaroon, 0.2)} 0%, ${alpha(stops.graphFlowTeal, 0.2)} 50%)`,
+
+ // Shared with every theme — see `gradient-vars-base.ts`.
+ gradientGlobalButtonPrimaryFill: gradientButtonPrimaryFill,
+ // Shared with every theme — see `gradient-vars-base.ts`.
+ gradientTextWhiteBlue,
+ gradientGlobalDividerFade: `linear-gradient(90deg, ${stops.panelExecBorderBlue} 0%, ${stops.dataVizCyan} 0%, ${stops.dataVizCyan} 21.692%, ${stops.dataVizOrange} 55.827%, ${stops.globalDividerPink} 100%)`,
+
+ gradientOverlayBlackFadeIn: `linear-gradient(180deg, ${alpha(stops.overlayBlack, 0.65)} 52.404%, ${alpha(stops.overlayGray, 0)} 100%)`,
+ // TODO(verify): 146.411deg read from a 2:1 swatch.
+ gradientWelcomeCardBgDark: `linear-gradient(146.411deg, ${stops.welcomeCardStart} 64.87%, ${alpha(stops.welcomeCardEnd, 0.5)} 91.196%)`,
+
+ // Identical stops and offsets to the existing `gradientsRedPressed` token, so
+ // it is reused to preserve the canonical 124.96deg angle. Figma exported this
+ // as 144.426deg because its angles are aspect-ratio dependent (2:1 swatch).
+ gradientAlertLineRed: gradientsRedPressed,
+
+ // TODO(verify): 150.642deg read from a 2:1 swatch. Two stacked fills.
+ gradientDashboardGraphNodeFill: `linear-gradient(90deg, ${alpha(stops.overlayBlack, 0.2)} 0%, ${alpha(stops.overlayBlack, 0.2)} 100%), linear-gradient(150.642deg, ${alpha(stops.graphNodeFillBlue, 0.09)} 26.471%, ${alpha(stops.graphNodeFillDark, 0.15)} 88.419%)`,
+ /*
+ * Graph-connector card family — Figma `Section 3` (274455:54313).
+ *
+ * The TODO that used to sit here ("166.51deg read from a 2:1 swatch") is
+ * resolved: the card in that section exports as an SVG carrying all three
+ * gradient defs verbatim, so these are exact rather than sampled.
+ *
+ * The angle differs from the old swatch reading because Figma normalises a
+ * gradient transform to its layer box — the same caveat recorded on
+ * `gradientCardInsightBorder`. On the 215x207 card the fill vector runs
+ * (0,4) -> (116.6,256.4), which is 155.21deg in CSS; the 120x60 swatch
+ * squeezes that to the 166.51deg previously recorded. The card is the real
+ * usage, so it wins.
+ *
+ * Both fills carry `fill-opacity="0.16"` in the export — the "(16%)" on the
+ * swatch labels. That is baked into the stops here rather than left for the
+ * consumer: 0.22 -> 0.035, 0.10 -> 0.016, 0.40 -> 0.064, 0.05 -> 0.008.
+ */
+ gradientGraphConnectorFill: `linear-gradient(155.21deg, ${alpha(stops.graphConnectorBlue, 0.035)} 0%, ${alpha(stops.graphConnectorBlue, 0.016)} 100%)`,
+ // Anchored bottom-centre, radii 85% of the card in both axes — the export
+ // rotates a (176.14, 182.947) scale by -90deg about (107.5, 211).
+ gradientGraphConnectorGlow: `radial-gradient(85% 85% at 50% 100%, ${alpha(stops.graphConnectorBlue, 0.064)} 0%, ${alpha(stops.graphConnectorBlue, 0.008)} 100%)`,
+
+ gradientIconButtonBlue: `linear-gradient(180deg, ${stops.iconButtonBlueStart} 0%, ${stops.iconButtonBlueMid} 54.12%, ${stops.iconButtonBlueEnd} 120.67%)`,
+
+ // --- Strokes -------------------------------------------------------------
+ // Applied to borders — needs `border-image` or a padding-box/border-box
+ // double-background; a gradient string alone will not render as a border.
+ //
+ // The Figma MCP server flattens a gradient stroke to its first stop, so these
+ // were recovered by rendering each swatch and sampling the border band pixel
+ // by pixel, then un-compositing against the card background. Direction and
+ // stop offsets below are measured, not guessed. All nine are horizontal.
+
+ /*
+ * Glass card family — Figma `Glass Card` (274490:55387).
+ *
+ * These are EXACT, not sampled: the updated frame exports the card surface
+ * and its flair as SVGs whose gradient defs can be read directly (nodes
+ * 274490:55140 and 274490:55139, at the mockup's 0.8928 scale). An earlier
+ * linear 5% -> 35% reading of the fill was a pixel-fit of this same radial:
+ * a corner-anchored radial sampled along one row degrades to exactly that
+ * ramp, and both agree with the published `Card-Glass-BG/Stop-0-White-5%`
+ * variable at the far corner.
+ *
+ * The fill is a radial anchored at the card's TOP-RIGHT corner (the SVG
+ * centres it at its own origin and the mockup rotates it 90deg clockwise),
+ * with both radii spanning the full card. Behind it sits the Glow-Teal
+ * flair, and the whole surface backdrop-blurs at `cardGlassBlur`.
+ */
+ gradientCardGlassBg: `radial-gradient(100% 100% at 100% 0%, ${alpha(stops.glassWhite, 0.4)} 0%, ${alpha(stops.glassWhite, 0.05)} 100%)`,
+ // The "70%" the old swatch label carried, applied to the fill: the value to
+ // reach for on an already-light surface.
+ gradientCardGlassBgSubtle: `radial-gradient(100% 100% at 100% 0%, ${alpha(stops.glassWhite, 0.28)} 0%, ${alpha(stops.glassWhite, 0.035)} 100%)`,
+ // `Card-Glass-BORDER`: the hairline is a vertical ramp that holds white 30%
+ // over the upper card and fades out entirely by the bottom edge. From the
+ // SVG stroke def (offsets 10% / 75% / 100% after the 90deg rotation).
+ gradientCardGlassBorder: `linear-gradient(180deg, ${alpha(stops.glassWhite, 0.3)} 10%, ${alpha(stops.glassWhiteWeak, 0.3)} 75%, ${alpha(stops.glassGray, 0)} 100%)`,
+ // `Dashboard-Card/Fill/Cyan-Purple`: the flair behind the glass blur. The swatch says
+ // "Radial" but the layer's own def is linear (same label drift as
+ // `gradientCardHighlightRadial` below); cyan at the top of the streak,
+ // periwinkle at the bottom. Alphas are part of the token (37% / 73%).
+ //
+ // The negative first offset is the whole point of this token. In Figma the
+ // ramp is defined over the LAYER box (121.4132 tall, y -19.1782 -> 102.235 in
+ // the export of node 274666:38565), but the crescent actually painted inside
+ // that box only spans y 35.9785 -> 102.235 — its path's cubic
+ // `C327.375 20.2369, 172.023 18.837, 39.3263 83.4893` bottoms out at
+ // t = 0.50983, well short of the control points near y 19.
+ //
+ // So the visible flair never shows pure cyan: its top edge already sits
+ // (35.9785 + 19.1782) / 121.4132 = 45.429% of the way to periwinkle.
+ // Anchoring the ramp at -83.247% reproduces that slice over a box that IS
+ // the crescent — solve (0 - p) / (100 - p) = 0.45429 for p.
+ gradientDashboardCardFillCyanPurple: `linear-gradient(180deg, ${alpha(stops.glassGlowCyan, 0.37)} -83.247%, ${alpha(stops.glassGlowPeriwinkle, 0.73)} 100%)`,
+ // `Card-Glass-CTA-Glow`: mint -> blue -> pink -> gold sweep behind the glass
+ // CTA button, designed to be layer-blurred (~45) by the consumer. Geometry is
+ // the flattened export's own: centre and radii normalised to its viewBox.
+ gradientCardGlassCtaGlow: `radial-gradient(87% 72% at 6.5% 14%, ${stops.glassCtaMint} 44%, ${stops.glassCtaBlue} 50%, ${stops.dataVizPink} 58%, ${stops.glassCtaGold} 99%)`,
+
+ // Measured: the blue stop renders #0a60ff, not the #0a66ff shown on the label.
+ // Ramps slate to blue, matching the toast instances that use it — the swatch
+ // preview runs the other way, but the applied instances are authoritative.
+ gradientGlobalBorderFade: `linear-gradient(90deg, ${alpha(stops.globalBorderSlateWeak, 0.7)} 10%, ${stops.globalBorderSlate} 31%, ${stops.buttonPrimaryGlowCyan} 51%, ${stops.panelExecBorderBlue} 79%)`,
+ // `Input-Border-Blue` — Figma `Input Field` (274417:44475), swatch labelled
+ // "DARK original: FFFFFF -> 0A60FF". Both stops already exist, so this adds
+ // no new colour. Sampling the rendered field's top border confirms a plain
+ // horizontal ramp: near-white at 13% across, (130,173,254) at 50%, and
+ // (39,114,255) at 88%, extrapolating to #0a60ff at the right edge.
+ gradientInputBorderBlue: `linear-gradient(90deg, ${stops.glassWhite} 0%, ${stops.panelExecBorderBlue} 100%)`,
+ // Figma reuses `Global-Border/Fade` for this distinct rainbow ramp.
+ gradientGlobalBorderRainbow: `linear-gradient(90deg, ${stops.panelExecBorderBlue} 0%, ${stops.dataVizCyan} 33%, ${stops.globalDividerPink} 83%, ${stops.dataVizOrange} 100%)`,
+
+ // Shared with every theme — see `gradient-vars-base.ts`.
+ gradientGlobalButtonPrimaryBorderGlow: gradientButtonPrimaryBorderGlow,
+ gradientDashboardGraphNodeBorder: `linear-gradient(90deg, ${alpha(stops.globalBorderSlateWeak, 0.7)} 0%, ${stops.globalBorderSlate} 100%)`,
+ // Alpha ramps UP left to right (6% -> 16%), opposite to the label order.
+ // Originally measured by sampling; the card SVG in `Section 3`
+ // (274455:54313) exports this stroke def verbatim and confirms it exactly,
+ // including the pure-horizontal vector (0,107.5) -> (215,107.5).
+ gradientGraphConnectorStroke: `linear-gradient(90deg, ${alpha(stops.graphConnectorBlue, 0.06)} 0%, ${alpha(stops.graphConnectorBlue, 0.16)} 100%)`,
+ gradientIconButtonBlueGlow: `linear-gradient(90deg, ${stops.iconButtonGlowBlue} 0%, ${alpha(stops.iconButtonGlowBlue, 0)} 100%)`,
+ // Named `Card-Highlight-Radial` in Figma, but the swatch fill is linear.
+ // Measured: starts fully opaque, not at the 70% the label implies.
+ // Vertical, not horizontal: the swatch rectangle is drawn sideways, but the
+ // only place the ramp is applied — the Activity Timeline rail (274455:53823)
+ // — runs it top to bottom. Same swatch-vs-instance split as the toast border.
+ gradientCardHighlightRadial: `linear-gradient(180deg, ${stops.glassWhite} 0%, ${alpha(stops.glassGray, 0)} 100%)`,
+
+ // --- Radial glows --------------------------------------------------------
+ // Figma's intermediate stops are linear interpolations between the designed
+ // stops, so only the designed stops are kept.
+
+ // The three status glows share one geometry: a circle centred on the top-left
+ // corner, matching the dot swatches. `farthest-side` because the timeline dot
+ // export (274455:53827) puts the end stop one box-width from that corner, not
+ // one diagonal — CSS would otherwise default to `farthest-corner` and stretch
+ // the ramp 41% too far, so the end colour never actually landed.
+ gradientGlowOrange: `radial-gradient(circle farthest-side at 0% 0%, ${alpha(stops.glowOrangeStart, 0.8)} 0%, ${alpha(stops.glowOrangeEnd, 0.8)} 100%)`,
+ gradientGlowGreen: `radial-gradient(circle farthest-side at 0% 0%, ${alpha(stops.glowGreenStart, 0.7)} 0%, ${alpha(stops.glowGreenEnd, 0.7)} 100%)`,
+ gradientGlowRed: `radial-gradient(circle farthest-side at 0% 0%, ${alpha(stops.glowRedStart, 0.4)} 0%, ${alpha(stops.glowRedEnd, 0.4)} 100%)`,
+ gradientGlowPinkShadow: `radial-gradient(ellipse at 29% -56%, ${alpha(stops.dataVizPink, 0.5)} 0%, ${alpha(stops.overlayBlack, 0.6)} 100%)`,
+ gradientBackgroundGlowBlue: `radial-gradient(ellipse 50% 100% at 50% 50%, ${alpha(stops.glowBlueStart, 0.3)} 0%, ${alpha(stops.glowBlueMid, 0.3)} 26.923%, ${alpha(stops.glowBlueDeep, 0.3)} 55.769%, ${alpha(stops.glowBlueEnd, 0)} 82.692%)`,
+ // Measured, not radial despite the swatch label — see `gradient-vars-base.ts`.
+ // Shared with every theme.
+ gradientPanelExecBorder: gradientCardInsightBorder,
+ gradientPanelBorderBlueCyanDark: `radial-gradient(ellipse at 50% 50%, ${stops.panelExecBorderBlue} 0%, ${stops.panelBorderCyanDark} 100%)`,
+};
diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx b/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx
index 92fad73d..ed76ce6f 100644
--- a/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx
+++ b/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx
@@ -31,6 +31,7 @@ import {
Shadows,
} from "@mui/material";
import { midnightVars } from "./midnight-vars";
+import { midnightGradientVars } from "./midnight-gradient-vars";
import {
buttonComponent,
inputComponents,
@@ -66,6 +67,7 @@ const palette: PaletteOptions = {
orange: orangePalette,
grey: greyPalette,
vars: midnightVars,
+ gradients: midnightGradientVars,
text: {
primary: midnightVars.baseTextStrong,
secondary: midnightVars.baseTextDefault,
diff --git a/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts b/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts
new file mode 100644
index 00000000..34833be5
--- /dev/null
+++ b/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Theme } from "@mui/material/styles";
+
+import { lightTheme } from "@/theme/light/light-theme";
+import { darkTheme } from "@/theme/dark/dark-theme";
+import { iocTheme } from "@/theme/ioc/ioc-theme";
+import { midnightTheme } from "@/theme/midnight/midnight-theme";
+import { baseGradientVars } from "../gradient-vars-base";
+import { midnightGradientVars } from "@/theme/midnight/midnight-gradient-vars";
+import { gradientsRedPressed } from "../gradients";
+
+const TOKEN_KEYS = Object.keys(baseGradientVars).sort();
+
+const THEMES: ReadonlyArray<[string, Theme]> = [
+ ["light", lightTheme],
+ ["dark", darkTheme],
+ ["ioc", iocTheme],
+ ["midnight", midnightTheme],
+];
+
+describe("gradient vars contract", () => {
+ it("exposes the same token set on every theme", () => {
+ for (const [name, theme] of THEMES) {
+ expect({
+ name,
+ keys: Object.keys(theme.palette.gradients).sort(),
+ }).toEqual({ name, keys: TOKEN_KEYS });
+ }
+ });
+
+ // `palette.vars` needed an explicit re-assert on the IoC theme because the
+ // second `createTheme` pass can drop custom palette keys. Guard `gradients`
+ // against the same failure on all four themes.
+ it("survives createTheme on every theme", () => {
+ for (const [name, theme] of THEMES) {
+ for (const key of TOKEN_KEYS) {
+ const value =
+ theme.palette.gradients[key as keyof typeof baseGradientVars];
+ expect({ name, key, empty: !value }).toEqual({
+ name,
+ key,
+ empty: false,
+ });
+ }
+ }
+ });
+
+ it("resolves Midnight to the Midnight gradient set", () => {
+ expect(midnightTheme.palette.gradients).toEqual(midnightGradientVars);
+ });
+
+ it("falls back to the base set on the non-Midnight themes", () => {
+ expect(lightTheme.palette.gradients).toEqual(baseGradientVars);
+ expect(darkTheme.palette.gradients).toEqual(baseGradientVars);
+ expect(iocTheme.palette.gradients).toEqual(baseGradientVars);
+ });
+
+ it("switches value when the theme switches", () => {
+ expect(midnightTheme.palette.gradients.gradientDataVizCyanBlue).not.toBe(
+ lightTheme.palette.gradients.gradientDataVizCyanBlue,
+ );
+ });
+});
+
+describe("midnight gradient values", () => {
+ // Exact fills — angle is axis-aligned or verified, so these are pinned.
+ it("matches the Figma values for axis-aligned fills", () => {
+ const g = midnightGradientVars;
+
+ expect(g.gradientDataVizCyanBlue).toBe(
+ "linear-gradient(90deg, #5de2e8 0%, #187adc 100%)",
+ );
+ expect(g.gradientGlobalButtonPrimaryFill).toBe(
+ "linear-gradient(90deg, #0745b8 0%, #2e6ee5 100%)",
+ );
+ expect(g.gradientTextWhiteBlue).toBe(
+ "linear-gradient(90deg, #ffffff 0%, #9dbcf7 51.28%, #3f7def 100%)",
+ );
+ expect(g.gradientGaugeArcTeal).toBe(
+ "linear-gradient(90deg, #29fcc4 0%, #00af2f 100%)",
+ );
+ expect(g.gradientDataVizPinkMagenta).toBe(
+ "linear-gradient(180deg, rgba(246, 52, 162, 0.7) 0%, rgba(144, 31, 95, 0.7) 100%)",
+ );
+ expect(g.gradientOverlayBlackFadeIn).toBe(
+ "linear-gradient(180deg, rgba(0, 0, 0, 0.65) 52.404%, rgba(102, 102, 102, 0) 100%)",
+ );
+ });
+
+ // Glow dots — radial gradient from the top-left corner.
+ it("matches the Figma values for the glow dots", () => {
+ expect(midnightGradientVars.gradientGlowGreen).toBe(
+ "radial-gradient(circle farthest-side at 0% 0%, rgba(17, 255, 200, 0.7) 0%, rgba(179, 255, 129, 0.7) 100%)",
+ );
+ expect(midnightGradientVars.gradientGlowOrange).toBe(
+ "radial-gradient(circle farthest-side at 0% 0%, rgba(255, 157, 0, 0.8) 0%, rgba(255, 212, 142, 0.8) 100%)",
+ );
+ expect(midnightGradientVars.gradientGlowRed).toBe(
+ "radial-gradient(circle farthest-side at 0% 0%, rgba(255, 3, 41, 0.4) 0%, rgba(255, 87, 70, 0.4) 100%)",
+ );
+ });
+
+ // `Gradient/Alert-Line-Red` has identical stops and offsets to the existing
+ // `gradientsRedPressed` token, so it reuses it rather than re-deriving the
+ // angle from an aspect-ratio-distorted Figma export.
+ it("reuses the canonical red gradient for Alert-Line-Red", () => {
+ expect(midnightGradientVars.gradientAlertLineRed).toBe(gradientsRedPressed);
+ });
+
+ // Single-stop in Figma, so it must resolve to a flat colour, not a ramp.
+ it("keeps single-stop tokens flat", () => {
+ expect(midnightGradientVars.gradientGraphFlowTeal).toBe(
+ "rgba(1, 117, 128, 0.2)",
+ );
+ });
+
+ // Stroke gradients cannot be read back from the Figma MCP server, so these
+ // values were measured by sampling the rendered swatch. Pinning them guards
+ // against a silent regression to the earlier evenly-distributed guesses.
+ describe("measured stroke geometry", () => {
+ const g = midnightGradientVars;
+
+ it("uses a horizontal ramp for every stroke", () => {
+ const strokes = [
+ g.gradientGlobalBorderFade,
+ g.gradientGlobalBorderRainbow,
+ g.gradientDashboardGraphNodeBorder,
+ g.gradientGraphConnectorStroke,
+ g.gradientIconButtonBlueGlow,
+ ];
+ for (const value of strokes) {
+ expect(value.startsWith("linear-gradient(90deg,")).toBe(true);
+ }
+ });
+
+ // Also swatch-horizontal but applied vertically: the button at
+ // I274405:38087;25258:78851 runs blue along its bottom edge up to cyan,
+ // which pushes the evenly-spaced teal stop off the box entirely.
+ it("runs the button border glow up the button", () => {
+ expect(g.gradientGlobalButtonPrimaryBorderGlow).toBe(
+ "linear-gradient(7.5deg, #3b76ea 10%, #00bceb 90%, #63fff7 170%)",
+ );
+ });
+
+ // The odd one out: its swatch is a horizontal rectangle, but the Activity
+ // Timeline rail that applies it runs the ramp top to bottom.
+ it("runs the card highlight ramp vertically", () => {
+ expect(g.gradientCardHighlightRadial).toBe(
+ "linear-gradient(180deg, #ffffff 0%, rgba(153, 153, 153, 0) 100%)",
+ );
+ });
+
+ // Glass card family — exact values from the SVG exports in `Glass Card`
+ // (274490:55387); see the block comment in `midnight-gradient-vars.ts`.
+ it("anchors the glass fill radial at the top-right corner", () => {
+ expect(g.gradientCardGlassBg).toBe(
+ "radial-gradient(100% 100% at 100% 0%, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.05) 100%)",
+ );
+ });
+
+ it("scales the subtle glass variant to 70% of the fill", () => {
+ expect(g.gradientCardGlassBgSubtle).toBe(
+ "radial-gradient(100% 100% at 100% 0%, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.035) 100%)",
+ );
+ });
+
+ it("fades the glass border out toward the bottom", () => {
+ expect(g.gradientCardGlassBorder).toBe(
+ "linear-gradient(180deg, rgba(255, 255, 255, 0.3) 10%, rgba(241, 241, 241, 0.3) 75%, rgba(153, 153, 153, 0) 100%)",
+ );
+ });
+
+ it("runs the glass flair cyan to periwinkle down the streak", () => {
+ expect(g.gradientDashboardCardFillCyanPurple).toBe(
+ "linear-gradient(180deg, rgba(0, 187, 255, 0.37) -83.247%, rgba(161, 166, 254, 0.73) 100%)",
+ );
+ });
+
+ it("sweeps the CTA glow mint to gold with tight midpoints", () => {
+ expect(g.gradientCardGlassCtaGlow).toBe(
+ "radial-gradient(87% 72% at 6.5% 14%, #74ffc7 44%, #03b3ff 50%, #f634a2 58%, #ffe070 99%)",
+ );
+ });
+
+ // Alpha ramps up left to right, opposite to the order shown on the label.
+ // Confirmed verbatim by the card SVG in `Section 3` (274455:54313).
+ it("ramps the graph connector stroke upward", () => {
+ expect(g.gradientGraphConnectorStroke).toBe(
+ "linear-gradient(90deg, rgba(199, 211, 234, 0.06) 0%, rgba(199, 211, 234, 0.16) 100%)",
+ );
+ });
+
+ // Graph-connector fill and glow — exact, from the same SVG export.
+ it("uses the card-accurate angle for the connector fill", () => {
+ expect(g.gradientGraphConnectorFill).toBe(
+ "linear-gradient(155.21deg, rgba(199, 211, 234, 0.035) 0%, rgba(199, 211, 234, 0.016) 100%)",
+ );
+ });
+
+ it("anchors the connector glow at the bottom of the card", () => {
+ expect(g.gradientGraphConnectorGlow).toBe(
+ "radial-gradient(85% 85% at 50% 100%, rgba(199, 211, 234, 0.064) 0%, rgba(199, 211, 234, 0.008) 100%)",
+ );
+ });
+
+ it("places the rainbow pink stop at 83%", () => {
+ expect(g.gradientGlobalBorderRainbow).toContain("#ff007f 83%");
+ });
+
+ // The label says #0a66ff; the rendered stroke measures #0a60ff. Direction
+ // comes from the toast instances, not the swatch, which runs the other way.
+ it("ramps the border fade slate to blue", () => {
+ expect(g.gradientGlobalBorderFade).toBe(
+ "linear-gradient(90deg, rgba(77, 99, 128, 0.7) 10%, #3d5066 31%, #00bceb 51%, #0a60ff 79%)",
+ );
+ });
+ });
+});
diff --git a/packages/open-ui-kit/src/theme/style/color-palette.ts b/packages/open-ui-kit/src/theme/style/color-palette.ts
index 189f8ab8..ee645ebc 100644
--- a/packages/open-ui-kit/src/theme/style/color-palette.ts
+++ b/packages/open-ui-kit/src/theme/style/color-palette.ts
@@ -317,6 +317,60 @@ export const gradientStopColors = {
secondaryBlueHoverWeak: "#b3cbe7",
} as const;
+// Card insight glow — the blue drop shadow paired with the gradient card
+// border. Figma: `Card/Basic Interactive` (274405:44327).
+//
+// Deliberately NOT added to any theme's `shadows` array: that array is
+// positional and `dialog.test.tsx` asserts index-to-token identity, so
+// inserting an entry would renumber every elevation.
+export const cardInsightGlow = "0px 4px 17px rgba(10, 96, 255, 0.4)";
+
+// Glass card drop shadow. Figma: `Glass Card` (274417:44469), effect variables
+// `4/X: 0`, `4/Y: 12`, `4/Blur: 48`, `4/Spread: 0`, `4/Color: #00000040`.
+//
+// Kept out of the `shadows` array for the same reason as `cardInsightGlow`.
+export const cardGlassShadow = "0px 12px 48px rgba(0, 0, 0, 0.25)";
+
+// Backdrop blur behind the glass card fill. No longer estimated: the updated
+// `Glass Card` frame (274490:55387) exports the card surface as an SVG whose
+// foreignObject carries `backdrop-filter: blur(35.71px)` at the mockup's
+// 0.8928 scale — 35.71 / 0.8928 = 40px. Matches the swatch label
+// `Radial + Background Blur - 72`, since Figma's 71.4 blur radius halves on
+// the way to CSS.
+export const cardGlassBlur = "40px";
+
+// Graph-connector card. Figma: `Section 3` (274455:54313). Both values are
+// read off the card's SVG export rather than estimated: the drop shadow from
+// its `feOffset dx="6"` / `feGaussianBlur stdDeviation="2"` (CSS blur is twice
+// the deviation) and colour matrix `#0d1622` at 0.25, and the blur from the
+// export's own `backdrop-filter`.
+export const cardConnectorShadow = "6px 0px 4px rgba(13, 22, 34, 0.25)";
+export const cardConnectorBlur = "30px";
+
+// Alert card severity label colours. Figma: `Alerts Card` (274421:47415) —
+// critical (274421:47325) and warning (274421:47332).
+//
+// These are the design's literal values rather than the `negativeTextDefault` /
+// `warningTextDefault` semantic tokens, which resolve to `#eebfcb` and
+// `#ffe7c7` — far too pale to read as a severity accent on this surface.
+// Figma binds the critical one to a library variable named `Red/55`, from a red
+// scale this library does not carry; the warning one is the same value already
+// present above as `gaugeArcAmber`, repeated here under its semantic name.
+export const alertCriticalText = "#eb4651";
+export const alertWarningText = "#ffae4c";
+
+// Alert card drop shadow, at the frame's 0.869 scale factor divided out.
+// Kept out of the `shadows` array for the same reason as `cardInsightGlow`.
+export const cardAlertShadow = "0px 4px 4px rgba(0, 0, 0, 0.1)";
+
+// Toast glow. Figma: `Toast message Glow` (274417:44480), the two `Text Card`
+// instances. Both use the same offset and blur; the glow is stronger on the
+// variant that carries a header (0.4) than on the message-only one (0.2).
+// The colour is `panelExecBorderBlue` (#0a60ff), the first stop of the
+// `Global-Border/Fade` ramp that draws the toast's border.
+export const toastGlow = "0px -1px 34px rgba(10, 96, 255, 0.2)";
+export const toastGlowStrong = "0px -1px 34px rgba(10, 96, 255, 0.4)";
+
// Light Mode Box Shadows
export const lightModeCardLifted = "0px 4px 4px rgba(200, 213, 245, 0.33)";
export const lightModeCardSubtle = "0px 2px 5px rgba(200, 213, 245, 0.4)";
@@ -654,3 +708,104 @@ export const electricBluePalette = {
alpha40: electricBlueAlpha40,
alpha10: electricBlueAlpha10,
};
+
+// Midnight gradient stops.
+//
+// Source: Figma "OXP" (wvTxiCkZBmP2jH24hzydHR), frame 274405:38026
+// "Components with Gradients". Keys are named after the Figma variable where
+// one exists (e.g. `Gradient/Graph-Flow/Teal` -> `graphFlowTeal`); the rest are
+// named after the component that consumes them.
+//
+// This mirrors the existing `gradientStopColors` block: gradient ramps need
+// intermediate colors that do not belong to a semantic 50-900 hue scale.
+// Stops that DO already exist are not duplicated here — reuse `teal300`
+// (#5de2e8), `blue300` (#187adc), `blue500` (#0051af), `red800` (#b11939),
+// `red900` (#a40f29) and `illustrationNegativeGradientStart` (#e09e89).
+export const midnightGradientStops = {
+ // Graph flow (Figma variables: Gradient/Graph-Flow/*)
+ graphFlowGray: "#c6c6c6",
+ graphFlowTeal: "#017580",
+ graphFlowMaroon: "#892727",
+
+ // Data viz
+ dataVizPink: "#f634a2", // Gradient/Dashboard-Background-Ring/Stroke/Stop-2-Pink
+ dataVizMagenta: "#901f5f",
+ dataVizPurple: "#8118dc",
+ dataVizCyan: "#02c8ff",
+ dataVizTeal: "#197690", // Gradient/Data-Viz-Cyan-Teal/Stop-1-Teal
+ dataVizOrange: "#ff9000",
+ dataVizAmber: "#995600",
+ dataVizGold: "#dcae18",
+ dataVizBlue: "#3b82f6",
+ dataVizMint: "#77e7cd",
+ dataVizFadeGray: "#d9d9d9",
+
+ // Gauge arc (Figma variables: Gradient/Gauge-Arc-*)
+ gaugeArcCyan: "#29b0fc",
+ gaugeArcTealStart: "#29fcc4",
+ gaugeArcTealEnd: "#00af2f",
+ gaugeArcAmber: "#ffae4c",
+
+ // Buttons and icons
+ buttonPrimaryFillStart: "#0745b8",
+ buttonPrimaryFillEnd: "#2e6ee5",
+ buttonPrimaryGlowBlue: "#3b76ea", // Gradient/Global-Button-Primary/Border-Glow/Stop-0-Blue
+ buttonPrimaryGlowCyan: "#00bceb", // ...Stop-1-Cyan
+ buttonPrimaryGlowTeal: "#63fff7", // ...Stop-2-Teal
+ iconSubtractBlue: "#5096ff",
+ iconButtonBlueStart: "#043abc",
+ iconButtonBlueMid: "#113ca1",
+ iconButtonBlueEnd: "#011d62",
+ iconButtonGlowBlue: "#3974ff",
+
+ // Text (Figma variable: Gradient/Text-White-Blue)
+ textWhiteBlueStart: "#ffffff",
+ // Figma emits this stop at 51.28%. It sits exactly on the line between the
+ // two ends, so it changes nothing visually — carried so the token reads the
+ // same as the design's own CSS.
+ textWhiteBlueMid: "#9dbcf7",
+ textWhiteBlueEnd: "#3f7def",
+
+ // Panels, borders and connectors
+ panelExecBorderBlue: "#0a60ff", // Gradient/Panel-Exec-Border/Stop-0-Blue
+ panelBorderCyanDark: "#16bdeb", // Gradient/Panel-Border-Blue-Cyan-Dark/Stop-1-Cyan
+ // NOTE: the `Global-Border/Fade` swatch labels its first stop #0a66ff, but the
+ // rendered stroke measures #0a60ff (`panelExecBorderBlue`). The label appears
+ // to be stale, so no #0a66ff stop is defined here.
+ globalBorderSlate: "#3d5066",
+ globalBorderSlateWeak: "#4d6380",
+ globalDividerPink: "#ff007f",
+ graphConnectorBlue: "#c7d3ea", // Gradient/Graph-Connector-Stroke/Stop-*
+ graphNodeFillBlue: "#2972ff",
+ graphNodeFillDark: "#01060d",
+
+ // Surfaces and overlays
+ welcomeCardStart: "#060b26",
+ welcomeCardEnd: "#1a1f37",
+ overlayBlack: "#000000",
+ overlayGray: "#666666",
+ glassWhite: "#ffffff",
+ glassWhiteWeak: "#f1f1f1",
+
+ // Glass card flair and CTA glow. Figma: `Glass Card` (274490:55387) —
+ // swatches `Gradient/Dashboard-Card/Fill/Cyan-Purple` and `Gradient/Card-Glass-CTA-Glow`.
+ // The CTA's pink stop is the existing `dataVizPink`, so it is not repeated.
+ glassGlowCyan: "#00bbff", // Dashboard-Card/Fill/Cyan-Purple/Stop-0
+ glassGlowPeriwinkle: "#a1a6fe", // Dashboard-Card/Fill/Cyan-Purple/Stop-1
+ glassCtaMint: "#74ffc7", // Card-Glass-CTA-Glow/Stop-0
+ glassCtaBlue: "#03b3ff", // Card-Glass-CTA-Glow/Stop-1
+ glassCtaGold: "#ffe070", // Card-Glass-CTA-Glow/Stop-3
+ glassGray: "#999999",
+
+ // Glows
+ glowOrangeStart: "#ff9d00",
+ glowOrangeEnd: "#ffd48e",
+ glowGreenStart: "#11ffc8",
+ glowGreenEnd: "#b3ff81",
+ glowRedStart: "#ff0329",
+ glowRedEnd: "#ff5746",
+ glowBlueStart: "#4a6ac8",
+ glowBlueMid: "#6abfff",
+ glowBlueDeep: "#3b69bc",
+ glowBlueEnd: "#1b4288",
+} as const;
diff --git a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts
new file mode 100644
index 00000000..632a8387
--- /dev/null
+++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { GradientVarsType } from "@/types/gradient-vars";
+import { midnightGradientStops as stops } from "./color-palette";
+import {
+ gradientsBackgroundDark,
+ gradientsIllustrationsBlue,
+ gradientsIllustrationsGreen,
+ gradientsIllustrationsLightBlue,
+ gradientsIllustrationsOrange,
+ gradientsIllustrationsPink,
+ gradientsIllustrationsPurple,
+ gradientsIllustrationsRainbow,
+ gradientsRedPressed,
+ gradientsSecondaryDefault,
+ gradientsSecondaryHover,
+} from "./gradients";
+
+/*
+ * Button gradients — shared by every theme.
+ *
+ * Unlike the rest of this file these are real design values, not placeholders.
+ * Design has not diverged them per theme and the Button gradient variants must
+ * look identical everywhere, so they are defined once here and re-used by
+ * `midnight-gradient-vars.ts` instead of being overridden per theme.
+ * Source: Figma frames 274405:44106 and I274405:38087;25258:78851.
+ */
+export const gradientButtonPrimaryFill = `linear-gradient(90deg, ${stops.buttonPrimaryFillStart} 0%, ${stops.buttonPrimaryFillEnd} 100%)`;
+
+/*
+ * The ramp runs bottom-to-top, not left-to-right: the `Border-Glow` swatch is a
+ * horizontal rectangle, but the button that applies it
+ * (I274405:38087;25258:78851) carries the ramp up its 105 x 28 box, blue along
+ * the bottom edge to cyan along the top. Same swatch-vs-application split as
+ * `gradientCardHighlightRadial`.
+ *
+ * The stroke's paint cannot be read back through the MCP server, so the
+ * geometry was fitted to the rendered node: sampling its straight edges gives a
+ * ramp 7.5 degrees off vertical whose blue and cyan stops sit at 10% and 90% of
+ * the gradient line. The three stops stay evenly spaced, which puts teal at
+ * 170% — off the box, which is why the frame shows no teal at this size. Those
+ * numbers reproduce the render to a maximum channel error of 3/255; the
+ * previous `90deg, 0/50/100` averaged 46.
+ *
+ * The angle is exact for the frame's aspect ratio. Figma maps the handle
+ * through the layer's bounding box, so a much wider button tilts further there
+ * than a fixed CSS angle can follow — immaterial at this tilt.
+ */
+export const gradientButtonPrimaryBorderGlow = `linear-gradient(7.5deg, ${stops.buttonPrimaryGlowBlue} 10%, ${stops.buttonPrimaryGlowCyan} 90%, ${stops.buttonPrimaryGlowTeal} 170%)`;
+
+/*
+ * Card insight border — `Gradient/Panel-Exec-Border`, shared by every theme.
+ *
+ * The Figma swatch is LABELLED "Radial", but it is not. Sampling the border of
+ * the rendered `Card/Basic Interactive` (274405:44327) and of the swatch itself
+ * gives the same diagonal signature — the top edge ramps blue -> cyan -> slate,
+ * the right edge is uniformly slate, and the left edge holds blue before
+ * turning cyan near the bottom. That is a diagonal linear ramp.
+ *
+ * Angle and offsets are measured from the 318x214 card, which is the real
+ * usage. Figma normalises gradient transforms to the layer box, so the same
+ * token measures differently on the 120x60 documentation swatch — re-measure
+ * before reusing this on a very differently proportioned surface.
+ */
+export const gradientCardInsightBorder = `linear-gradient(135deg, ${stops.panelExecBorderBlue} 25%, ${stops.dataVizCyan} 34%, ${stops.globalBorderSlate} 60%, rgba(77, 99, 128, 0.7) 100%)`;
+
+/* Text gradient — shared by every theme, like the button gradients above.
+ *
+ * A real design value (not a placeholder). Design has not diverged it per theme,
+ * so it is defined once and reused everywhere via `background-clip: text`.
+ * Source: Figma "OXP" (wvTxiCkZBmP2jH24hzydHR), `Gradient/Text-White-Blue`
+ * (frame 274405:44228, "Welcome Amy!"), white -> blue, left to right.
+ */
+export const gradientTextWhiteBlue = `linear-gradient(90deg, ${stops.textWhiteBlueStart} 0%, ${stops.textWhiteBlueMid} 51.28%, ${stops.textWhiteBlueEnd} 100%)`;
+
+/*
+ * Base gradient tokens — the fallback every theme starts from.
+ *
+ * Apart from the three shared gradients above (the two button gradients and the
+ * text gradient), this deliberately contains NO new design values.
+ *
+ * The gradient set was designed for the Midnight theme only (Figma "OXP",
+ * frame 274405:38026). Light, Dark and IoC have no design-approved gradients
+ * yet, but `GradientVarsType` is a total contract, so they need a value for
+ * every key. Each remaining entry is therefore mapped to the closest gradient
+ * this library already ships in `gradientsPalette` — chosen so components
+ * render something on-brand rather than nothing.
+ *
+ * Those are PROVISIONAL, not design-approved. When design delivers Light /
+ * Dark / IoC gradients, add a `*-gradient-vars.ts` per theme that spreads this
+ * object and overrides, exactly as `midnight-gradient-vars.ts` does.
+ *
+ * Midnight overrides every provisional entry, so only the three shared
+ * gradients above (the two button gradients and the text gradient) reach the
+ * Midnight theme from this file.
+ */
+export const baseGradientVars: GradientVarsType = {
+ // --- Fills ---------------------------------------------------------------
+ gradientGaugeArcAmber: gradientsIllustrationsOrange,
+ gradientGaugeArcTeal: gradientsIllustrationsGreen,
+ gradientIconSubtractBlue: gradientsSecondaryDefault,
+ gradientDataVizCyanTeal: gradientsIllustrationsBlue,
+ gradientDataVizCyanBlue: gradientsIllustrationsLightBlue,
+ gradientDataVizPinkMagenta: gradientsIllustrationsPink,
+ gradientDataVizOrangeAmber: gradientsIllustrationsOrange,
+ gradientDataVizPinkFlat: gradientsIllustrationsPink,
+ gradientDataVizPinkPurple: gradientsIllustrationsPurple,
+ gradientDataVizOrangeGold: gradientsIllustrationsOrange,
+ gradientDataVizBlueDark: gradientsSecondaryDefault,
+ gradientProgressBarTeal: gradientsIllustrationsGreen,
+ gradientGraphFlow: gradientsIllustrationsBlue,
+ gradientGraphFlowPink: gradientsIllustrationsPink,
+ gradientGraphFlowTeal: gradientsIllustrationsGreen,
+ gradientGraphFlowMaroon: gradientsRedPressed,
+ gradientGlobalButtonPrimaryFill: gradientButtonPrimaryFill,
+ gradientTextWhiteBlue,
+ gradientGlobalDividerFade: gradientsIllustrationsRainbow,
+ gradientOverlayBlackFadeIn: gradientsBackgroundDark,
+ gradientWelcomeCardBgDark: gradientsBackgroundDark,
+ gradientAlertLineRed: gradientsRedPressed,
+ gradientDashboardGraphNodeFill: gradientsSecondaryDefault,
+ gradientGraphConnectorFill: gradientsSecondaryHover,
+ gradientGraphConnectorGlow: gradientsSecondaryHover,
+ gradientIconButtonBlue: gradientsSecondaryDefault,
+
+ // --- Strokes -------------------------------------------------------------
+ gradientCardGlassBg: gradientsSecondaryHover,
+ gradientCardGlassBgSubtle: gradientsSecondaryHover,
+ gradientCardGlassBorder: gradientsSecondaryHover,
+ gradientDashboardCardFillCyanPurple: gradientsSecondaryHover,
+ gradientCardGlassCtaGlow: gradientsIllustrationsRainbow,
+ gradientGlobalBorderFade: gradientsSecondaryDefault,
+ gradientInputBorderBlue: gradientsSecondaryDefault,
+ gradientGlobalBorderRainbow: gradientsIllustrationsRainbow,
+ gradientGlobalButtonPrimaryBorderGlow: gradientButtonPrimaryBorderGlow,
+ gradientDashboardGraphNodeBorder: gradientsSecondaryDefault,
+ gradientGraphConnectorStroke: gradientsSecondaryHover,
+ gradientIconButtonBlueGlow: gradientsIllustrationsLightBlue,
+ gradientCardHighlightRadial: gradientsSecondaryHover,
+
+ // --- Radial glows --------------------------------------------------------
+ gradientGlowOrange: gradientsIllustrationsOrange,
+ gradientGlowGreen: gradientsIllustrationsGreen,
+ gradientGlowRed: gradientsRedPressed,
+ gradientGlowPinkShadow: gradientsIllustrationsPink,
+ gradientBackgroundGlowBlue: gradientsIllustrationsBlue,
+ gradientPanelExecBorder: gradientCardInsightBorder,
+ gradientPanelBorderBlueCyanDark: gradientsSecondaryDefault,
+};
diff --git a/packages/open-ui-kit/src/types/gradient-vars.ts b/packages/open-ui-kit/src/types/gradient-vars.ts
new file mode 100644
index 00000000..b97a7453
--- /dev/null
+++ b/packages/open-ui-kit/src/types/gradient-vars.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2025 Cisco Systems, Inc. and its affiliates
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Theme-aware gradient tokens.
+ *
+ * Mirrors `VarsType`: a single flat contract that every theme must implement,
+ * mounted on the MUI palette as `palette.gradients`. Components read
+ * `theme.palette.gradients.gradientDataVizCyanBlue` with no theme branching —
+ * swapping the theme swaps the value, exactly like `palette.vars`.
+ *
+ * Token names come from the `gradient-token` labels in Figma "OXP"
+ * (wvTxiCkZBmP2jH24hzydHR), frame 274405:38026 "Components with Gradients",
+ * camelCased from the Figma path: `Gradient/Data-Viz-Cyan-Blue` ->
+ * `gradientDataVizCyanBlue`.
+ */
+export interface GradientVarsType {
+ // --- Fills ---------------------------------------------------------------
+ gradientGaugeArcAmber: string;
+ gradientGaugeArcTeal: string;
+ gradientIconSubtractBlue: string;
+ gradientDataVizCyanTeal: string;
+ gradientDataVizCyanBlue: string;
+ gradientDataVizPinkMagenta: string;
+ /** Renamed: Figma labels this `Data-Viz-Pink-Magenta` but the stops are orange. */
+ gradientDataVizOrangeAmber: string;
+ /** Renamed: Figma labels this `Data-Viz-Pink-Magenta`; single stop, renders flat. */
+ gradientDataVizPinkFlat: string;
+ gradientDataVizPinkPurple: string;
+ gradientDataVizOrangeGold: string;
+ gradientDataVizBlueDark: string;
+ gradientProgressBarTeal: string;
+ gradientGraphFlow: string;
+ gradientGraphFlowPink: string;
+ gradientGraphFlowTeal: string;
+ /** Renamed: Figma reuses `Graph-Flow` for this distinct maroon ramp. */
+ gradientGraphFlowMaroon: string;
+ gradientGlobalButtonPrimaryFill: string;
+ /** Gradient text fill (`background-clip: text`). Figma: `Gradient/Text-White-Blue`. */
+ gradientTextWhiteBlue: string;
+ gradientGlobalDividerFade: string;
+ gradientOverlayBlackFadeIn: string;
+ gradientWelcomeCardBgDark: string;
+ gradientAlertLineRed: string;
+ gradientDashboardGraphNodeFill: string;
+ gradientGraphConnectorFill: string;
+ /** `Graph-Connector-Glow`: radial lift from the bottom edge of the card. */
+ gradientGraphConnectorGlow: string;
+ gradientIconButtonBlue: string;
+
+ // --- Strokes -------------------------------------------------------------
+ // Applied to borders. Colors and stop order are correct; angles and stop
+ // offsets are approximate — see midnight-gradient-vars.ts.
+ gradientCardGlassBg: string;
+ /** Renamed: same stops as `gradientCardGlassBg` at 70% layer opacity. */
+ gradientCardGlassBgSubtle: string;
+ /** `Card-Glass-BORDER`: white hairline that fades out toward the bottom. */
+ gradientCardGlassBorder: string;
+ /** `Dashboard-Card/Fill/Cyan-Purple`: cyan-to-periwinkle flair behind the glass blur. */
+ gradientDashboardCardFillCyanPurple: string;
+ /** `Card-Glass-CTA-Glow`: mint-blue-pink-gold glow behind the glass CTA. */
+ gradientCardGlassCtaGlow: string;
+ gradientGlobalBorderFade: string;
+ /** `Input-Border-Blue`: white-to-blue ramp around the prompt input field. */
+ gradientInputBorderBlue: string;
+ /** Renamed: Figma reuses `Global-Border/Fade` for this distinct rainbow ramp. */
+ gradientGlobalBorderRainbow: string;
+ gradientGlobalButtonPrimaryBorderGlow: string;
+ gradientDashboardGraphNodeBorder: string;
+ gradientGraphConnectorStroke: string;
+ gradientIconButtonBlueGlow: string;
+ /** Named `Card-Highlight-Radial` in Figma, but the swatch fill is linear. */
+ gradientCardHighlightRadial: string;
+
+ // --- Radial glows --------------------------------------------------------
+ gradientGlowOrange: string;
+ gradientGlowGreen: string;
+ gradientGlowRed: string;
+ gradientGlowPinkShadow: string;
+ gradientBackgroundGlowBlue: string;
+ gradientPanelExecBorder: string;
+ gradientPanelBorderBlueCyanDark: string;
+}
diff --git a/packages/open-ui-kit/src/types/theme.ts b/packages/open-ui-kit/src/types/theme.ts
index 5ef50f43..4d775c28 100644
--- a/packages/open-ui-kit/src/types/theme.ts
+++ b/packages/open-ui-kit/src/types/theme.ts
@@ -13,6 +13,7 @@ import "@mui/material/Tab";
import React from "react";
import type { Color } from "@mui/material/styles";
import { VarsType } from "./vars";
+import { GradientVarsType } from "./gradient-vars";
declare module "@mui/material/styles" {
interface Palette {
@@ -20,6 +21,7 @@ declare module "@mui/material/styles" {
negative: Palette["primary"];
orange: Palette["primary"];
vars: VarsType;
+ gradients: GradientVarsType;
}
interface PaletteOptions {
@@ -27,6 +29,7 @@ declare module "@mui/material/styles" {
negative?: PaletteOptions["primary"];
orange?: PaletteOptions["primary"];
vars?: VarsType;
+ gradients?: GradientVarsType;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface -- MUI module augmentation hook (same as `ColorPartial` in createPalette)
@@ -68,7 +71,11 @@ declare module "@mui/material/Button" {
contained: false;
primary: true;
secondary: true;
+ /** Gradient background fill. Figma: `Gradient/Global-Button-Primary/Fill`. */
+ gradient: true;
outlined: true;
+ /** Gradient border ring. Figma: `Gradient/Global-Button-Primary/Border-Glow`. */
+ gradientOutlined: true;
tertariary: true;
}
@@ -106,4 +113,12 @@ declare module "@mui/material/Typography" {
body2Semibold: true;
headingSubSection: true;
}
+
+ interface TypographyOwnProps {
+ /**
+ * Fill the text with a gradient (`background-clip: text`) instead of a
+ * flat color. Composes with any `variant`. Figma: `Gradient/Text-White-Blue`.
+ */
+ gradient?: boolean;
+ }
}
diff --git a/packages/open-ui-kit/src/typography/typography.mdx b/packages/open-ui-kit/src/typography/typography.mdx
index c1adfcd1..fb37b885 100644
--- a/packages/open-ui-kit/src/typography/typography.mdx
+++ b/packages/open-ui-kit/src/typography/typography.mdx
@@ -73,7 +73,8 @@ import { TypographySection } from "./typography-row";
{
token: "h6",
label: "Title h6",
- usage: "Used for section titles, accordion headers, and first-level titles within a container.",
+ usage:
+ "Used for section titles, accordion headers, and first-level titles within a container.",
family: "Sharp Sans, sans-serif",
fontWeight: 700,
fontWeightLabel: "Sharp Sans Bold",
@@ -84,7 +85,8 @@ import { TypographySection } from "./typography-row";
{
token: "headingSubSection",
label: "Title h7",
- usage: "Used for sub-section titles, and second-level titles within a container.",
+ usage:
+ "Used for sub-section titles, and second-level titles within a container.",
family: "Sharp Sans, sans-serif",
fontWeight: 700,
fontWeightLabel: "Sharp Sans Bold",
@@ -101,7 +103,8 @@ import { TypographySection } from "./typography-row";
{
token: "subtitle1",
label: "Subtitle 1",
- usage: "Used for first-level titles within a sub container or subsection.",
+ usage:
+ "Used for first-level titles within a sub container or subsection.",
family: "Inter, sans-serif",
fontWeight: 500,
fontWeightLabel: "Inter Medium",
@@ -113,7 +116,8 @@ import { TypographySection } from "./typography-row";
{
token: "subtitle2",
label: "Subtitle 2",
- usage: "Used for first-level titles within a sub container or subsection.",
+ usage:
+ "Used for first-level titles within a sub container or subsection.",
family: "Inter, sans-serif",
fontWeight: 500,
fontWeightLabel: "Inter Medium",
@@ -131,7 +135,8 @@ import { TypographySection } from "./typography-row";
{
token: "body1Semibold",
label: "Body 1 SemiBold",
- usage: "Used for first-level titles within a sub container or subsection, and to section paragraphs.",
+ usage:
+ "Used for first-level titles within a sub container or subsection, and to section paragraphs.",
family: "Inter, sans-serif",
fontWeight: 600,
fontWeightLabel: "Inter Semibold",
@@ -142,7 +147,8 @@ import { TypographySection } from "./typography-row";
{
token: "body2Semibold",
label: "Body 2 SemiBold",
- usage: "Used for first-level titles within a sub container or subsection, and to section paragraphs.",
+ usage:
+ "Used for first-level titles within a sub container or subsection, and to section paragraphs.",
family: "Inter, sans-serif",
fontWeight: 600,
fontWeightLabel: "Inter Semibold",
@@ -177,7 +183,8 @@ import { TypographySection } from "./typography-row";
{
token: "captionSemibold",
label: "Caption Bold",
- usage: "Used for third-level paragraph text, or small text within components.",
+ usage:
+ "Used for third-level paragraph text, or small text within components.",
family: "Inter, sans-serif",
fontWeight: 600,
fontWeightLabel: "Inter Bold",
@@ -189,7 +196,8 @@ import { TypographySection } from "./typography-row";
{
token: "captionMedium",
label: "Caption Medium",
- usage: "Used for third-level paragraph text, or small text within components.",
+ usage:
+ "Used for third-level paragraph text, or small text within components.",
family: "Inter, sans-serif",
fontWeight: 500,
fontWeightLabel: "Inter Medium",
@@ -201,7 +209,8 @@ import { TypographySection } from "./typography-row";
{
token: "caption",
label: "Caption",
- usage: "Used for third-level paragraph text, or small text within components.",
+ usage:
+ "Used for third-level paragraph text, or small text within components.",
family: "Inter, sans-serif",
fontWeight: 400,
fontWeightLabel: "Inter",
diff --git a/packages/open-ui-kit/tsconfig.localtest.json b/packages/open-ui-kit/tsconfig.localtest.json
new file mode 100644
index 00000000..5b0fe1f4
--- /dev/null
+++ b/packages/open-ui-kit/tsconfig.localtest.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "typeRoots": ["../../node_modules/@types", "./src/types"],
+ "types": ["jest", "node"]
+ }
+}
diff --git a/scripts/prepare-release-package.cjs b/scripts/prepare-release-package.cjs
index 66724bfc..bf999870 100644
--- a/scripts/prepare-release-package.cjs
+++ b/scripts/prepare-release-package.cjs
@@ -102,3 +102,4 @@ if (require.main === module) {
}
module.exports = { prepareReleasePackage };
+
\ No newline at end of file