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 ( + + + + + +
+ + {stroke && strokeWidth > 0 && ( + + )} + {stroke && + dotRadius > 0 && + points.map((p, i) => ( + + ))} + + ); +}; + +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. + + + + + + + ), + }, +}; + 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