From b3be1fe122639af5877daacb160baa9943929463 Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Tue, 21 Jul 2026 12:25:53 +0530 Subject: [PATCH 01/23] feat: add Midnight theme foundation Add Midnight as a fourth built-in theme (Light/Dark/IoC/Midnight): - New Dark Navy and Electric Blue palettes in color-palette.ts - midnight-vars.ts (dark-family vars + Midnight overrides) - midnight-theme.tsx - Wire ThemeMode.Midnight into theme-provider and barrel export - Add Midnight to the Storybook theme switcher Signed-off-by: Yashwant Rautela --- packages/open-ui-kit/.storybook/preview.ts | 12 ++ packages/open-ui-kit/src/index.ts | 1 + .../src/theme-provider/theme-provider.tsx | 6 + .../src/theme/midnight/midnight-theme.tsx | 129 ++++++++++++++++++ .../src/theme/midnight/midnight-vars.ts | 116 ++++++++++++++++ .../src/theme/style/color-palette.ts | 24 ++++ 6 files changed, 288 insertions(+) create mode 100644 packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx create mode 100644 packages/open-ui-kit/src/theme/midnight/midnight-vars.ts diff --git a/packages/open-ui-kit/.storybook/preview.ts b/packages/open-ui-kit/.storybook/preview.ts index df8a41af..923871e2 100644 --- a/packages/open-ui-kit/.storybook/preview.ts +++ b/packages/open-ui-kit/.storybook/preview.ts @@ -11,6 +11,7 @@ import { withScreenshot } from "@prantlf/storycap"; import { darkTheme } from "../src/theme/dark/dark-theme"; import { iocTheme } from "../src/theme/ioc/ioc-theme"; import { lightTheme } from "../src/theme/light/light-theme"; +import { midnightTheme } from "../src/theme/midnight/midnight-theme"; const docsTheme = create({ base: "light", @@ -45,6 +46,7 @@ const muiThemeDecorator = withThemeFromJSXProvider({ light: lightTheme, dark: darkTheme, ioc: iocTheme, + midnight: midnightTheme, }, }); @@ -52,6 +54,7 @@ const themeBackgrounds = { light: "#EFF3FC", dark: "#00142B", ioc: "#07111F", + midnight: "#060A0F", }; const themeBackgroundTokens = { @@ -79,6 +82,14 @@ const themeBackgroundTokens = { previewBackground: "#07111F", text: "rgba(255, 255, 255, 0.94)", }, + midnight: { + background: themeBackgrounds.midnight, + border: "#3A4E77", + link: "#558BFF", + mutedText: "#C5C7CB", + previewBackground: "#060A0F", + text: "#E8E9EA", + }, }; const getThemeBackground = (theme?: string) => @@ -223,6 +234,7 @@ export const globalTypes = { { value: "light", icon: "sun", title: "Light" }, { value: "dark", icon: "moon", title: "Dark" }, { value: "ioc", icon: "mirror", title: "IoC" }, + { value: "midnight", icon: "starhollow", title: "Midnight" }, ], showName: false, dynamicTitle: false, diff --git a/packages/open-ui-kit/src/index.ts b/packages/open-ui-kit/src/index.ts index c8d10a4b..d2232e0e 100644 --- a/packages/open-ui-kit/src/index.ts +++ b/packages/open-ui-kit/src/index.ts @@ -17,6 +17,7 @@ export { gradientsPalette } from "./theme/style/gradients"; 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 type { VarsType } from "./types/vars"; export { ThemeMode, diff --git a/packages/open-ui-kit/src/theme-provider/theme-provider.tsx b/packages/open-ui-kit/src/theme-provider/theme-provider.tsx index bc2bdd6e..ae5db366 100644 --- a/packages/open-ui-kit/src/theme-provider/theme-provider.tsx +++ b/packages/open-ui-kit/src/theme-provider/theme-provider.tsx @@ -14,12 +14,14 @@ import { import { darkTheme } from "@/theme/dark/dark-theme"; import { iocTheme } from "@/theme/ioc/ioc-theme"; import { lightTheme } from "@/theme/light/light-theme"; +import { midnightTheme } from "@/theme/midnight/midnight-theme"; export { useTheme } from "@mui/material"; export enum ThemeMode { Light = "light", Dark = "dark", IoC = "ioc", + Midnight = "midnight", } export interface ThemeModeContextValue { @@ -57,6 +59,10 @@ function resolveBuiltInTheme(mode: ThemeMode): Theme { return iocTheme; } + if (mode === ThemeMode.Midnight) { + return midnightTheme; + } + return mode === ThemeMode.Dark ? darkTheme : lightTheme; } diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx b/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx new file mode 100644 index 00000000..92fad73d --- /dev/null +++ b/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx @@ -0,0 +1,129 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ +import { + bluePalette, + greenPalette, + greyPalette, + lightOrangePalette, + orangePalette, + redPalette, + surfaceDarkPalette, + darkNavy200, + darkNavy400, + darkModeCardFloating, + darkModeCardLifted, + darkModeCardRaised, + darkModeCardSubtle, + darkModeFooterBottom, + darkModeSideDrawerLeft, + darkModeSideDrawerRight, +} from "@/theme/style/color-palette"; +import { breakpoints, commonMixins } from "@/theme/style/common"; +import { typography } from "@/theme/style/typography"; +import { + createTheme, + PaletteOptions, + ThemeOptions, + Theme, + Shadows, +} from "@mui/material"; +import { midnightVars } from "./midnight-vars"; +import { + buttonComponent, + inputComponents, + snackbarComponent, +} from "@/theme/mui"; + +// Midnight reuses the Dark theme's shadow set (identical drop-shadow values). +export const shadows: Shadows = [ + `none`, + darkModeCardLifted, + darkModeCardSubtle, + darkModeCardRaised, + darkModeCardFloating, + darkModeSideDrawerRight, + darkModeSideDrawerLeft, + darkModeFooterBottom, + ...Array(17).fill("none"), +] as Shadows; + +const palette: PaletteOptions = { + mode: "dark", + primary: bluePalette, + secondary: { + ...surfaceDarkPalette, + main: surfaceDarkPalette[500], + }, + tertiary: lightOrangePalette, + error: redPalette, + warning: lightOrangePalette, + info: bluePalette, + success: greenPalette, + negative: redPalette, + orange: orangePalette, + grey: greyPalette, + vars: midnightVars, + text: { + primary: midnightVars.baseTextStrong, + secondary: midnightVars.baseTextDefault, + disabled: midnightVars.baseTextDisabled, + }, + background: { + paper: darkNavy200, + default: darkNavy400, + }, + action: { + hoverOpacity: 0.05, + selectedOpacity: 0.25, + focusOpacity: 0.18, + }, +}; + +const theme: Theme = createTheme({ + breakpoints, + palette, + typography, + mixins: commonMixins, +}); + +const midnightThemeOptions: ThemeOptions = { + shadows, + components: { + ...buttonComponent(theme), + ...inputComponents(theme), + ...snackbarComponent(theme), + MuiCssBaseline: { + styleOverrides: { + html: { + scrollbarWidth: "thin", + scrollbarColor: `${theme.palette.vars.baseTextMedium} ${theme.palette.background.default}`, + }, + "*::-webkit-scrollbar": { + width: "12px", + height: "12px", + }, + "*::-webkit-scrollbar-track": { + backgroundColor: theme.palette.background.default, + borderRadius: 8, + }, + "*::-webkit-scrollbar-thumb": { + backgroundColor: theme.palette.vars.controlIconMedium, + borderRadius: 8, + border: "2px solid transparent", + backgroundClip: "content-box", + }, + "*::-webkit-scrollbar-thumb:hover": { + backgroundColor: theme.palette.vars.baseTextMedium, + }, + "*::-webkit-scrollbar-corner": { + backgroundColor: theme.palette.background.default, + }, + }, + }, + }, +}; + +export const midnightTheme: Theme = createTheme(theme, midnightThemeOptions); diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts new file mode 100644 index 00000000..d4db6fe0 --- /dev/null +++ b/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VarsType } from "@/types/vars"; +import { darkVars } from "@/theme/dark/dark-vars"; +import { + surfaceDarkPalette, + greyPalette, + darkNavyPalette, + electricBluePalette, + surfaceDark900Alpha10, +} from "@/theme/style/color-palette"; + +// Midnight is a dark-family theme. It shares most semantic tokens with the +// Dark theme, so it is composed as `darkVars` plus the tokens that Midnight +// redefines: deep "Dark Navy" surfaces, "Electric Blue" primary/interactive, +// and the associated border/background shifts. Values are mapped from the +// Figma "Accordion - Midnight" / Midnight token set. +export const midnightVars: VarsType = { + ...darkVars, + + // Base — Dark Navy surfaces + baseBackgroundStrong: darkNavyPalette[400], // #060a0f canvas + baseBackgroundMedium: darkNavyPalette[200], // #0a141f + baseBackgroundWeak: darkNavyPalette[100], // #1e293b + baseBackgroundHover: darkNavyPalette[300], // #0f1623 + baseBorderDefault: surfaceDarkPalette[300], // #263b62 + baseBorderStrong: surfaceDarkPalette[100], // #3a4e77 + baseBorderMedium: surfaceDarkPalette[200], // #31466e + baseBorderWeak: surfaceDarkPalette[500], // #0d274d + + // Control + controlBackgroundDefault: darkNavyPalette[200], + controlBackgroundWeak: darkNavyPalette[100], + controlBackgroundMedium: darkNavyPalette[100], + controlBackgroundDisabled: darkNavyPalette[200], + controlBorderDefault: surfaceDarkPalette[100], // #3a4e77 + controlBorderStrong: surfaceDarkPalette[200], // #31466e + controlBorderMedium: surfaceDarkPalette[300], // #263b62 + controlBorderWeak: surfaceDarkPalette[400], // #183056 + controlBorderHover: electricBluePalette[700], // #1469cc + controlBorderActive: electricBluePalette[700], + controlBorderDisabled: surfaceDarkPalette[400], + controlIconHover: electricBluePalette[700], + controlIconActive: electricBluePalette[700], + + // Interactive — Primary (Electric Blue) + interactivePrimaryDefaultDefault: electricBluePalette[500], // #558bff + interactivePrimaryDefaultHover: electricBluePalette[300], // #0ab6ff + interactivePrimaryDefaultActive: electricBluePalette[700], // #1469cc + interactivePrimaryDefaultDisabled: electricBluePalette[500], + interactivePrimaryWeakDefault: darkNavyPalette[200], + interactivePrimaryWeakHover: darkNavyPalette[100], + interactivePrimaryWeakActive: darkNavyPalette[100], + interactivePrimaryWeakDisabled: surfaceDark900Alpha10, + + // Interactive — Secondary + interactiveSecondaryDefaultDefault: greyPalette[50], + interactiveSecondaryDefaultHover: greyPalette[0], + interactiveSecondaryDefaultActive: greyPalette[100], + interactiveSecondaryDefaultDisabled: greyPalette[0], + interactiveSecondaryWeakDefault: darkNavyPalette[200], + interactiveSecondaryWeakHover: darkNavyPalette[100], + interactiveSecondaryWeakActive: greyPalette[800], + interactiveSecondaryWeakDisabled: surfaceDarkPalette[900], + + // Interactive — Inverse + interactiveInverseBackgroundDefault: greyPalette[200], + interactiveInverseBackgroundHover: greyPalette[100], + interactiveInverseBackgroundActive: greyPalette[200], + interactiveInverseBackgroundDisabled: greyPalette[200], + interactiveInverseTextHover: surfaceDarkPalette[600], + + // Excellent — Electric Blue + excellentBackgroundDefault: electricBluePalette[700], + excellentBackgroundWeak: electricBluePalette[300], + excellentBackgroundDisabled: electricBluePalette[500], + excellentBackgroundHover: electricBluePalette[500], + excellentBackgroundActive: electricBluePalette[700], + excellentTextDefault: electricBluePalette[300], + excellentTextHover: electricBluePalette[300], + excellentTextActive: electricBluePalette[300], + excellentTextInDefault: electricBluePalette[300], + excellentTextInDisabled: electricBluePalette[300], + excellentBorderDefault: electricBluePalette[500], + excellentBorderHover: electricBluePalette[300], + excellentBorderActive: electricBluePalette[700], + excellentBorderDisabled: electricBluePalette[500], + excellentBorderWeak: electricBluePalette[500], + excellentIconDefault: electricBluePalette[500], + excellentIconHover: electricBluePalette[300], + excellentIconActive: electricBluePalette[700], + excellentIconDisabled: electricBluePalette[500], + excellentIconInDefault: electricBluePalette[300], + excellentIconInHover: electricBluePalette[300], + excellentIconInActive: electricBluePalette[300], + excellentIconInDisabled: electricBluePalette[300], + + // Brand — Electric Blue + Dark Navy + brandIconPrimaryDefault: electricBluePalette[500], + brandIconPrimaryStrong: electricBluePalette[700], + brandIconPrimaryMedium: electricBluePalette[300], + brandIconPrimaryWeak: electricBluePalette[300], + brandIconSecondaryDefault: electricBluePalette[300], + brandIconSecondaryWeak: greyPalette[100], + brandIconSecondaryMedium: greyPalette[200], + brandBackgroundPrimaryDefault: darkNavyPalette[200], + brandBackgroundPrimaryWeak: darkNavyPalette[100], + brandBackgroundPrimaryMedium: surfaceDarkPalette[400], + brandBackgroundSecondaryDefault: darkNavyPalette[100], + brandTextPrimary: electricBluePalette[500], + brandTextSecondary: greyPalette[0], +}; 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 d3d9c301..0871a05b 100644 --- a/packages/open-ui-kit/src/theme/style/color-palette.ts +++ b/packages/open-ui-kit/src/theme/style/color-palette.ts @@ -626,3 +626,27 @@ export const OS_LIGHT_COLORS = { sunset: sunsetPalette, brand: brandColors, }; + +// Dark Navy (Midnight deep surfaces) +export const darkNavy100 = "#1e293b"; +export const darkNavy200 = "#0a141f"; +export const darkNavy300 = "#0f1623"; +export const darkNavy400 = "#060a0f"; + +export const darkNavyPalette = { + 100: darkNavy100, + 200: darkNavy200, + 300: darkNavy300, + 400: darkNavy400, +}; + +// Electric Blue (Midnight primary / interactive) +export const electricBlue300 = "#0ab6ff"; +export const electricBlue500 = "#558bff"; +export const electricBlue700 = "#1469cc"; + +export const electricBluePalette = { + 300: electricBlue300, + 500: electricBlue500, + 700: electricBlue700, +}; From 85589d4e9d5e04653e4133d18b61c22055327896 Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Tue, 28 Jul 2026 17:20:17 +0530 Subject: [PATCH 02/23] fix(theme): correct Midnight excellent/interactive disabled token opacity Align 9 Midnight semantic tokens with the Agntcy Midnight design spec. The excellent Weak/Disabled and interactive disabled tokens were mapped to solid Electric Blue values instead of the intended low-opacity variants, causing the excellent Banner to render as solid bright cyan and disabled states to appear fully opaque. Add electricBlueAlpha10/40 palette variants and repoint the affected tokens to them (mirroring the Dark theme's lightBluePalette alpha usage). Signed-off-by: Yashwant Rautela --- .../src/theme/midnight/midnight-vars.ts | 19 ++++++++++--------- .../src/theme/style/color-palette.ts | 4 ++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts index d4db6fe0..20198350 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts @@ -11,7 +11,7 @@ import { greyPalette, darkNavyPalette, electricBluePalette, - surfaceDark900Alpha10, + surfaceDark900, } from "@/theme/style/color-palette"; // Midnight is a dark-family theme. It shares most semantic tokens with the @@ -51,11 +51,11 @@ export const midnightVars: VarsType = { interactivePrimaryDefaultDefault: electricBluePalette[500], // #558bff interactivePrimaryDefaultHover: electricBluePalette[300], // #0ab6ff interactivePrimaryDefaultActive: electricBluePalette[700], // #1469cc - interactivePrimaryDefaultDisabled: electricBluePalette[500], + interactivePrimaryDefaultDisabled: electricBluePalette["alpha40"], interactivePrimaryWeakDefault: darkNavyPalette[200], interactivePrimaryWeakHover: darkNavyPalette[100], interactivePrimaryWeakActive: darkNavyPalette[100], - interactivePrimaryWeakDisabled: surfaceDark900Alpha10, + interactivePrimaryWeakDisabled: electricBluePalette["alpha10"], // Interactive — Secondary interactiveSecondaryDefaultDefault: greyPalette[50], @@ -73,31 +73,32 @@ export const midnightVars: VarsType = { interactiveInverseBackgroundActive: greyPalette[200], interactiveInverseBackgroundDisabled: greyPalette[200], interactiveInverseTextHover: surfaceDarkPalette[600], + interactiveInverseTextDisabled: surfaceDark900, // Excellent — Electric Blue excellentBackgroundDefault: electricBluePalette[700], - excellentBackgroundWeak: electricBluePalette[300], - excellentBackgroundDisabled: electricBluePalette[500], + excellentBackgroundWeak: electricBluePalette["alpha10"], + excellentBackgroundDisabled: electricBluePalette["alpha40"], excellentBackgroundHover: electricBluePalette[500], excellentBackgroundActive: electricBluePalette[700], excellentTextDefault: electricBluePalette[300], excellentTextHover: electricBluePalette[300], excellentTextActive: electricBluePalette[300], excellentTextInDefault: electricBluePalette[300], - excellentTextInDisabled: electricBluePalette[300], + excellentTextInDisabled: electricBluePalette["alpha10"], excellentBorderDefault: electricBluePalette[500], excellentBorderHover: electricBluePalette[300], excellentBorderActive: electricBluePalette[700], - excellentBorderDisabled: electricBluePalette[500], + excellentBorderDisabled: electricBluePalette["alpha40"], excellentBorderWeak: electricBluePalette[500], excellentIconDefault: electricBluePalette[500], excellentIconHover: electricBluePalette[300], excellentIconActive: electricBluePalette[700], - excellentIconDisabled: electricBluePalette[500], + excellentIconDisabled: electricBluePalette["alpha40"], excellentIconInDefault: electricBluePalette[300], excellentIconInHover: electricBluePalette[300], excellentIconInActive: electricBluePalette[300], - excellentIconInDisabled: electricBluePalette[300], + excellentIconInDisabled: electricBluePalette["alpha10"], // Brand — Electric Blue + Dark Navy brandIconPrimaryDefault: electricBluePalette[500], 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 0871a05b..189f8ab8 100644 --- a/packages/open-ui-kit/src/theme/style/color-palette.ts +++ b/packages/open-ui-kit/src/theme/style/color-palette.ts @@ -644,9 +644,13 @@ export const darkNavyPalette = { export const electricBlue300 = "#0ab6ff"; export const electricBlue500 = "#558bff"; export const electricBlue700 = "#1469cc"; +export const electricBlueAlpha40 = "#558bff66"; +export const electricBlueAlpha10 = "#558bff19"; export const electricBluePalette = { 300: electricBlue300, 500: electricBlue500, 700: electricBlue700, + alpha40: electricBlueAlpha40, + alpha10: electricBlueAlpha10, }; From 27646e7f22b6db5a635cd9b2d69d23f1c2a6c440 Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Thu, 6 Aug 2026 13:16:56 +0530 Subject: [PATCH 03/23] Midnight Theme Fixes (#12) Design token corrections for accordion, activity-timeline, checkbox, code-block, dialog, floating-button, header, message, navigation, and popover components, plus code-block syntax highlighting via refractor. Signed-off-by: Yashwant Rautela --- DESIGN-TOKEN-CHANGES.md | 357 ++++++++++++++++++ .../open-ui-kit/.storybook/css/preview.css | 23 +- packages/open-ui-kit/jest.config.js | 25 +- packages/open-ui-kit/package.json | 1 + .../accordion/components/accordion.tsx | 8 +- .../accordion/components/elements.tsx | 10 +- .../src/components/accordion/types/index.ts | 2 + .../components/activity-timeline.tsx | 4 + .../checkbox/stories/checkbox.stories.tsx | 4 +- .../code-block/__tests__/code-block.test.tsx | 168 ++++++++- .../code-block/components/code-block.tsx | 6 +- .../components/code-block/prism-grammar.ts | 56 +++ .../src/components/code-block/styles/index.ts | 156 ++++++-- .../components/dialog/components/elements.tsx | 6 +- .../__tests__/floating-button.test.tsx | 2 +- .../floating-button/styles/index.ts | 7 +- .../footer/stories/footer.stories.tsx | 9 +- .../header/__tests__/header.test.tsx | 4 +- .../src/components/header/styles/index.ts | 4 +- .../message/__tests__/message.test.tsx | 4 +- .../src/components/message/styles/index.ts | 2 +- .../src/components/navigation/styles/index.ts | 2 +- .../popover/__tests__/popover.test.tsx | 18 +- .../components/popover/components/popover.tsx | 2 +- .../src/components/popover/styles/index.ts | 4 +- .../src/custom-icons/brand-logos.tsx | 4 +- .../test-utils/react-syntax-highlighter.js | 9 +- 27 files changed, 810 insertions(+), 87 deletions(-) create mode 100644 DESIGN-TOKEN-CHANGES.md create mode 100644 packages/open-ui-kit/src/components/code-block/prism-grammar.ts diff --git a/DESIGN-TOKEN-CHANGES.md b/DESIGN-TOKEN-CHANGES.md new file mode 100644 index 00000000..edd625fe --- /dev/null +++ b/DESIGN-TOKEN-CHANGES.md @@ -0,0 +1,357 @@ +# Design token alignment — change summary + +Branch: `gauge-chart/gradient` +Package: `packages/open-ui-kit` +Figma source: [Outshift Spark Component Library](https://www.figma.com/design/o6t5UKJGaw75ZAiLfijAxq/Outshift-Spark-Component-Library) + +Work done: audited several components against the Figma token set, corrected the +tokens that were wrong, and fixed a Storybook CSS bug that was making the Docs +tab display the wrong colors for every component. + +All values below were read from the Figma variable definitions or verified by +rendering the component and reading its computed style — not inferred from the +source alone. Two of the changes exist specifically because the source code +looked correct but rendered incorrectly. + +## Two kinds of change in this branch + +It is worth separating these, because they carry different risk and want +different review attention. + +**Spec alignment (sections 1–3).** The code did what it said it did; it simply +pointed at the wrong token, or shipped stale artwork. These are low-risk, and +the thing to review is whether the chosen token is the *right* one — the diff +itself is obviously correct. + +**Latent rendering bugs (sections 4–5).** In these the source code read as +correct, and a reviewer grepping for the token would have concluded everything +was fine — but the browser rendered something else. Neither would have been +caught by reading the code, and section 5 in particular had been quietly +corrupting the way the team verifies token work. These deserve the closer look. + +The distinction also explains why several of the checks below render the +component and read `getComputedStyle` rather than asserting on a style object: +after section 4, asserting that a style function *returns* the right token was +no longer sufficient evidence that the token reaches the screen. + +--- + +## 1. Header — profile dropdown background + +**Token:** `baseBackgroundMedium` → `controlBackgroundWeak` +**Figma row:** Control / Background / Weak + +The Profile / Log out dropdown paper and its menu items were painted with +`baseBackgroundMedium`. Figma specifies Control/Background/Weak for this surface. + +### Why this was needed + +The two tokens are not interchangeable — they belong to different scales. +`baseBackgroundMedium` is the *base* surface scale, shared with SideDrawer, +Toast, CodeBlock gutters and chart tooltips. `controlBackgroundWeak` is the +*control* surface scale. A dropdown is a control, so it should track the control +scale: if the control palette is ever retuned, the dropdown should move with the +other controls rather than with drawers and toasts. + +Beyond the semantics, the two resolve to visibly different colors — in midnight +`#0a141f` versus `#1e293b` — so the dropdown was rendering noticeably darker than +the design. Leaving it on the base token would also have made the mismatch +invisible to future audits, since the code looked deliberate. + +| File | Change | +| --- | --- | +| `src/components/header/styles/index.ts` | `getStoryMenuPaperStyles` and `getStoryMenuItemStyles` background | +| `src/components/header/__tests__/header.test.tsx` | Two assertions that pinned the old token | + +Resolved values for the new token: + +| Theme | Value | +| --- | --- | +| Light | `#f5f8fd` (Surface Light/100) | +| Dark | `#0d274d` (Surface Dark/500) | +| Midnight | `#1e293b` (Dark Navy/100) | +| IoC | `rgba(255,255,255,0.09)` (deliberate deviation, see note in `ioc-vars.ts:93`) | + +In midnight the dropdown moves from `#0a141f` to `#1e293b`, so it now reads +slightly lighter against the page. + +> **Follow-up:** the item hover state still uses `baseBackgroundHover` +> (`#0f1623` in midnight), which is now *darker* than the resting background — +> previously hover was lighter. If that inversion is unintended, the consistent +> fix is switching hover to `controlBackgroundHover`. + +--- + +## 2. FloatingButton — primary variant background + +**Token:** primary variant now uses `baseBackgroundWeak`; secondary is unchanged. + +Both variants shared `controlBackgroundDefault`. The background is now selected +per variant, mirroring how the border color was already chosen. + +### Why this was needed + +The `variant` prop was only half-implemented. It already switched the border +color between primary and secondary, but the background was a single hard-wired +token, so the two variants were distinguishable by outline alone. Figma gives the +primary variant Base/Background/Weak, i.e. a distinct fill — the component could +not express that at all. + +The fix follows the shape of the code that was already there rather than adding a +new mechanism: a `backgroundColor` constant chosen by variant, directly parallel +to the existing `borderColor` constant. That keeps the two variant-dependent +values side by side, so the next person adding a variant sees both. + +| File | Change | +| --- | --- | +| `src/components/floating-button/styles/index.ts` | Added `backgroundColor` branch on `variant` | +| `src/components/floating-button/__tests__/floating-button.test.tsx` | Primary-variant assertion | + +```ts +const backgroundColor = + variant === "primary" + ? theme.palette.vars.baseBackgroundWeak + : theme.palette.vars.controlBackgroundDefault; +``` + +Midnight renders the primary (blue-bordered) button at `#1e293b`. Hover, active +and disabled behavior are untouched. + +--- + +## 3. Footer — AGNTCY brand icon + +The icon did not match the Figma component. The mark was re-exported from Figma +node `179577-1004`, where it is flattened into a single "powered by AGNTCY" +group — the AGNTCY path was extracted and the viewBox cropped to its measured +bounds. + +### Why this was needed + +Two separate problems, one visual and one structural. + +The **artwork was stale**: the committed path was an older AGNTCY lockup with +different letterforms and proportions from the one in the current Figma +component. No amount of resizing would have reconciled them — the geometry had to +be replaced. + +The **color was untokenized**: the story rendered `` with no color, +so the icon inherited ambient `currentColor` from the surrounding footer text and +came out grey. Figma fills the mark with the primary blue. Because the icon +declares `fill="currentColor"`, the color is a property of whoever renders it, so +this had to be fixed at the call site rather than in the icon. + +Extracting the path was not a straight export: the Figma node is a flattened +group containing both the "powered by" text and the mark, so the mark's own +bounds had to be computed from the path data (x 72.613→138) to crop the viewBox. +That is why the viewBox has a non-zero origin rather than the usual `0 0 …`. + +| File | Change | +| --- | --- | +| `src/custom-icons/brand-logos.tsx` | `AgntcyBrand` path replaced; viewBox `0 0 105 24` → `72.613 0 65.387 14.882` | +| `src/components/footer/stories/footer.stories.tsx` | Size `64×16` → `66×15`; color now tokenized | + +The icon keeps the file's existing `fill="currentColor"` convention so it themes +like the other brand logos. The story colors it with +`interactivePrimaryDefaultDefault`, which resolves to `#558bff` in midnight — +exactly the fill Figma uses for the mark — while following each theme's primary +color elsewhere. + +> **Decision point:** if the logo is meant to be `#558bff` in *every* theme (a +> fixed brand color rather than a themed one), hard-code `electricBlue500` +> instead of the token. + +--- + +## 4. Dialog — description text color + +**File:** `src/components/dialog/components/elements.tsx` + +`StyledDialogContentText` already declared `color: baseTextDefault`, but that +color was not winning at render time. MUI's `DialogContentText` injects +`color="textSecondary"` as a Typography system prop, and system-prop styles are +emitted *after* the styled override. + +That happens to be harmless in dark and midnight, where +`palette.text.secondary === baseTextDefault`. In **light** and **IoC** the two +values differ, so the dialog description was rendering the wrong color. + +The fix wraps the declaration in a doubled `&&` selector so it outranks the +injected style in every theme: + +```ts +// MUI injects color="textSecondary" as a system prop whose styles are +// emitted after this override; the doubled selector outranks it. +"&&": { + color: theme.palette.vars.baseTextDefault, +}, +``` + +Verified with a throwaway rendered test asserting the computed color equals +`baseTextDefault` in the light theme (where the values diverge). No visual change +in midnight. + +### Why this was needed + +The code was making a promise it did not keep. `elements.tsx` plainly declared +`color: baseTextDefault`, so anyone auditing dialog colors — by reading the file +or grepping for the token — would have concluded the component was correct and +moved on. It was not: in light and IoC the dialog description rendered +`palette.text.secondary` instead. + +That makes it worse than an ordinary wrong-color bug. A visibly wrong color gets +reported; this one was invisible in the two dark themes the team looks at most, +and actively defended by source code that read as correct. It would have survived +any number of token audits. + +It also set the standard of proof for the rest of this branch. Before finding +this, checking that a style function returned the right token seemed like +sufficient verification. After it, the checks in sections 5 and in "Audited — no +change required" all render the component and read `getComputedStyle`, because +returning the right token and painting the right color turned out to be different +claims. + +--- + +## 5. Storybook Docs CSS was repainting real components + +**File:** `.storybook/css/preview.css` + +This one is not a component bug — it made the **Docs tab misreport the colors of +every component in the kit**, which is what surfaced it (midnight breadcrumbs +appeared blue instead of `#e8e9ea`). + +Four rules targeted bare HTML elements under `.sbdocs-content` with +`!important`. Storybook renders stories *inline* inside that container, so the +rules were repainting the rendered components, not just the surrounding prose. + +Each selector now carries a `:not(.sb-unstyled *)` guard. `.sb-unstyled` is the +class Storybook already applies to every inline story wrapper for exactly this +purpose, so this follows the framework's own convention. + +### Why this was needed + +This is the most consequential fix in the branch, because it was not breaking a +component — it was breaking the instrument the team uses to check components. + +The Docs tab is where token work gets verified. While these rules were in place, +the Docs tab was showing Storybook's own chrome colors on top of the real +components, so it could not be trusted for exactly the task it was being used +for. Every "does this token look right?" judgement made in Docs was potentially +answering a question about Storybook's CSS instead. + +The reach was wide. Because MUI renders `Typography` `body1`/`body2`/default as a +`

`, the muted-text rule covered ordinary body text across roughly 39 component +files — including `DialogContentText`, meaning the dialog fix in section 4 would +still have *appeared* wrong in Docs afterwards. Two independent bugs were +stacking on the same element, and only fixing both makes the component readable. + +The specific trigger was noticing midnight breadcrumbs rendering `#558BFF` when +the token resolves to `#e8e9ea`. Rendering `Breadcrumbs` under `midnightTheme` +returned the correct `rgb(232, 233, 234)`, which ruled out the component and +pointed at the environment. + +`!important` on a bare-element selector is what made it unfixable from the +component side: no Emotion class could outrank it, so no amount of correcting +component styles would have helped. The guard was applied at the source rather +than by escalating specificity in components, which would have spread the problem +across the kit. + +Rules that were leaking, and what they overrode inside stories: + +| Selector | Impact | +| --- | --- | +| `.sbdocs-content a` | Every link — Breadcrumbs, Footer, Link | +| `.sbdocs-content p`, `li` | Any `

`/`

  • `. MUI `Typography` renders `body1`/`body2`/default as `

    `, so ~39 component files were affected — including `DialogContentText` | +| `.sbdocs-content h1`–`h4` | Real headings, plus forcing Sharp Sans and `letter-spacing: 0` | +| `.sbdocs-content p code`, `li code` | Code inside those elements | + +The forced link color is theme-dependent (`preview.ts:88`); in midnight it is +`#558BFF`, which is why breadcrumbs looked blue. + +**Scope of the fix:** it only *narrows* selectors, so it can never add styling — +docs prose is unaffected. Inside story previews, elements now show their real +token colors. The Canvas tab was never affected (no `.sbdocs-content` ancestor), +so Canvas and Docs should now agree; a disagreement between the two tabs is a +useful signal of a genuine bug. + +`Typography variant="caption"` renders a `` and was never in these +selectors — the Footer copyright text, for example, was always correct. + +--- + +## Audited — no change required + +Two items were investigated and found already correct. Recording them so they +are not "fixed" into a regression later — in both cases there is a plausible +wrong answer sitting right next to the right one, and a future reader without +this note could easily talk themselves into it. + +**Breadcrumb link text** already uses `interactiveSecondaryDefaultDefault`, +resolved through `Link`'s `customizeColor` callback. Confirmed by rendering the +component and reading the computed color (`#062242` in light). The separator +chevron and collapsed "…" trigger use the same token; the collapsed dropdown's +menu items intentionally use `baseTextDefault`. + +**`interactiveSecondaryDefaultDefault` already matches the Agntcy Light / Agntcy +Dark columns** in light and dark. Read directly from the Figma variable +(`Tokens` collection, library "Outshift Spark Foundations"): + +| Figma mode | Alias → value | Codebase | +| --- | --- | --- | +| **Agntcy Light** | Surface Dark/600 → `#062242` | light: `surfaceDarkPalette[600]` ✅ | +| **Agntcy Dark** | Surface Light/300 → `#e8eefb` | dark: `surfaceLightPalette[300]` ✅ | +| Light | Surface Dark/500 → `#0d274d` | not used | +| Dark | Surface Light/300 → `#e8eefb` | not used | +| Agntcy Midnight | Grey/50 → `#e8e9ea` | midnight: `greyPalette[50]` ✅ | +| IoC | Surface Light/300 → `#e8eefb` | ioc: `iocBluePalette[500]` = `#2B82F6` ⚠️ | + +Note that the plain "Light" column is a *different* color from Agntcy Light. The +codebase deliberately follows the Agntcy columns; changing light to `#0d274d` +would move away from the intended spec. + +The hover / active / disabled states of the same family also match Agntcy +Light/Dark exactly. + +--- + +## Open questions for design + +1. **Midnight disabled state is brighter than the resting state.** + `interactiveSecondaryDefaultDisabled` is Grey/0 (`#ffffff`) while resting is + Grey/50 (`#e8e9ea`), so disabled links appear *more* prominent than enabled + ones. This was confirmed in the Figma token set — the code mirrors it + faithfully, so it is a design-side question, not a code bug. Every other + theme dims disabled with alpha. + +2. **IoC diverges from Figma on the `interactiveSecondary` family.** Figma + specifies Surface Light/300 (`#e8eefb`); the code uses `iocBluePalette[500]` + (`#2B82F6`). This resembles the deliberate IoC deviation documented in + `ioc-vars.ts:93`, but this one carries no explanatory comment. Either align it + to Figma or add a comment recording why it differs. + +3. **Header dropdown hover** — see the follow-up note in section 1. + +--- + +## Verification + +| Suite | Result | +| --- | --- | +| `header.test.tsx` | 19 passed | +| `footer.test.tsx` | 9 passed | +| `floating-button` | 12 passed | +| `dialog.test.tsx` | 7 passed | +| `tsc --noEmit` | clean | +| `prettier --check` on `preview.css` | clean | + +Storybook selector guards were verified by building the Docs DOM structure in +jsdom and running the old and new selectors against it: the old selectors matched +docs content *and* story content, the new ones match only docs content. + +Tests run with Node 22 (`~/.nvm/versions/node/v22.23.1`). The default `node` +v20.9.0 on this machine is too old for the repo's Vitest/Rolldown toolchain, and +this package's suite is Jest — `npx jest --config=jest.config.js`. + +Temporary verification tests written during this work were deleted after use; +none remain in the tree. diff --git a/packages/open-ui-kit/.storybook/css/preview.css b/packages/open-ui-kit/.storybook/css/preview.css index 6da24129..84c1327a 100644 --- a/packages/open-ui-kit/.storybook/css/preview.css +++ b/packages/open-ui-kit/.storybook/css/preview.css @@ -50,11 +50,16 @@ body { max-width: 1040px; } +/* The bare-element selectors below must exclude `.sb-unstyled` subtrees. + Storybook renders stories inline inside `.sbdocs-content`, so an unguarded + `.sbdocs-content a` (etc.) repaints the rendered components themselves and + the Docs tab stops showing their real token colors. */ + .sbdocs-title, -.sbdocs-content h1, -.sbdocs-content h2, -.sbdocs-content h3, -.sbdocs-content h4 { +.sbdocs-content h1:not(.sb-unstyled *), +.sbdocs-content h2:not(.sb-unstyled *), +.sbdocs-content h3:not(.sb-unstyled *), +.sbdocs-content h4:not(.sb-unstyled *) { color: var(--ouk-storybook-text) !important; font-family: "Sharp Sans", @@ -69,20 +74,20 @@ body { .sbdocs-subtitle, .sbdocs-p, .sbdocs-li, -.sbdocs-content p, -.sbdocs-content li { +.sbdocs-content p:not(.sb-unstyled *), +.sbdocs-content li:not(.sb-unstyled *) { color: var(--ouk-storybook-muted-text) !important; } .sbdocs-a, -.sbdocs-content a { +.sbdocs-content a:not(.sb-unstyled *) { color: var(--ouk-storybook-link) !important; } .sbdocs-p code, .sbdocs-li code, -.sbdocs-content p code, -.sbdocs-content li code { +.sbdocs-content p:not(.sb-unstyled *) code, +.sbdocs-content li:not(.sb-unstyled *) code { background: var(--ouk-storybook-preview-bg) !important; border: 1px solid var(--ouk-storybook-border) !important; border-radius: 4px !important; diff --git a/packages/open-ui-kit/jest.config.js b/packages/open-ui-kit/jest.config.js index a08cc5d7..15995433 100644 --- a/packages/open-ui-kit/jest.config.js +++ b/packages/open-ui-kit/jest.config.js @@ -20,8 +20,31 @@ module.exports = { transform: { "^.+\\.(ts|tsx|js|jsx)$": ["ts-jest", { tsconfig: "tsconfig.json" }], }, + // CodeBlock patches the refractor grammar (see components/code-block/ + // prism-grammar.ts). refractor and its hast/parse-entities dependencies are + // ESM-only, so they have to be transformed rather than skipped. transformIgnorePatterns: [ - "node_modules/(?!(lodash-es|@mui|@babel/runtime)/)", + `node_modules/(?!(${[ + "lodash-es", + "@mui", + "@babel/runtime", + // refractor and every ESM package in its dependency closure. + "refractor", + "character-entities", + "character-entities-legacy", + "character-reference-invalid", + "comma-separated-tokens", + "decode-named-character-reference", + "hast-util-parse-selector", + "hastscript", + "is-alphabetical", + "is-alphanumerical", + "is-decimal", + "is-hexadecimal", + "parse-entities", + "property-information", + "space-separated-tokens", + ].join("|")})/)`, ], modulePathIgnorePatterns: ["/dist/"], // Ignore the dist directory to avoid Haste module naming collisions testMatch: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[tj]s?(x)"], // Ensure test files are matched diff --git a/packages/open-ui-kit/package.json b/packages/open-ui-kit/package.json index 32b093e3..6ccc7928 100644 --- a/packages/open-ui-kit/package.json +++ b/packages/open-ui-kit/package.json @@ -181,6 +181,7 @@ "react-syntax-highlighter": "^16.1.1", "react-virtuoso": "^4.18.7", "recharts": "^2.15.3", + "refractor": "^5.0.0", "sonner": "^2.0.5", "use-debounce": "^10.1.1", "zustand": "^5.0.14" diff --git a/packages/open-ui-kit/src/components/accordion/components/accordion.tsx b/packages/open-ui-kit/src/components/accordion/components/accordion.tsx index 651057a3..20b5ef4c 100644 --- a/packages/open-ui-kit/src/components/accordion/components/accordion.tsx +++ b/packages/open-ui-kit/src/components/accordion/components/accordion.tsx @@ -32,6 +32,7 @@ export const Accordion = ({ action, endSlot, showDivider, + showBorder, accordionSummaryProps, detailsContentBoxProps, children, @@ -41,9 +42,14 @@ export const Accordion = ({ const summaryTextLineHeight = size === "large" ? "24px" : "20px"; const mediumSize = size === "medium"; const shouldShowDivider = showDivider ?? (mediumSize && !contained); + const shouldShowBorder = showBorder ?? (mediumSize && !contained); return ( - + prop !== "contained" && prop !== "mediumSize", -})<{ contained?: boolean; mediumSize?: boolean }>( - ({ theme, contained, mediumSize }) => ({ + shouldForwardProp: (prop) => prop !== "contained" && prop !== "showBorder", +})<{ contained?: boolean; showBorder?: boolean }>( + ({ theme, contained, showBorder }) => ({ padding: 0, color: theme.palette.vars.baseTextStrong, backgroundColor: "transparent", @@ -36,7 +36,7 @@ export const StyledAccordion = styled(Accordion, { color: theme.palette.vars.baseTextDisabled, backgroundColor: "transparent", }, - ...(mediumSize && + ...(showBorder && !contained && { borderTop: `1px solid ${theme.palette.vars.controlBorderDefault}`, }), @@ -54,7 +54,7 @@ export const StyledAccordion = styled(Accordion, { }), }), ) as ComponentType< - AccordionProps & { contained?: boolean; mediumSize?: boolean } + AccordionProps & { contained?: boolean; showBorder?: boolean } >; export const StyledAccordionSummary = styled(AccordionSummary, { diff --git a/packages/open-ui-kit/src/components/accordion/types/index.ts b/packages/open-ui-kit/src/components/accordion/types/index.ts index b7e14b99..07c49cae 100644 --- a/packages/open-ui-kit/src/components/accordion/types/index.ts +++ b/packages/open-ui-kit/src/components/accordion/types/index.ts @@ -40,6 +40,8 @@ export interface AccordionProps extends MuiAccordionProps { endSlot?: ReactNode; /** Overrides the default summary divider visibility. Medium uncontained accordions show it by default. */ showDivider?: boolean; + /** Overrides the default top border visibility. Medium uncontained accordions show it by default. */ + showBorder?: boolean; /** Props forwarded to the internal MUI AccordionSummary. */ accordionSummaryProps?: AccordionSummaryProps; /** Props forwarded to the details content wrapper. */ diff --git a/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline.tsx b/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline.tsx index a5edc544..2ad2db9a 100644 --- a/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline.tsx +++ b/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline.tsx @@ -74,6 +74,10 @@ export const ActivityTimeline = ({ titleStartIcon={step.titleStartIcon} subTitle={step.subTitle} size={size} + // The timeline supplies its own separators, so the medium + // accordion's top border and summary divider are redundant here. + showBorder={false} + showDivider={false} > {step.content} diff --git a/packages/open-ui-kit/src/components/checkbox/stories/checkbox.stories.tsx b/packages/open-ui-kit/src/components/checkbox/stories/checkbox.stories.tsx index 640da6b7..c84f91dd 100644 --- a/packages/open-ui-kit/src/components/checkbox/stories/checkbox.stories.tsx +++ b/packages/open-ui-kit/src/components/checkbox/stories/checkbox.stories.tsx @@ -254,7 +254,9 @@ export const BareStates: Story = { {...args} {...checkboxStateProps(checkState, visualState)} /> - {stateLabel[checkState]} + + {stateLabel[checkState]} + ))} diff --git a/packages/open-ui-kit/src/components/code-block/__tests__/code-block.test.tsx b/packages/open-ui-kit/src/components/code-block/__tests__/code-block.test.tsx index 515b8db4..2a0b1edf 100644 --- a/packages/open-ui-kit/src/components/code-block/__tests__/code-block.test.tsx +++ b/packages/open-ui-kit/src/components/code-block/__tests__/code-block.test.tsx @@ -6,6 +6,7 @@ import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; +import { refractor } from "refractor/all"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; @@ -13,7 +14,9 @@ import { CodeBlock } from "../components/code-block"; import { codeTextStyle, containerStackStyles, + customStyle, lineNumberStyle, + prismStyle, separatorFirstBox, } from "../styles"; import type { CodeBlockProps } from "../types"; @@ -21,6 +24,14 @@ import type { CodeBlockProps } from "../types"; const CODE = `const x = 1;\nconsole.log(x);`; const noop = jest.fn(); +/** Minimal shape of the hast tree `refractor.highlight` returns. */ +type HastNode = { + type: string; + value?: string; + properties?: { className?: string[] }; + children?: HastNode[]; +}; + const renderCodeBlock = (props: Partial = {}, dark = false) => render( @@ -91,7 +102,7 @@ describe("CodeBlock", () => { expect(containerStackStyles(lightTheme)).toEqual( expect.objectContaining({ backgroundColor: lightTheme.palette.vars.controlBackgroundDefault, - border: `1px solid ${lightTheme.palette.vars.controlBorderDefault}`, + border: `1px solid ${lightTheme.palette.vars.controlBorderWeak}`, borderRadius: "6px", }), ); @@ -154,7 +165,7 @@ describe("CodeBlock", () => { expect(containerStackStyles(darkTheme)).toEqual( expect.objectContaining({ backgroundColor: darkTheme.palette.vars.controlBackgroundDefault, - border: `1px solid ${darkTheme.palette.vars.controlBorderDefault}`, + border: `1px solid ${darkTheme.palette.vars.controlBorderWeak}`, borderRadius: "6px", }), ); @@ -170,6 +181,159 @@ describe("CodeBlock", () => { }); }); + describe("syntax colors", () => { + it("maps grammar tokens to the Figma accent ramp", () => { + const light = prismStyle(lightTheme); + + expect(light).toEqual( + expect.objectContaining({ + comment: { color: lightTheme.palette.vars.accentEDefault }, + keyword: { color: lightTheme.palette.vars.accentADefault }, + arrow: { color: lightTheme.palette.vars.accentADefault }, + "control-flow": { color: lightTheme.palette.vars.accentBDefault }, + function: { color: lightTheme.palette.vars.accentFDefault }, + "declaration-name": { + color: lightTheme.palette.vars.accentGDefault, + }, + "class-name": { color: lightTheme.palette.vars.accentJDefault }, + parameter: { color: lightTheme.palette.vars.accentHDefault }, + identifier: { color: lightTheme.palette.vars.accentHDefault }, + number: { color: lightTheme.palette.vars.successTextDefault }, + punctuation: { color: lightTheme.palette.vars.baseTextStrong }, + }), + ); + }); + + it("resolves the Figma frame's literal syntax colors in light mode", () => { + const light = prismStyle(lightTheme); + + // Values read from the Figma "Code block" frame variable definitions. + expect(light.keyword).toEqual({ color: "#5c6ddd" }); // Accent/A + expect(light["control-flow"]).toEqual({ color: "#b8428c" }); // Accent/B + expect(light.comment).toEqual({ color: "#7da11b" }); // Accent/E + expect(light.function).toEqual({ color: "#e8361a" }); // Accent/F + expect(light["declaration-name"]).toEqual({ color: "#46aace" }); // Accent/G + expect(light.parameter).toEqual({ color: "#1c2b7f" }); // Accent/H + expect(light.identifier).toEqual({ color: "#1c2b7f" }); // Accent/H + expect(light["class-name"]).toEqual({ color: "#028e99" }); // Accent/J + expect(light.number).toEqual({ color: "#00b285" }); // Success/Text/Default + }); + + it("gives control flow a different color from other keywords", () => { + // Accent/B previously sat on `regex`/`constant`, which the JavaScript + // samples never produce, so it painted nothing and `return`/`await` + // rendered as Accent/A. These must stay distinct in every theme. + for (const theme of [lightTheme, darkTheme]) { + const style = prismStyle(theme); + expect(style["control-flow"]).not.toEqual(style.keyword); + expect(style["declaration-name"]).not.toEqual(style.punctuation); + expect(style.identifier).not.toEqual(style.punctuation); + } + }); + + it("follows the active theme instead of baking in one ramp", () => { + const light = prismStyle(lightTheme); + const dark = prismStyle(darkTheme); + + expect(dark.keyword).toEqual({ + color: darkTheme.palette.vars.accentADefault, + }); + expect(dark.keyword).not.toEqual(light.keyword); + expect(customStyle(darkTheme).color).toBe( + darkTheme.palette.vars.baseTextStrong, + ); + }); + + it("hands the theme-resolved palette to the highlighter", () => { + const { container, unmount } = renderCodeBlock(); + const readPalette = () => + JSON.parse( + container.querySelector("pre")?.getAttribute("data-prism-style") ?? + "{}", + ); + + expect(readPalette()).toEqual(prismStyle(lightTheme)); + expect(container.querySelector("pre")).toHaveStyle({ + color: lightTheme.palette.vars.baseTextStrong, + }); + + unmount(); + const darkRender = renderCodeBlock({}, true); + + expect( + JSON.parse( + darkRender.container + .querySelector("pre") + ?.getAttribute("data-prism-style") ?? "{}", + ), + ).toEqual(prismStyle(darkTheme)); + }); + }); + + // `react-syntax-highlighter` is mocked in jest.config.js, so the rendered + // output has no real token spans and the palette tests above can only prove + // that a color is *assigned* to a token name. These drive the same refractor + // grammar the component uses, to prove the token names are ones the language + // actually emits — the failure mode this change exists to fix. + describe("grammar coverage for the Figma roles", () => { + const SAMPLE = [ + "function resolveAfter2Seconds(x) {", + " return new Promise((resolve) => {", + " setTimeout(() => { resolve(x); }, 2000);", + " });", + "}", + "const p1 = await resolveAfter2Seconds(20);", + "return x + p1; // done", + "console.log(p1);", + ].join("\n"); + + // refractor nests tokens — a `parameter` whose inside-grammar matched an + // `identifier` renders as . + // Collect the whole ancestor chain so either role can be asserted. + const classesFor = (text: string): string[] => { + const found: string[][] = []; + const walk = (node: HastNode, inherited: string[]) => { + for (const child of node.children ?? []) { + if (child.type === "text") { + if (child.value === text) found.push(inherited); + continue; + } + const classes = [ + ...inherited, + ...(child.properties?.className ?? []).filter((c) => c !== "token"), + ]; + walk(child, classes); + } + }; + walk(refractor.highlight(SAMPLE, "javascript") as unknown as HastNode, []); + return found[0] ?? []; + }; + + it.each([ + ["return", "control-flow"], // Accent/B + ["await", "control-flow"], // Accent/B + ["p1", "declaration-name"], // Accent/G + ["x", "identifier"], // Accent/H + ["console", "identifier"], // Accent/H + ["=>", "arrow"], // Accent/A + ["resolve", "parameter"], // Accent/H + ["Promise", "class-name"], // Accent/J + ["2000", "number"], // Success/Text/Default + ])("tokenizes %s as %s", (text, expected) => { + expect(classesFor(text)).toContain(expected); + }); + + it("keeps control flow separate from ordinary keywords", () => { + expect(classesFor("const")).not.toContain("control-flow"); + expect(classesFor("return")).toContain("control-flow"); + }); + + it("leaves a function binding on the function role, not the declaration role", () => { + // `const add = function` is a function, so Accent/F outranks Accent/G. + expect(classesFor("resolveAfter2Seconds")).toContain("function"); + }); + }); + describe("size prop", () => { it("renders size=medium without throwing", () => { expect(() => renderCodeBlock({ size: "medium" })).not.toThrow(); diff --git a/packages/open-ui-kit/src/components/code-block/components/code-block.tsx b/packages/open-ui-kit/src/components/code-block/components/code-block.tsx index 127b751c..afcdd364 100644 --- a/packages/open-ui-kit/src/components/code-block/components/code-block.tsx +++ b/packages/open-ui-kit/src/components/code-block/components/code-block.tsx @@ -16,6 +16,10 @@ import { } from "@/components/code-block/styles"; import React from "react"; import { Prism, type SyntaxHighlighterProps } from "react-syntax-highlighter"; +// Adds the `declaration-name` and `identifier` tokens that `prismStyle` paints +// with Accent/G and Accent/H. Imported for its side effect on the shared +// refractor grammar. +import "@/components/code-block/prism-grammar"; import { Separator } from "./separator"; import { CopyButton } from "@/components/copy-button"; import type { CodeBlockProps } from "../types"; @@ -83,7 +87,7 @@ export const CodeBlock = (props: CodeBlockProps) => { /> `, and `parameter` carries declaration- +// site parameters. Those only needed stylesheet keys, which live in `styles`. +// +// This mutates the refractor singleton that `react-syntax-highlighter`'s +// `Prism` export is bound to — there is one copy in the tree, and both reach it +// through the same `refractor/all` specifier. Guarded so repeated imports from +// multiple entry points apply it once. +let patched = false; + +export const patchJavaScriptGrammar = (): void => { + if (patched) return; + patched = true; + + // Inserted before `keyword` so the patterns that precede it still win: + // `function-variable` keeps `const add = function` on Accent/F, and + // `class-name` keeps `Promise` on Accent/J. + refractor.languages.insertBefore("javascript", "keyword", { + "declaration-name": { + pattern: /((?:\b(?:const|let|var)\s+))[A-Za-z_$][\w$]*/, + lookbehind: true, + }, + }); + + // `Grammar` types only the tokens prismjs ships, so reach the rest through + // an index signature rather than widening the upstream type. + const javascript = refractor.languages.javascript as Record; + + // Appended last, so it only claims identifiers no earlier pattern matched. + javascript.identifier = /\b[A-Za-z_$][\w$]*\b/; + + // The frame paints `console` as an ordinary identifier reference. refractor + // has a dedicated `console` token that nests `class-name`; since + // react-syntax-highlighter resolves overlapping classes in array order and + // `class-name` lands last, a `console` stylesheet key could not outrank it. + // Removing the token lets `console` fall through to `identifier` instead. + delete javascript.console; +}; + +patchJavaScriptGrammar(); diff --git a/packages/open-ui-kit/src/components/code-block/styles/index.ts b/packages/open-ui-kit/src/components/code-block/styles/index.ts index e5be79e3..0ed98fcb 100644 --- a/packages/open-ui-kit/src/components/code-block/styles/index.ts +++ b/packages/open-ui-kit/src/components/code-block/styles/index.ts @@ -9,7 +9,9 @@ import type { CSSProperties } from "react"; export const containerStackStyles = (theme: Theme): CSSProperties => ({ backgroundColor: theme.palette.vars.controlBackgroundDefault, - border: `1px solid ${theme.palette.vars.controlBorderDefault}`, + // Figma binds the card outline to Control/Border/Weak; Control/Border/Default + // is reserved for the controls inside it, such as the copy button. + border: `1px solid ${theme.palette.vars.controlBorderWeak}`, borderRadius: "6px", position: "relative", }); @@ -35,7 +37,9 @@ export const customStyle = ( margin: "0", backgroundColor: theme.palette.vars.controlBackgroundDefault, borderRadius: showLineNumbers ? "0 0 4px 4px" : "4px", - color: theme.palette.vars.baseTextDefault, + // Figma paints the code area of a highlighted block with Base/Text/Strong; + // unhighlighted grammar tokens inherit this color. + color: theme.palette.vars.baseTextStrong, }; }; @@ -115,42 +119,114 @@ export const headerButtonStyles = (theme: Theme): CSSProperties => ({ color: theme.palette.vars.brandIconPrimaryDefault, }); -// Syntax tokens intentionally keep Prism palette literals; no Spark semantic -// tokens exist for language grammar colors in the current design system. -export const prismStyle: { [key: string]: CSSProperties } = { - 'pre[class*="language-"]': { background: "transparent", textShadow: "none" }, - 'code[class*="language-"]': { background: "transparent", textShadow: "none" }, - comment: { color: "slategray" }, - prolog: { color: "slategray" }, - doctype: { color: "slategray" }, - cdata: { color: "slategray" }, - punctuation: { color: "#999" }, - property: { color: "#905" }, - tag: { color: "#905" }, - boolean: { color: "#905" }, - number: { color: "#905" }, - constant: { color: "#905" }, - symbol: { color: "#905" }, - deleted: { color: "#905" }, - selector: { color: "#690" }, - "attr-name": { color: "#690" }, - string: { color: "#690" }, - char: { color: "#690" }, - builtin: { color: "#690" }, - inserted: { color: "#690" }, - operator: { color: "#9a6e3a" }, - entity: { color: "#9a6e3a", cursor: "help" }, - url: { color: "#9a6e3a" }, - ".language-css .token.string": { color: "#9a6e3a" }, - ".style .token.string": { color: "#9a6e3a" }, - atrule: { color: "#07a" }, - "attr-value": { color: "#07a" }, - keyword: { color: "#07a" }, - function: { color: "#DD4A68" }, - "class-name": { color: "#DD4A68" }, - regex: { color: "#e90" }, - important: { color: "#e90", fontWeight: "bold" }, - variable: { color: "#e90" }, - bold: { fontWeight: "bold" }, - italic: { fontStyle: "italic" }, +// Syntax colors follow the Figma "Code block" frame, which paints language +// grammar with the Spark accent ramp instead of a stock Prism palette: +// +// Accent/A keywords (function, const, new, async) and the arrow `=>` +// Accent/B control-flow keywords (return, await) +// Accent/E comments +// Accent/F function names and call sites +// Accent/G declaration names (const a = ...) +// Accent/H parameters and identifier references +// Accent/J classes and constructors (Promise) +// Success/Text/Default numeric literals +// Base/Text/Strong punctuation and unclassified code +// +// The highlighter is `react-syntax-highlighter`'s full `Prism` export, which is +// bound to `refractor/all` rather than the stock prismjs grammar. refractor +// emits a richer token set, so most of the frame maps directly: `control-flow` +// splits `return`/`await` off `keyword`, `arrow` separates `=>` from the other +// operators, and `parameter` covers declaration-site parameters. +// +// Two roles have no refractor token — the binding name in a declaration and a +// bare identifier reference. `prism-grammar.ts` adds `declaration-name` and +// `identifier` for those; without that module they fall back to punctuation. +// +// Taking tokens rather than the frame's literals also fixes the theme: the +// Figma midnight frame resolves the accent ramp to its light-theme values, +// which is why parameters and punctuation are barely legible there. Reading +// through `theme.palette.vars` gives each theme its own ramp. +export const prismStyle = (theme: Theme): { [key: string]: CSSProperties } => { + const { vars } = theme.palette; + + return { + 'pre[class*="language-"]': { background: "transparent", textShadow: "none" }, + 'code[class*="language-"]': { + background: "transparent", + textShadow: "none", + }, + + // Punctuation and operators stay on the code area's own text color. + punctuation: { color: vars.baseTextStrong }, + operator: { color: vars.baseTextStrong }, + + // Comments — Accent/E + comment: { color: vars.accentEDefault }, + prolog: { color: vars.accentEDefault }, + doctype: { color: vars.accentEDefault }, + cdata: { color: vars.accentEDefault }, + + // Keywords — Accent/A. `arrow` is a sub-token of `operator`; the frame + // paints `=>` with the keyword color, and class order lets it win. + keyword: { color: vars.accentADefault }, + atrule: { color: vars.accentADefault }, + arrow: { color: vars.accentADefault }, + + // Control-flow keywords — Accent/B. refractor tags these with both + // `keyword` and `control-flow`; `control-flow` is last, so it wins. + "control-flow": { color: vars.accentBDefault }, + + // Function names and call sites — Accent/F + function: { color: vars.accentFDefault }, + "function-variable": { color: vars.accentFDefault }, + + // Classes, constructors and language builtins — Accent/J + "class-name": { color: vars.accentJDefault }, + builtin: { color: vars.accentJDefault }, + entity: { color: vars.accentJDefault, cursor: "help" }, + + // Parameters, identifiers and property names — Accent/H. + // `identifier` comes from `prism-grammar.ts` and catches bare references + // (`x`, `b`, `console`) that no other pattern claimed. `variable` carries + // refractor's `dom` token, so `document` matches `console` rather than + // rendering as attention/regex. + parameter: { color: vars.accentHDefault }, + identifier: { color: vars.accentHDefault }, + variable: { color: vars.accentHDefault }, + property: { color: vars.accentHDefault }, + "literal-property": { color: vars.accentHDefault }, + "string-property": { color: vars.accentHDefault }, + "attr-name": { color: vars.accentHDefault }, + tag: { color: vars.accentHDefault }, + selector: { color: vars.accentHDefault }, + + // Declaration binding names — Accent/G. Also from `prism-grammar.ts`. + "declaration-name": { color: vars.accentGDefault }, + + // Literals — Success/Text/Default. The Figma sample is JavaScript with no + // string in it, so it does not specify a string color; grouping strings + // with numbers keeps every literal on one token now that Accent/G carries + // declaration names. + number: { color: vars.successTextDefault }, + boolean: { color: vars.successTextDefault }, + string: { color: vars.successTextDefault }, + char: { color: vars.successTextDefault }, + "attr-value": { color: vars.successTextDefault }, + url: { color: vars.successTextDefault }, + inserted: { color: vars.successTextDefault }, + ".language-css .token.string": { color: vars.successTextDefault }, + ".style .token.string": { color: vars.successTextDefault }, + + // Regex, symbols and emphasis — Accent/B, alongside control flow above. + regex: { color: vars.accentBDefault }, + "regex-delimiter": { color: vars.accentBDefault }, + "regex-source": { color: vars.accentBDefault }, + constant: { color: vars.accentBDefault }, + symbol: { color: vars.accentBDefault }, + deleted: { color: vars.accentBDefault }, + important: { color: vars.accentBDefault, fontWeight: "bold" }, + + bold: { fontWeight: "bold" }, + italic: { fontStyle: "italic" }, + }; }; diff --git a/packages/open-ui-kit/src/components/dialog/components/elements.tsx b/packages/open-ui-kit/src/components/dialog/components/elements.tsx index e9d2146b..253f60af 100644 --- a/packages/open-ui-kit/src/components/dialog/components/elements.tsx +++ b/packages/open-ui-kit/src/components/dialog/components/elements.tsx @@ -74,5 +74,9 @@ export const StyledDialogActions: ComponentType = styled( export const StyledDialogContentText: ComponentType = styled(MuiDialogContentText)(({ theme }) => ({ ...theme.typography.body2, - color: theme.palette.vars.baseTextDefault, + // MUI injects color="textSecondary" as a system prop whose styles are + // emitted after this override; the doubled selector outranks it. + "&&": { + color: theme.palette.vars.baseTextDefault, + }, })); diff --git a/packages/open-ui-kit/src/components/floating-button/__tests__/floating-button.test.tsx b/packages/open-ui-kit/src/components/floating-button/__tests__/floating-button.test.tsx index ccd39ab1..59aef2f5 100644 --- a/packages/open-ui-kit/src/components/floating-button/__tests__/floating-button.test.tsx +++ b/packages/open-ui-kit/src/components/floating-button/__tests__/floating-button.test.tsx @@ -97,7 +97,7 @@ describe("FloatingButton", () => { describe("token styles", () => { it("uses light theme tokens for primary styling", () => { expect(getFloatingButtonStyles(lightTheme, "primary")).toMatchObject({ - background: `${lightTheme.palette.vars.controlBackgroundDefault} !important`, + background: `${lightTheme.palette.vars.baseBackgroundWeak} !important`, border: `2px solid ${lightTheme.palette.vars.interactivePrimaryDefaultDefault} !important`, color: `${lightTheme.palette.vars.baseTextStrong} !important`, boxShadow: lightTheme.shadows[4], diff --git a/packages/open-ui-kit/src/components/floating-button/styles/index.ts b/packages/open-ui-kit/src/components/floating-button/styles/index.ts index 04dcbd18..f8c0cc9b 100644 --- a/packages/open-ui-kit/src/components/floating-button/styles/index.ts +++ b/packages/open-ui-kit/src/components/floating-button/styles/index.ts @@ -16,10 +16,15 @@ export const getFloatingButtonStyles = ( ? theme.palette.vars.interactivePrimaryDefaultDefault : theme.palette.vars.controlBorderDefault; + const backgroundColor = + variant === "primary" + ? theme.palette.vars.baseBackgroundWeak + : theme.palette.vars.controlBackgroundDefault; + return { borderRadius: "100px", boxShadow: theme.shadows[4], - background: `${theme.palette.vars.controlBackgroundDefault} !important`, + background: `${backgroundColor} !important`, color: `${theme.palette.vars.baseTextStrong} !important`, border: `2px solid ${borderColor} !important`, letterSpacing: "0.1px", diff --git a/packages/open-ui-kit/src/components/footer/stories/footer.stories.tsx b/packages/open-ui-kit/src/components/footer/stories/footer.stories.tsx index e5c2e516..e13d3f7d 100644 --- a/packages/open-ui-kit/src/components/footer/stories/footer.stories.tsx +++ b/packages/open-ui-kit/src/components/footer/stories/footer.stories.tsx @@ -28,7 +28,14 @@ const ProductNode = () => ( > {`© ${new Date().getFullYear()} Cisco Systems Inc. • powered by`} - + ({ + width: 66, + height: 15, + flexShrink: 0, + color: theme.palette.vars.interactivePrimaryDefaultDefault, + })} + /> ({ diff --git a/packages/open-ui-kit/src/components/header/__tests__/header.test.tsx b/packages/open-ui-kit/src/components/header/__tests__/header.test.tsx index a9d8271a..6aefea04 100644 --- a/packages/open-ui-kit/src/components/header/__tests__/header.test.tsx +++ b/packages/open-ui-kit/src/components/header/__tests__/header.test.tsx @@ -210,7 +210,7 @@ describe("Header", () => { height: "96px", borderRadius: "8px", border: `2px solid ${lightTheme.palette.vars.interactivePrimaryDefaultActive}`, - backgroundColor: lightTheme.palette.vars.baseBackgroundMedium, + backgroundColor: lightTheme.palette.vars.controlBackgroundWeak, boxShadow: lightTheme.shadows[2], padding: "8px 0", }), @@ -225,7 +225,7 @@ describe("Header", () => { height: "40px", padding: "8px 16px", color: darkTheme.palette.vars.baseTextDefault, - backgroundColor: darkTheme.palette.vars.baseBackgroundMedium, + backgroundColor: darkTheme.palette.vars.controlBackgroundWeak, "&:hover": { backgroundColor: darkTheme.palette.vars.baseBackgroundHover, }, diff --git a/packages/open-ui-kit/src/components/header/styles/index.ts b/packages/open-ui-kit/src/components/header/styles/index.ts index 167b4cdd..54a46c63 100644 --- a/packages/open-ui-kit/src/components/header/styles/index.ts +++ b/packages/open-ui-kit/src/components/header/styles/index.ts @@ -153,7 +153,7 @@ export const getStoryMenuPaperStyles = (theme: Theme) => ({ boxSizing: "border-box", borderRadius: "8px", border: `2px solid ${theme.palette.vars.interactivePrimaryDefaultActive}`, - backgroundColor: theme.palette.vars.baseBackgroundMedium, + backgroundColor: theme.palette.vars.controlBackgroundWeak, boxShadow: theme.shadows[2], padding: "8px 0", }, @@ -166,7 +166,7 @@ export const getStoryMenuItemStyles = (theme: Theme) => ({ height: "40px", padding: "8px 16px", color: theme.palette.vars.baseTextDefault, - backgroundColor: theme.palette.vars.baseBackgroundMedium, + backgroundColor: theme.palette.vars.controlBackgroundWeak, "&:hover": { backgroundColor: theme.palette.vars.baseBackgroundHover, }, diff --git a/packages/open-ui-kit/src/components/message/__tests__/message.test.tsx b/packages/open-ui-kit/src/components/message/__tests__/message.test.tsx index 644e5180..8bedc058 100644 --- a/packages/open-ui-kit/src/components/message/__tests__/message.test.tsx +++ b/packages/open-ui-kit/src/components/message/__tests__/message.test.tsx @@ -116,7 +116,7 @@ describe("Message", () => { height: "auto", padding: "12px 16px", gap: "12px", - background: lightTheme.palette.vars.baseBackgroundWeak, + background: lightTheme.palette.vars.controlBackgroundDefault, color: lightTheme.palette.vars.baseTextDefault, borderWidth: "1px 1px 1px 4px", borderRadius: "4px", @@ -206,7 +206,7 @@ describe("Message", () => { ).toMatchObject({ width: "480px", height: "auto", - background: darkTheme.palette.vars.baseBackgroundWeak, + background: darkTheme.palette.vars.controlBackgroundDefault, }); expect(getMessageActionStyles(darkTheme)).toMatchObject({ color: darkTheme.palette.vars.interactivePrimaryDefaultDefault, diff --git a/packages/open-ui-kit/src/components/message/styles/index.ts b/packages/open-ui-kit/src/components/message/styles/index.ts index d5750971..247e880c 100644 --- a/packages/open-ui-kit/src/components/message/styles/index.ts +++ b/packages/open-ui-kit/src/components/message/styles/index.ts @@ -59,7 +59,7 @@ export const getMessageRootStyles = ( height: "auto", padding: "12px 16px", gap: "12px", - background: theme.palette.vars.baseBackgroundWeak, + background: theme.palette.vars.controlBackgroundDefault, borderStyle: "solid", borderColor: getMessageStatusColor(theme, type), borderWidth: "1px 1px 1px 4px", diff --git a/packages/open-ui-kit/src/components/navigation/styles/index.ts b/packages/open-ui-kit/src/components/navigation/styles/index.ts index 8cfb7a3e..5a93b370 100644 --- a/packages/open-ui-kit/src/components/navigation/styles/index.ts +++ b/packages/open-ui-kit/src/components/navigation/styles/index.ts @@ -325,7 +325,7 @@ export const getNavigationDrawerItemStyles = ( "&:hover": { backgroundColor: selected ? theme.palette.vars.interactivePrimaryWeakHover - : theme.palette.vars.baseBackgroundHover, + : theme.palette.vars.brandBackgroundPrimaryMedium, }, }); diff --git a/packages/open-ui-kit/src/components/popover/__tests__/popover.test.tsx b/packages/open-ui-kit/src/components/popover/__tests__/popover.test.tsx index f800dc1c..664187d6 100644 --- a/packages/open-ui-kit/src/components/popover/__tests__/popover.test.tsx +++ b/packages/open-ui-kit/src/components/popover/__tests__/popover.test.tsx @@ -127,7 +127,7 @@ describe("Popover", () => { }); it("positions bottom-side popovers with arrows on the top edge", () => { - const bg = lightTheme.palette.vars.controlBackgroundDefault; + const bg = lightTheme.palette.vars.baseBackgroundWeak; expect(getArrowStyles(PopoverPlacement.BottomStart, bg)).toMatchObject({ top: "-8px", @@ -144,7 +144,7 @@ describe("Popover", () => { }); it("positions top-side popovers with arrows on the bottom edge", () => { - const bg = lightTheme.palette.vars.controlBackgroundDefault; + const bg = lightTheme.palette.vars.baseBackgroundWeak; expect(getArrowStyles(PopoverPlacement.TopStart, bg)).toMatchObject({ bottom: "-8px", @@ -205,7 +205,7 @@ describe("Popover", () => { }); it("positions left and right arrows outside the side edges", () => { - const bg = lightTheme.palette.vars.controlBackgroundDefault; + const bg = lightTheme.palette.vars.baseBackgroundWeak; expect(getArrowStyles(PopoverPlacement.Left, bg)).toMatchObject({ right: "-8px", @@ -260,7 +260,7 @@ describe("Popover", () => { width: "228px", minWidth: "228px", maxWidth: "228px", - background: lightTheme.palette.vars.controlBackgroundDefault, + background: lightTheme.palette.vars.baseBackgroundWeak, borderRadius: "6px", boxShadow: "none", overflow: "visible", @@ -277,7 +277,7 @@ describe("Popover", () => { maxWidth: "360px", }); expect(getPopoverContentStyles(lightTheme)).toMatchObject({ - background: lightTheme.palette.vars.controlBackgroundDefault, + background: lightTheme.palette.vars.baseBackgroundWeak, border: "0px solid transparent", borderRadius: "6px", gap: "16px", @@ -324,7 +324,7 @@ describe("Popover", () => { ).toMatchObject({ background: lightTheme.palette.vars.controlBorderActive, }); - expect(lightTheme.palette.vars.controlBackgroundDefault).toBe("#fbfcfe"); + expect(lightTheme.palette.vars.baseBackgroundWeak).toBe("#fbfcfe"); expect(lightTheme.palette.vars.controlBorderActive).toBe("#0051af"); expect(lightTheme.palette.vars.baseTextStrong).toBe("#00142b"); expect(lightTheme.palette.vars.baseTextDefault).toBe("#3c4551"); @@ -332,10 +332,10 @@ describe("Popover", () => { it("uses dark mode design tokens", () => { expect(getPopoverPaperStyles(darkTheme)).toMatchObject({ - background: darkTheme.palette.vars.controlBackgroundDefault, + background: darkTheme.palette.vars.baseBackgroundWeak, }); expect(getPopoverContentStyles(darkTheme, true)).toMatchObject({ - background: darkTheme.palette.vars.controlBackgroundDefault, + background: darkTheme.palette.vars.baseBackgroundWeak, border: `2px solid ${darkTheme.palette.vars.controlBorderActive}`, }); expect(popoverTitleStyles(darkTheme)).toMatchObject({ @@ -344,7 +344,7 @@ describe("Popover", () => { expect(popoverBodyStyles(darkTheme)).toMatchObject({ color: darkTheme.palette.vars.baseTextDefault, }); - expect(darkTheme.palette.vars.controlBackgroundDefault).toBe("#183056"); + expect(darkTheme.palette.vars.baseBackgroundWeak).toBe("#183056"); expect(darkTheme.palette.vars.controlBorderActive).toBe("#12c1ff"); expect(darkTheme.palette.vars.baseTextStrong).toBe("#ffffff"); expect(darkTheme.palette.vars.baseTextDefault).toBe("#e8e9ea"); diff --git a/packages/open-ui-kit/src/components/popover/components/popover.tsx b/packages/open-ui-kit/src/components/popover/components/popover.tsx index 68050498..0a7bf572 100644 --- a/packages/open-ui-kit/src/components/popover/components/popover.tsx +++ b/packages/open-ui-kit/src/components/popover/components/popover.tsx @@ -76,7 +76,7 @@ export const Popover = ({ }); const bg = featureHighlight ? theme.palette.vars.controlBorderActive - : theme.palette.vars.controlBackgroundDefault; + : theme.palette.vars.baseBackgroundWeak; return ( { return ( - + diff --git a/packages/open-ui-kit/test-utils/react-syntax-highlighter.js b/packages/open-ui-kit/test-utils/react-syntax-highlighter.js index 0bb57452..ca3d4b12 100644 --- a/packages/open-ui-kit/test-utils/react-syntax-highlighter.js +++ b/packages/open-ui-kit/test-utils/react-syntax-highlighter.js @@ -13,13 +13,20 @@ const SyntaxHighlighter = ({ lineNumberStyle, showLineNumbers, startingLineNumber = 1, + style, }) => { const lines = String(children ?? "").split("\n"); const { style: codeStyle, ...restCodeTagProps } = codeTagProps; return React.createElement( "pre", - { style: customStyle }, + { + style: customStyle, + // The real highlighter turns `style` into per-token colors. The mock + // cannot tokenize, so it records the map instead — otherwise a wrong or + // missing syntax palette would be invisible to every test. + "data-prism-style": style ? JSON.stringify(style) : undefined, + }, showLineNumbers ? React.createElement( "span", From b4e18dab7db60d8fb196cac4b42067c3f77c61a8 Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Thu, 6 Aug 2026 13:18:18 +0530 Subject: [PATCH 04/23] Fixes for Yarn Lock file Signed-off-by: Yashwant Rautela --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index f6fe598e..5e1427fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5269,6 +5269,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.1" react-virtuoso: "npm:^4.18.7" recharts: "npm:^2.15.3" + refractor: "npm:^5.0.0" rollup: "npm:^4.62.2" rollup-plugin-circular-dependencies: "npm:^2.0.1" rollup-plugin-cleanup: "npm:^3.2.1" From a6f359004438c124f04be6ba1a287d184056b30a Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Thu, 6 Aug 2026 13:18:46 +0530 Subject: [PATCH 05/23] fixes for avatar token (#14) Signed-off-by: Yashwant Rautela --- .../src/components/avatar/__tests__/avatar.test.tsx | 10 ++++++---- .../src/components/avatar/components/elements.tsx | 6 +++--- .../src/components/avatar/stories/avatar.stories.tsx | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/open-ui-kit/src/components/avatar/__tests__/avatar.test.tsx b/packages/open-ui-kit/src/components/avatar/__tests__/avatar.test.tsx index 42f371df..20da7384 100644 --- a/packages/open-ui-kit/src/components/avatar/__tests__/avatar.test.tsx +++ b/packages/open-ui-kit/src/components/avatar/__tests__/avatar.test.tsx @@ -108,10 +108,10 @@ describe("Avatar", () => { expect(styles.fontWeight).toBe("600"); expect(styles.lineHeight).toBe("133%"); expect(styles.letterSpacing).toBe("0.15px"); - expect(lightTheme.palette.vars.brandBackgroundPrimaryWeak).toBe( + expect(lightTheme.palette.vars.interactivePrimaryWeakDefault).toBe( "#e8f1ff", ); - expect(lightTheme.palette.vars.brandBackgroundPrimaryMedium).toBe( + expect(lightTheme.palette.vars.interactivePrimaryWeakHover).toBe( "#9bcaff", ); expect(lightTheme.palette.vars.brandIconPrimaryDefault).toBe("#187adc"); @@ -148,8 +148,10 @@ describe("Avatar", () => { expect(styles.backgroundColor).toBe("rgb(6, 34, 66)"); expect(styles.color).toBe("rgb(27, 205, 255)"); - expect(darkTheme.palette.vars.brandBackgroundPrimaryWeak).toBe("#062242"); - expect(darkTheme.palette.vars.brandBackgroundPrimaryMedium).toBe( + expect(darkTheme.palette.vars.interactivePrimaryWeakDefault).toBe( + "#062242", + ); + expect(darkTheme.palette.vars.interactivePrimaryWeakHover).toBe( "#263b62", ); expect(darkTheme.palette.vars.brandIconPrimaryDefault).toBe("#1bcdff"); diff --git a/packages/open-ui-kit/src/components/avatar/components/elements.tsx b/packages/open-ui-kit/src/components/avatar/components/elements.tsx index 3ea6a307..702adedd 100644 --- a/packages/open-ui-kit/src/components/avatar/components/elements.tsx +++ b/packages/open-ui-kit/src/components/avatar/components/elements.tsx @@ -24,7 +24,7 @@ export const StyledAvatar = styled(MuiAvatar, { borderRadius: "50px", backgroundColor: hasImage ? "transparent" - : theme.palette.vars.brandBackgroundPrimaryWeak, + : theme.palette.vars.interactivePrimaryWeakDefault, color: theme.palette.vars.brandIconPrimaryDefault, fontSize: avatarSize === "L" ? "16px" : "12px", fontWeight: 600, @@ -49,7 +49,7 @@ export const StyledAvatar = styled(MuiAvatar, { "&:hover": { backgroundColor: hasImage ? "transparent" - : theme.palette.vars.brandBackgroundPrimaryMedium, + : theme.palette.vars.interactivePrimaryWeakHover, color: theme.palette.vars.brandIconPrimaryStrong, "& .MuiSvgIcon-root": { @@ -81,7 +81,7 @@ export const StyledAvatarGroup = styled(MuiAvatarGroup, { width: avatarSize === "L" ? 40 : 32, height: avatarSize === "L" ? 40 : 32, borderRadius: "50px", - backgroundColor: theme.palette.vars.brandBackgroundPrimaryWeak, + backgroundColor: theme.palette.vars.interactivePrimaryWeakDefault, border: `2px solid ${ theme.palette.mode === "dark" ? theme.palette.vars.baseBorderWeak diff --git a/packages/open-ui-kit/src/components/avatar/stories/avatar.stories.tsx b/packages/open-ui-kit/src/components/avatar/stories/avatar.stories.tsx index b40d89a8..96832118 100644 --- a/packages/open-ui-kit/src/components/avatar/stories/avatar.stories.tsx +++ b/packages/open-ui-kit/src/components/avatar/stories/avatar.stories.tsx @@ -71,7 +71,7 @@ const HoverState = ({ children }: { children: ReactNode }) => ( ({ "& .MuiAvatar-root": { - bgcolor: theme.palette.vars.brandBackgroundPrimaryMedium, + bgcolor: theme.palette.vars.interactivePrimaryWeakHover, color: theme.palette.vars.brandIconPrimaryStrong, }, "& .MuiSvgIcon-root": { From 7487b93d137527f768037668334f22857406a3a5 Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Thu, 6 Aug 2026 13:19:00 +0530 Subject: [PATCH 06/23] fix: Navigation and Header Component (#15) Signed-off-by: Yashwant Rautela --- .../header/stories/header.stories.tsx | 33 +-- .../navigation/__tests__/navigation.test.tsx | 25 ++- .../navigation/components/navigation.tsx | 5 +- .../src/components/navigation/styles/index.ts | 50 +++-- .../src/custom-icons/brand-logos.tsx | 60 ++++-- .../src/custom-icons/navigation/dashboard.tsx | 199 ++++++++---------- .../src/theme/midnight/midnight-vars.ts | 7 +- 7 files changed, 193 insertions(+), 186 deletions(-) diff --git a/packages/open-ui-kit/src/components/header/stories/header.stories.tsx b/packages/open-ui-kit/src/components/header/stories/header.stories.tsx index f7eac0cc..994f9127 100644 --- a/packages/open-ui-kit/src/components/header/stories/header.stories.tsx +++ b/packages/open-ui-kit/src/components/header/stories/header.stories.tsx @@ -6,7 +6,7 @@ import { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; -import { Box, Stack, Typography } from "@/components"; +import { Badge, Box, Stack, Typography } from "@/components"; import { AccountCircleOutlined, ExpandMore, @@ -193,32 +193,11 @@ const defaultActions: HeaderAction[] = [ { id: "notifications", icon: ( - ({ - alignItems: "center", - backgroundColor: theme.palette.vars.excellentBackgroundDefault, - color: theme.palette.vars.baseTextInverse, - borderRadius: "64px", - content: '"1"', - display: "flex", - fontSize: "10px", - lineHeight: "16px", - height: "16px", - justifyContent: "center", - minWidth: "19px", - padding: "0 6.5px", - position: "absolute", - right: "-6px", - top: "-8px", - boxSizing: "border-box", - }), - }} - > - - + } + /> ), tooltip: "Notifications", "aria-label": "notifications", diff --git a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx index efa8b3e8..f45baf28 100644 --- a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx +++ b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx @@ -168,15 +168,26 @@ describe("Navigation", () => { ).toMatchObject({ height: "40px", padding: "8px", - backgroundColor: lightTheme.palette.vars.interactivePrimaryWeakDefault, - color: lightTheme.palette.vars.interactivePrimaryDefaultActive, + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryWeak, + color: lightTheme.palette.vars.brandTextPrimary, + // Figma binds no border on selected; only the open-submenu state has one. + border: "1px solid transparent", + borderRadius: "8px", }); expect(getNavigationItemStyles(lightTheme, "open", false)).toMatchObject({ - backgroundColor: lightTheme.palette.vars.interactivePrimaryWeakDefault, - color: lightTheme.palette.vars.interactivePrimaryDefaultActive, - border: `1px solid ${lightTheme.palette.vars.controlBorderStrong}`, + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryWeak, + // Open submenu keeps the secondary label color and is distinguished by + // its border, unlike selected. + color: lightTheme.palette.vars.brandTextSecondary, + border: `1px solid ${lightTheme.palette.vars.baseBorderStrong}`, borderRightWidth: 0, }); + expect( + getNavigationItemStyles(lightTheme, "default", false), + ).toMatchObject({ + backgroundColor: "transparent", + color: lightTheme.palette.vars.brandTextSecondary, + }); expect( getNavigationCollapseButtonStyles(lightTheme, false), ).toMatchObject({ @@ -208,8 +219,8 @@ describe("Navigation", () => { expect( getNavigationItemStyles(darkTheme, "selected", false), ).toMatchObject({ - backgroundColor: darkTheme.palette.vars.brandBackgroundSecondaryDefault, - color: darkTheme.palette.vars.brandIconPrimaryDefault, + backgroundColor: darkTheme.palette.vars.brandBackgroundPrimaryWeak, + color: darkTheme.palette.vars.brandTextPrimary, }); expect(getNavigationCollapseButtonStyles(darkTheme, true)).toMatchObject({ marginTop: "auto", diff --git a/packages/open-ui-kit/src/components/navigation/components/navigation.tsx b/packages/open-ui-kit/src/components/navigation/components/navigation.tsx index c13af469..14500773 100644 --- a/packages/open-ui-kit/src/components/navigation/components/navigation.tsx +++ b/packages/open-ui-kit/src/components/navigation/components/navigation.tsx @@ -176,7 +176,10 @@ export const Navigation = ({ subNavigationItem?.id === item.id ? "open" : getItemState(item, selectedItemId); - const selected = state === "selected" || state === "open"; + // Only "selected" takes the primary icon ramp. An item with + // its sub-menu open keeps the secondary ramp, matching the + // Figma frame. + const selected = state === "selected"; return ( { - const selected = state === "selected" || state === "open"; + // Tokens follow the Figma "Navigation" item states (frame node 179634:5030, + // `.menu-item`). `selected` and `open` share a background but not a text + // color: the frame paints selected labels Brand/Text/Primary and open-submenu + // labels Brand/Text/Secondary, distinguishing open by its border alone. + const selected = state === "selected"; const open = state === "open"; + const active = selected || open; const disabled = state === "disabled"; return { @@ -182,19 +187,21 @@ export const getNavigationItemStyles = ( gap: "8px", position: "relative", isolation: "isolate", - border: selected - ? `1px solid ${theme.palette.vars.controlBorderStrong}` + // Only the open-submenu state binds a border; it opens on the right edge to + // merge into the sub-navigation panel. Selected is fully rounded. + border: open + ? `1px solid ${theme.palette.vars.baseBorderStrong}` : "1px solid transparent", - borderRightWidth: selected ? 0 : "1px", - borderRadius: selected ? "8px 0px 0px 8px" : "8px", - backgroundColor: selected - ? getNavigationActiveBackground(theme) + borderRightWidth: open ? 0 : "1px", + borderRadius: open ? "8px 0px 0px 8px" : "8px", + backgroundColor: active + ? theme.palette.vars.brandBackgroundPrimaryWeak : "transparent", color: disabled ? theme.palette.vars.baseTextDisabled : selected - ? getNavigationActiveColor(theme) - : theme.palette.vars.baseTextStrong, + ? theme.palette.vars.brandTextPrimary + : theme.palette.vars.brandTextSecondary, cursor: disabled ? "default" : "pointer", font: "inherit", textAlign: "left", @@ -207,18 +214,16 @@ export const getNavigationItemStyles = ( right: compact ? "-24px" : "-23px", width: compact ? "24px" : "23px", height: "40px", - backgroundColor: getNavigationActiveBackground(theme), - borderTop: `1px solid ${theme.palette.vars.controlBorderStrong}`, - borderBottom: `1px solid ${theme.palette.vars.controlBorderStrong}`, + backgroundColor: theme.palette.vars.brandBackgroundPrimaryWeak, + borderTop: `1px solid ${theme.palette.vars.baseBorderStrong}`, + borderBottom: `1px solid ${theme.palette.vars.baseBorderStrong}`, zIndex: -1, } : undefined, "&:hover": disabled ? {} : { - backgroundColor: selected - ? getNavigationActiveBackground(theme) - : theme.palette.vars.baseBackgroundHover, + backgroundColor: theme.palette.vars.brandBackgroundPrimaryWeak, }, }; }; @@ -313,19 +318,22 @@ export const getNavigationDrawerItemStyles = ( gap: "2px", border: 0, borderRadius: "6px", + // Tokens follow the Figma "Navigation" drawer item states (frame node + // 179634:5030, `.drawer-item`). Default rests on Brand/Text/Secondary with no + // fill; hover and selected are the same treatment — + // Brand/Background/Primary/Medium behind Brand/Text/Primary. backgroundColor: selected - ? theme.palette.vars.interactivePrimaryWeakHover + ? theme.palette.vars.brandBackgroundPrimaryMedium : "transparent", color: selected - ? theme.palette.vars.interactivePrimaryDefaultActive - : theme.palette.vars.baseTextStrong, + ? theme.palette.vars.brandTextPrimary + : theme.palette.vars.brandTextSecondary, cursor: "pointer", font: "inherit", textAlign: "left", "&:hover": { - backgroundColor: selected - ? theme.palette.vars.interactivePrimaryWeakHover - : theme.palette.vars.brandBackgroundPrimaryMedium, + backgroundColor: theme.palette.vars.brandBackgroundPrimaryMedium, + color: theme.palette.vars.brandTextPrimary, }, }); diff --git a/packages/open-ui-kit/src/custom-icons/brand-logos.tsx b/packages/open-ui-kit/src/custom-icons/brand-logos.tsx index 7ed4e5cc..3a00e19e 100644 --- a/packages/open-ui-kit/src/custom-icons/brand-logos.tsx +++ b/packages/open-ui-kit/src/custom-icons/brand-logos.tsx @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SvgIcon, SvgIconProps } from "@mui/material"; +import { SvgIcon, SvgIconProps, useTheme } from "@mui/material"; +import { + outshiftBlue, + outshiftLogoGreen, + outshiftLogoLightBlue, + outshiftLogoOrange, +} from "@/theme/style/color-palette"; export const AgntcyBrand = (props: SvgIconProps) => { return ( @@ -80,84 +86,98 @@ export const CiscoBrand = (props: SvgIconProps) => { ); }; +// The Figma header (179783:9025) paints the Outshift mark in four brand colors +// plus a wordmark, so the artwork cannot share one inherited `currentColor`: +// +// spark horizontal, lower-left, lower ray Outshift/Logo Light Blue +// upper-right diagonal Outshift/Logo Green +// upper-left ray Outshift/Logo Orange +// vertical ray Outshift/Blue +// by CISCO Outshift/Logo Light Blue +// outshift wordmark Brand/Logo/Secondary +// +// The brand colors are fixed in every theme; only the wordmark follows the +// theme, so it reads `brandLogoSecondary` instead of a palette constant. export const OutshiftBrand = (props: SvgIconProps) => { + const { vars } = useTheme().palette; + return ( ); diff --git a/packages/open-ui-kit/src/custom-icons/navigation/dashboard.tsx b/packages/open-ui-kit/src/custom-icons/navigation/dashboard.tsx index ee61a0a2..df94077d 100644 --- a/packages/open-ui-kit/src/custom-icons/navigation/dashboard.tsx +++ b/packages/open-ui-kit/src/custom-icons/navigation/dashboard.tsx @@ -4,121 +4,106 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SvgIcon, SvgIconProps } from "@mui/material"; +import { SvgIcon, SvgIconProps, useTheme } from "@mui/material"; + +// The Figma navigation item (frame node 179634:5030) paints the Dashboard mark +// with three tones rather than a single flat fill, and swaps the whole ramp +// between the unselected and selected states: +// +// unselected tall block Brand/Icon/Secondary/Weak +// bars Brand/Icon/Secondary/Medium +// square Brand/Icon/Secondary/Default +// selected tall block Brand/Icon/Primary/Default +// bars Brand/Icon/Primary/Default +// square Brand/Icon/Primary/Strong +// +// The shapes therefore cannot use `currentColor` — the item only carries one +// inherited color, which is why all three variants rendered identically before. +type DashboardTones = { + block: string; + bars: string; + square: string; +}; + +const DashboardMark = ({ + tones, + ...props +}: SvgIconProps & { tones: DashboardTones }) => ( + + + + + + + + +); export const DashboardSelected = (props: SvgIconProps) => { + const { vars } = useTheme().palette; + return ( - - - - - - - - + ); }; export const DashboardUnSelected = (props: SvgIconProps) => { - return ( - - - - - - - - - ); -}; + const { vars } = useTheme().palette; -export const DashboardDisabled = (props: SvgIconProps) => { return ( - - - - - - - - + ); }; + +// The disabled state has no tonal ramp in the frame — it stays flat and takes +// the item's inherited disabled text color. +export const DashboardDisabled = (props: SvgIconProps) => ( + +); diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts index 20198350..0bef9bdb 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts @@ -105,9 +105,10 @@ export const midnightVars: VarsType = { brandIconPrimaryStrong: electricBluePalette[700], brandIconPrimaryMedium: electricBluePalette[300], brandIconPrimaryWeak: electricBluePalette[300], - brandIconSecondaryDefault: electricBluePalette[300], - brandIconSecondaryWeak: greyPalette[100], - brandIconSecondaryMedium: greyPalette[200], + // The Brand/Icon/Secondary ramp is not redefined for Midnight — the Figma + // Midnight mode resolves it to the same Surface values the Dark theme uses + // (Weak #e3eafa, Medium #c8d5f5, Default #4f628d), so it inherits from + // `darkVars` rather than being overridden here. brandBackgroundPrimaryDefault: darkNavyPalette[200], brandBackgroundPrimaryWeak: darkNavyPalette[100], brandBackgroundPrimaryMedium: surfaceDarkPalette[400], From a7b1729284f5feec31580bfa73a72fb893b844ce Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Fri, 7 Aug 2026 14:59:43 +0530 Subject: [PATCH 07/23] fix: format code and improve readability in various files Signed-off-by: Yashwant Rautela --- packages/open-ui-kit/src/colors/colors.mdx | 6 ++++- .../code-block/__tests__/code-block.test.tsx | 5 +++- .../src/components/code-block/styles/index.ts | 5 +++- .../open-ui-kit/src/typography/typography.mdx | 27 ++++++++++++------- 4 files changed, 31 insertions(+), 12 deletions(-) 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/code-block/__tests__/code-block.test.tsx b/packages/open-ui-kit/src/components/code-block/__tests__/code-block.test.tsx index 2a0b1edf..9e3cb57a 100644 --- a/packages/open-ui-kit/src/components/code-block/__tests__/code-block.test.tsx +++ b/packages/open-ui-kit/src/components/code-block/__tests__/code-block.test.tsx @@ -305,7 +305,10 @@ describe("CodeBlock", () => { walk(child, classes); } }; - walk(refractor.highlight(SAMPLE, "javascript") as unknown as HastNode, []); + walk( + refractor.highlight(SAMPLE, "javascript") as unknown as HastNode, + [], + ); return found[0] ?? []; }; diff --git a/packages/open-ui-kit/src/components/code-block/styles/index.ts b/packages/open-ui-kit/src/components/code-block/styles/index.ts index 0ed98fcb..50912db0 100644 --- a/packages/open-ui-kit/src/components/code-block/styles/index.ts +++ b/packages/open-ui-kit/src/components/code-block/styles/index.ts @@ -150,7 +150,10 @@ export const prismStyle = (theme: Theme): { [key: string]: CSSProperties } => { const { vars } = theme.palette; return { - 'pre[class*="language-"]': { background: "transparent", textShadow: "none" }, + 'pre[class*="language-"]': { + background: "transparent", + textShadow: "none", + }, 'code[class*="language-"]': { background: "transparent", textShadow: "none", 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", From 984291cb979ebd2ac6217eb11120b9da95a995d7 Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Fri, 7 Aug 2026 16:54:48 +0530 Subject: [PATCH 08/23] fix: enhance custom icon tests to handle multi-tone and brand color exceptions Signed-off-by: Yashwant Rautela --- .../icon/__tests__/custom-icons.test.ts | 78 ++++++++++++++----- 1 file changed, 57 insertions(+), 21 deletions(-) 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 fc06da1a..5965311d 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 @@ -25,30 +25,66 @@ function getSource(filePath: string) { return fs.readFileSync(filePath, "utf8"); } +// The dashboard nav icon paints a multi-tone ramp that a single inherited +// color can't express, so it's exempt from the `currentColor` rule (see the +// comment above DashboardMark in navigation/dashboard.tsx). +const MULTI_TONE_ICON_FILES = new Set([ + path.join("navigation", "dashboard.tsx"), +]); + +// OutshiftBrand paints the Outshift mark in fixed brand colors instead of +// `currentColor` by design (see the comment above the component in +// brand-logos.tsx): the mark must keep its brand colors regardless of theme. +// AgntcyBrand and CiscoBrand in the same file are still held to the normal +// rule. +const OUTSHIFT_BRAND_FILE = "brand-logos.tsx"; +const OUTSHIFT_BRAND_COLOR_IDENTIFIERS = new Set([ + "{outshiftLogoLightBlue}", + "{outshiftLogoGreen}", + "{outshiftLogoOrange}", + "{outshiftBlue}", + "{vars.brandLogoSecondary}", +]); + describe("custom icons", () => { it("uses currentColor for SVG fills and strokes", () => { - const badPaintAttributes = iconFiles.flatMap((filePath) => { - const source = getSource(filePath); - const matches = [ - ...source.matchAll( - /\b(?:fill|stroke)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g, - ), - ]; - - return matches - .filter((match) => { - const value = match[1].toLowerCase(); - return ( - value !== '"none"' && - value !== "'none'" && - value !== '"currentcolor"' && - value !== "'currentcolor'" + const badPaintAttributes = iconFiles + .filter( + (filePath) => + !MULTI_TONE_ICON_FILES.has(path.relative(customIconsDir, filePath)), + ) + .flatMap((filePath) => { + const source = getSource(filePath); + const matches = [ + ...source.matchAll( + /\b(?:fill|stroke)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g, + ), + ]; + const isOutshiftBrandFile = + path.relative(customIconsDir, filePath) === OUTSHIFT_BRAND_FILE; + + return matches + .filter((match) => { + if ( + isOutshiftBrandFile && + OUTSHIFT_BRAND_COLOR_IDENTIFIERS.has(match[1]) + ) { + return false; + } + + const value = match[1].toLowerCase(); + return ( + value !== '"none"' && + value !== "'none'" && + value !== '"currentcolor"' && + value !== "'currentcolor'" + ); + }) + .map( + (match) => + `${path.relative(customIconsDir, filePath)}: ${match[0]}`, ); - }) - .map( - (match) => `${path.relative(customIconsDir, filePath)}: ${match[0]}`, - ); - }); + }); expect(badPaintAttributes).toEqual([]); }); From 007e52a6266b069f047115840bcfa7ed4dc95f4d Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Tue, 11 Aug 2026 15:29:16 +0530 Subject: [PATCH 09/23] fix: enhance custom icon tests to handle multi-tone and brand color exceptions Signed-off-by: Yashwant Rautela --- .../components/card/components/elements.tsx | 23 ++++-- .../components/card/stories/card.stories.tsx | 35 ++++++-- .../src/components/card/styles/index.ts | 4 +- .../icon/__tests__/custom-icons.test.ts | 82 ++++++++++++++----- .../navigation/__tests__/navigation.test.tsx | 63 ++++++++++++++ .../navigation/components/navigation.tsx | 4 +- .../src/components/navigation/styles/index.ts | 9 +- .../src/custom-icons/org-switcher.tsx | 49 ++++++++--- 8 files changed, 210 insertions(+), 59 deletions(-) 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 dbfc4d55..8cb7fe39 100644 --- a/packages/open-ui-kit/src/components/card/components/elements.tsx +++ b/packages/open-ui-kit/src/components/card/components/elements.tsx @@ -39,22 +39,27 @@ export const StyledCard = styled(MuiCard, { export const StyledCardActionArea = styled(MuiCardActionArea)(({ theme }) => ({ borderRadius: "8px", - "&:hover .MuiCard-root, &:focus-visible .MuiCard-root": { - ...cardInteractiveStyles(theme), - "& .MuiCardActionArea-focusHighlight": { - opacity: 0, - }, - }, + "&:hover .MuiCard-root, &:focus-visible .MuiCard-root": + cardInteractiveStyles(theme), "&:active .MuiCard-root": cardActiveStyles(theme), "&.Mui-disabled .MuiCard-root": cardDisabledStyles(theme), + // MUI paints a translucent overlay (`focusHighlight`) across the whole action + // area on hover and focus. It is a sibling of the card, not a descendant, so + // it cannot be reached from a `.MuiCard-root` rule, and MUI scopes its own + // rules as `&:hover .focusHighlight` / `&.Mui-focusVisible .focusHighlight`. + // A bare `& .focusHighlight` is one specificity step lower and loses to them, + // so each selector has to be matched directly to keep the overlay hidden. "& .MuiCardActionArea-focusHighlight": { opacity: 0, }, + "&:hover .MuiCardActionArea-focusHighlight": { + opacity: 0, + }, + "&.Mui-focusVisible .MuiCardActionArea-focusHighlight": { + opacity: 0, + }, "&:focus-visible": { outline: "none", - "& .MuiCardActionArea-focusHighlight": { - opacity: 0, - }, }, })) as ComponentType; 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 f618ec3d..fecd52fe 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 @@ -20,12 +20,16 @@ import { CardContent, CardDescription, CardHeader, + CardSubheader, Link, LinkType, Skeleton, Stack, + Tag, + TagStatus, Typography, } from "@/components"; +import { GeneralSize } from "@/common"; import { cardActiveStyles, cardSkeletonStyles } from "../styles"; import { DocsHeader } from "storybook/components/docs-header.stories"; @@ -83,7 +87,12 @@ const CardStats = () => ( color: theme.palette.vars.baseTextMedium, })} > - + ({ + color: theme.palette.vars.successIconDefault, + fontSize: 16, + })} + /> 10k @@ -131,23 +140,29 @@ const ImportCardContent = () => ( ({ alignItems: "center", + backgroundColor: theme.palette.vars.baseBackgroundMedium, border: `1px solid ${theme.palette.vars.controlBorderActive}`, borderRadius: "4px", + color: theme.palette.vars.controlIconDefault, display: "flex", - height: 32, + height: 40, justifyContent: "center", - width: 32, + width: 40, })} > - + Import Existing - + ({ + backgroundColor: theme.palette.vars.accentGWeak, + })} + type="info" + /> - - Upload A2A card, MCP config, or OASF file - + Upload A2A card, MCP config, or OASF file @@ -260,7 +275,9 @@ export const Metrics: Story = { Headline not clickable - + + 0% + ({ }); export const cardDisabledStyles = (theme: Theme): CSSObject => ({ - backgroundColor: theme.palette.vars.controlBackgroundWeak, - border: `1px solid ${theme.palette.vars.controlBorderWeak}`, + backgroundColor: theme.palette.vars.controlBackgroundDisabled, + border: `1px solid ${theme.palette.vars.controlBorderDisabled}`, boxShadow: theme.shadows[2], color: theme.palette.vars.baseTextDisabled, pointerEvents: "none" as const, 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 fc06da1a..a6f9bbba 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 @@ -6,6 +6,10 @@ import fs from "node:fs"; import path from "node:path"; +// The suite only reads sources, but it still needs the jest globals to be +// typed, and `typeRoots` in tsconfig.json does not reach the hoisted +// `@types/jest`. Every other suite picks them up through this import. +import "@testing-library/jest-dom"; const customIconsDir = path.resolve(__dirname, "../../../custom-icons"); @@ -25,30 +29,66 @@ function getSource(filePath: string) { return fs.readFileSync(filePath, "utf8"); } +// The dashboard nav icon paints a multi-tone ramp that a single inherited +// color can't express, so it's exempt from the `currentColor` rule (see the +// comment above DashboardMark in navigation/dashboard.tsx). +const MULTI_TONE_ICON_FILES = new Set([ + path.join("navigation", "dashboard.tsx"), +]); + +// OutshiftBrand paints the Outshift mark in fixed brand colors instead of +// `currentColor` by design (see the comment above the component in +// brand-logos.tsx): the mark must keep its brand colors regardless of theme. +// AgntcyBrand and CiscoBrand in the same file are still held to the normal +// rule. +const OUTSHIFT_BRAND_FILE = "brand-logos.tsx"; +const OUTSHIFT_BRAND_COLOR_IDENTIFIERS = new Set([ + "{outshiftLogoLightBlue}", + "{outshiftLogoGreen}", + "{outshiftLogoOrange}", + "{outshiftBlue}", + "{vars.brandLogoSecondary}", +]); + describe("custom icons", () => { it("uses currentColor for SVG fills and strokes", () => { - const badPaintAttributes = iconFiles.flatMap((filePath) => { - const source = getSource(filePath); - const matches = [ - ...source.matchAll( - /\b(?:fill|stroke)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g, - ), - ]; - - return matches - .filter((match) => { - const value = match[1].toLowerCase(); - return ( - value !== '"none"' && - value !== "'none'" && - value !== '"currentcolor"' && - value !== "'currentcolor'" + const badPaintAttributes = iconFiles + .filter( + (filePath) => + !MULTI_TONE_ICON_FILES.has(path.relative(customIconsDir, filePath)), + ) + .flatMap((filePath) => { + const source = getSource(filePath); + const matches = [ + ...source.matchAll( + /\b(?:fill|stroke)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g, + ), + ]; + const isOutshiftBrandFile = + path.relative(customIconsDir, filePath) === OUTSHIFT_BRAND_FILE; + + return matches + .filter((match) => { + if ( + isOutshiftBrandFile && + OUTSHIFT_BRAND_COLOR_IDENTIFIERS.has(match[1]) + ) { + return false; + } + + const value = match[1].toLowerCase(); + return ( + value !== '"none"' && + value !== "'none'" && + value !== '"currentcolor"' && + value !== "'currentcolor'" + ); + }) + .map( + (match) => + `${path.relative(customIconsDir, filePath)}: ${match[0]}`, ); - }) - .map( - (match) => `${path.relative(customIconsDir, filePath)}: ${match[0]}`, - ); - }); + }); expect(badPaintAttributes).toEqual([]); }); diff --git a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx index f45baf28..e2c1ee05 100644 --- a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx +++ b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx @@ -12,6 +12,7 @@ import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; import { Navigation, NavigationDrawer, NavigationSubNavigation } from ".."; import { + getNavigationDrawerItemStyles, getNavigationDrawerStyles, getNavigationCollapseButtonStyles, getNavigationItemStyles, @@ -188,6 +189,24 @@ describe("Navigation", () => { backgroundColor: "transparent", color: lightTheme.palette.vars.brandTextSecondary, }); + // Hovering a sub-menu option adds the fill but keeps the label on + // Brand/Text/Secondary; only selected holds Brand/Text/Primary. + expect(getNavigationDrawerItemStyles(lightTheme, false)).toMatchObject({ + backgroundColor: "transparent", + color: lightTheme.palette.vars.brandTextSecondary, + "&:hover": { + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryMedium, + color: lightTheme.palette.vars.brandTextSecondary, + }, + }); + expect(getNavigationDrawerItemStyles(lightTheme, true)).toMatchObject({ + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryMedium, + color: lightTheme.palette.vars.brandTextPrimary, + "&:hover": { + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryMedium, + color: lightTheme.palette.vars.brandTextPrimary, + }, + }); expect( getNavigationCollapseButtonStyles(lightTheme, false), ).toMatchObject({ @@ -229,4 +248,48 @@ describe("Navigation", () => { }); }); }); + + /* + * Figma `.Switcher` (179634:5059) resolves the bracket and the arrow to + * Brand/Icon/Secondary/Weak and the diamond to Brand/Icon/Secondary/Default. + * Two tones cannot ride on `currentColor`, since the switcher button passes + * down only one inherited colour — which is why the nav renders the complete + * `OrgSwitcher` rather than the single-tone `OrgSwitcherDefault`. + */ + describe("organization switcher mark", () => { + const switcherPaths = (container: HTMLElement) => + Array.from( + container.querySelectorAll( + 'button[aria-label="[Organization]"] svg path, button[aria-label="[Organization]"] path', + ), + ).map((path) => path.getAttribute("fill")); + + it.each([ + ["light", false, lightTheme], + ["dark", true, darkTheme], + ])("paints all three %s layers on the brand ramp", (_mode, dark, theme) => { + const { container } = wrap( + , + dark, + ); + const fills = switcherPaths(container); + + expect(fills).toEqual([ + theme.palette.vars.brandIconSecondaryWeak, + theme.palette.vars.brandIconSecondaryWeak, + theme.palette.vars.brandIconSecondaryDefault, + ]); + // The single-tone mark would have collapsed the layers onto one + // inherited colour. + expect(fills).not.toContain("currentColor"); + }); + + it("keeps the two tones distinguishable in both themes", () => { + for (const theme of [lightTheme, darkTheme]) { + expect(theme.palette.vars.brandIconSecondaryWeak).not.toBe( + theme.palette.vars.brandIconSecondaryDefault, + ); + } + }); + }); }); diff --git a/packages/open-ui-kit/src/components/navigation/components/navigation.tsx b/packages/open-ui-kit/src/components/navigation/components/navigation.tsx index 14500773..1c25c990 100644 --- a/packages/open-ui-kit/src/components/navigation/components/navigation.tsx +++ b/packages/open-ui-kit/src/components/navigation/components/navigation.tsx @@ -9,7 +9,7 @@ import { DashboardSelected, DashboardUnSelected, KeyboardArrowRight, - OrgSwitcherDefault, + OrgSwitcher, } from "@/custom-icons"; import { useEffect, useState, type ReactNode } from "react"; import type { @@ -153,7 +153,7 @@ export const Navigation = ({ selected={isOrganizationDrawerOpen} type="button" > - + {!isCompact ? ( <> diff --git a/packages/open-ui-kit/src/components/navigation/styles/index.ts b/packages/open-ui-kit/src/components/navigation/styles/index.ts index 3698281c..2bd930b6 100644 --- a/packages/open-ui-kit/src/components/navigation/styles/index.ts +++ b/packages/open-ui-kit/src/components/navigation/styles/index.ts @@ -320,8 +320,9 @@ export const getNavigationDrawerItemStyles = ( borderRadius: "6px", // Tokens follow the Figma "Navigation" drawer item states (frame node // 179634:5030, `.drawer-item`). Default rests on Brand/Text/Secondary with no - // fill; hover and selected are the same treatment — - // Brand/Background/Primary/Medium behind Brand/Text/Primary. + // fill; hover adds the Brand/Background/Primary/Medium fill but keeps the + // label on Brand/Text/Secondary. Only selected paints Brand/Text/Primary, and + // it holds that ramp while hovered. backgroundColor: selected ? theme.palette.vars.brandBackgroundPrimaryMedium : "transparent", @@ -333,7 +334,9 @@ export const getNavigationDrawerItemStyles = ( textAlign: "left", "&:hover": { backgroundColor: theme.palette.vars.brandBackgroundPrimaryMedium, - color: theme.palette.vars.brandTextPrimary, + color: selected + ? theme.palette.vars.brandTextPrimary + : theme.palette.vars.brandTextSecondary, }, }); diff --git a/packages/open-ui-kit/src/custom-icons/org-switcher.tsx b/packages/open-ui-kit/src/custom-icons/org-switcher.tsx index b29d8248..0ea199cc 100644 --- a/packages/open-ui-kit/src/custom-icons/org-switcher.tsx +++ b/packages/open-ui-kit/src/custom-icons/org-switcher.tsx @@ -4,15 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SvgIcon, SvgIconProps } from "@mui/material"; +import { SvgIcon, SvgIconProps, useTheme } from "@mui/material"; + +// The mark is drawn as three stacked layers, named after the +// Brand/Icon/Secondary ramp in Figma: the "Weak" bracket along the bottom, the +// "Medium" arrow on the right and the "Default" diamond on the left. The +// exports below build the partial marks by stacking those layers, so the +// geometry is declared once. +const BRACKET_PATH = + "M 10,10.583 C 10.55,10.583 11,11.033 11,11.583 L 11,13.583 L 19,13.583 C 19.55,13.583 20,14.033 20,14.583 L 20,17.583 C 20,18.143 19.55,18.583 19,18.583 C 18.45,18.583 18,18.143 18,17.583 L 18,15.583 L 11,15.583 L 11,17.583 C 11,18.143 10.55,18.583 10,18.583 C 9.45,18.583 9,18.143 9,17.583 L 9,15.583 L 2,15.583 L 2,17.583 C 2,18.143 1.55,18.583 1,18.583 C 0.45,18.583 0,18.143 0,17.583 L 0,14.583 C 0,14.033 0.45,13.583 1,13.583 L 9,13.583 L 9,11.583 C 9,11.033 9.45,10.583 10,10.583"; + +const ARROW_PATH = + "M 11.06,7.643 L 12.29,8.883 C 12.68,9.273 13.32,9.273 13.71,8.883 L 17.29,5.293 C 17.68,4.903 17.68,4.273 17.29,3.883 L 13.71,0.293 C 13.32,-0.097 12.68,-0.097 12.29,0.293 L 11.06,1.523 L 12.35,2.823 C 12.39,2.863 12.43,2.903 12.47,2.943 L 13,2.413 L 15.17,4.583 L 13,6.753 L 12.47,6.233 C 12.43,6.273 12.39,6.313 12.35,6.353 L 11.06,7.643"; + +const DIAMOND_PATH = + "M 6.29,0.293 C 6.68,-0.097 7.32,-0.097 7.71,0.293 L 11.29,3.883 C 11.68,4.273 11.68,4.903 11.29,5.293 L 7.71,8.883 C 7.32,9.273 6.68,9.273 6.29,8.883 L 2.71,5.293 C 2.32,4.903 2.32,4.273 2.71,3.883 L 6.29,0.293"; export function OrgSwitcherWeak(props: SvgIconProps) { return ( - + ); } @@ -20,10 +31,7 @@ export function OrgSwitcherWeak(props: SvgIconProps) { export function OrgSwitcherMedium(props: SvgIconProps) { return ( - + ); } @@ -31,10 +39,25 @@ export function OrgSwitcherMedium(props: SvgIconProps) { export function OrgSwitcherDefault(props: SvgIconProps) { return ( - + + + ); +} + +// The complete mark, matching the `.Switcher` frame (node 179634:5059) in the +// Outshift Spark Component Library. All three layers are painted, and the frame +// resolves the bracket and the arrow to Brand/Icon/Secondary/Weak while the +// diamond takes Brand/Icon/Secondary/Default. That two-tone ramp is why the +// layers cannot share `currentColor` — the caller only carries one inherited +// color. +export function OrgSwitcher(props: SvgIconProps) { + const { vars } = useTheme().palette; + + return ( + + + + ); } From ef2c8592d770cfc7e841030dffbfc282b220dcfe Mon Sep 17 00:00:00 2001 From: jatinverma-prog Date: Thu, 30 Jul 2026 12:59:13 +0530 Subject: [PATCH 10/23] Button | Gradient Variant --- .../button/ButtonIconOnlyGradient.js | 27 + .../button/ButtonIconOnlyGradient.tsx | 27 + .../data/material/components/button/button.md | 7 + packages/open-ui-kit/jest.verify.tmp.js | 3 + .../button/__tests__/button.test.tsx | 137 ++++ .../components/button/components/elements.tsx | 621 +++++++++++------- .../button/stories/button.stories.tsx | 42 +- .../src/components/button/types/index.ts | 2 +- .../gradients/theme-gradients.stories.tsx | 192 ++++++ packages/open-ui-kit/src/index.ts | 3 + .../open-ui-kit/src/theme/dark/dark-theme.tsx | 2 + .../open-ui-kit/src/theme/ioc/ioc-theme.tsx | 3 + .../src/theme/light/light-theme.tsx | 2 + .../theme/midnight/midnight-gradient-vars.ts | 120 ++++ .../src/theme/midnight/midnight-theme.tsx | 2 + .../style/__tests__/gradient-vars.test.ts | 158 +++++ .../src/theme/style/color-palette.ts | 80 +++ .../src/theme/style/gradient-vars-base.ts | 99 +++ .../open-ui-kit/src/types/gradient-vars.ts | 72 ++ packages/open-ui-kit/src/types/theme.ts | 7 + 20 files changed, 1359 insertions(+), 247 deletions(-) create mode 100644 docs/data/material/components/button/ButtonIconOnlyGradient.js create mode 100644 docs/data/material/components/button/ButtonIconOnlyGradient.tsx create mode 100644 packages/open-ui-kit/jest.verify.tmp.js create mode 100644 packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx create mode 100644 packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts create mode 100644 packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts create mode 100644 packages/open-ui-kit/src/theme/style/gradient-vars-base.ts create mode 100644 packages/open-ui-kit/src/types/gradient-vars.ts 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/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/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..18f38f8d 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,15 @@ export const Secondary: Story = { render: (args) => , }; +/** Gradient background fill — Figma token `Gradient/Global-Button-Primary/Fill`. */ +export const Gradient: Story = { + args: { + ...defaultArgs, + variant: "gradient", + }, + render: (args) => , +}; + export const Outlined: Story = { args: { ...defaultArgs, @@ -266,6 +284,15 @@ export const Outlined: Story = { render: (args) => , }; +/** Gradient border ring — Figma token `Gradient/Global-Button-Primary/Border-Glow`. */ +export const GradientOutlined: Story = { + args: { + ...defaultArgs, + variant: "gradientOutlined", + }, + render: (args) => , +}; + export const Tertiary: Story = { args: { ...defaultArgs, @@ -334,6 +361,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/gradients/theme-gradients.stories.tsx b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx new file mode 100644 index 00000000..2e62cd15 --- /dev/null +++ b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx @@ -0,0 +1,192 @@ +/* + * 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", + "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/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..2a6b3207 --- /dev/null +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -0,0 +1,120 @@ +/* + * 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, +} 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, + 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%)`, + // TODO(verify): 166.51deg read from a 2:1 swatch. + gradientGraphConnectorFill: `linear-gradient(166.51deg, ${alpha(stops.graphConnectorBlue, 0.035)} 0%, ${alpha(stops.graphConnectorBlue, 0.016)} 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. + + // Measured: alpha holds at 0.30 to ~72%, then falls linearly to 0. + gradientCardGlassBg: `linear-gradient(90deg, ${alpha(stops.glassWhite, 0.3)} 0%, ${alpha(stops.glassWhiteWeak, 0.3)} 72%, ${alpha(stops.glassGray, 0)} 100%)`, + // Figma labels this swatch "LINEAR - 70%", but it renders pixel-identical to + // `gradientCardGlassBg` — the 70% is not applied to the fill. Kept as a + // separate token because the design documents it as one; values intentionally + // match until design confirms whether the label or the fill is authoritative. + gradientCardGlassBgSubtle: `linear-gradient(90deg, ${alpha(stops.glassWhite, 0.3)} 0%, ${alpha(stops.glassWhiteWeak, 0.3)} 72%, ${alpha(stops.glassGray, 0)} 100%)`, + + // Measured: first stop renders #0a60ff, not the #0a66ff shown on the label. + gradientGlobalBorderFade: `linear-gradient(90deg, ${stops.panelExecBorderBlue} 0%, ${stops.buttonPrimaryGlowCyan} 50%, ${stops.globalBorderSlate} 70%, ${alpha(stops.globalBorderSlateWeak, 0.7)} 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%)`, + // Measured: alpha ramps UP left to right (6% -> 16%), opposite to the label order. + 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. + gradientCardHighlightRadial: `linear-gradient(90deg, ${stops.glassWhite} 0%, ${alpha(stops.glassGray, 0)} 100%)`, + + // --- Radial glows -------------------------------------------------------- + // TODO(verify): geometry approximated from Figma's SVG `gradientTransform` + // matrix. Figma's intermediate stops are linear interpolations between the + // designed stops, so only the designed stops are kept. + + gradientGlowOrange: `radial-gradient(ellipse at 50% 67%, ${alpha(stops.glowOrangeStart, 0.8)} 0%, ${alpha(stops.glowOrangeEnd, 0.8)} 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%)`, + gradientPanelExecBorder: `radial-gradient(ellipse at 50% 50%, ${stops.panelExecBorderBlue} 0%, ${stops.dataVizCyan} 33%, ${stops.globalBorderSlate} 66%, ${alpha(stops.globalBorderSlateWeak, 0.7)} 100%)`, + 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..dd229feb --- /dev/null +++ b/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts @@ -0,0 +1,158 @@ +/* + * 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.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%)", + ); + }); + + // `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.gradientCardGlassBg, + g.gradientCardGlassBgSubtle, + g.gradientGlobalBorderFade, + g.gradientGlobalBorderRainbow, + g.gradientGlobalButtonPrimaryBorderGlow, + g.gradientDashboardGraphNodeBorder, + g.gradientGraphConnectorStroke, + g.gradientIconButtonBlueGlow, + g.gradientCardHighlightRadial, + ]; + for (const value of strokes) { + expect(value.startsWith("linear-gradient(90deg,")).toBe(true); + } + }); + + it("holds the glass ramp flat until 72%", () => { + expect(g.gradientCardGlassBg).toBe( + "linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0%, rgba(241, 241, 241, 0.3) 72%, rgba(153, 153, 153, 0) 100%)", + ); + }); + + // Renders pixel-identical to the non-subtle variant despite its "70%" label. + it("keeps both glass variants identical", () => { + expect(g.gradientCardGlassBgSubtle).toBe(g.gradientCardGlassBg); + }); + + // Alpha ramps up left to right, opposite to the order shown on the label. + 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%)", + ); + }); + + it("places the rainbow pink stop at 83%", () => { + expect(g.gradientGlobalBorderRainbow).toContain("#ff007f 83%"); + }); + + // The label says #0a66ff; the rendered stroke measures #0a60ff. + it("uses the measured first stop for the border fade", () => { + expect(g.gradientGlobalBorderFade).toBe( + "linear-gradient(90deg, #0a60ff 0%, #00bceb 50%, #3d5066 70%, rgba(77, 99, 128, 0.7) 100%)", + ); + }); + }); +}); 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..48daa177 100644 --- a/packages/open-ui-kit/src/theme/style/color-palette.ts +++ b/packages/open-ui-kit/src/theme/style/color-palette.ts @@ -654,3 +654,83 @@ 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", + + // 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", + glassGray: "#999999", + + // Glows + glowOrangeStart: "#ff9d00", + glowOrangeEnd: "#ffd48e", + 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..26356838 --- /dev/null +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -0,0 +1,99 @@ +/* + * 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%)`; +export const gradientButtonPrimaryBorderGlow = `linear-gradient(90deg, ${stops.buttonPrimaryGlowBlue} 0%, ${stops.buttonPrimaryGlowCyan} 50%, ${stops.buttonPrimaryGlowTeal} 100%)`; + +/* + * Base gradient tokens — the fallback every theme starts from. + * + * Apart from the two button gradients above, 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 shared button + * gradients above 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, + gradientGlobalDividerFade: gradientsIllustrationsRainbow, + gradientOverlayBlackFadeIn: gradientsBackgroundDark, + gradientWelcomeCardBgDark: gradientsBackgroundDark, + gradientAlertLineRed: gradientsRedPressed, + gradientDashboardGraphNodeFill: gradientsSecondaryDefault, + gradientGraphConnectorFill: gradientsSecondaryHover, + gradientIconButtonBlue: gradientsSecondaryDefault, + + // --- Strokes ------------------------------------------------------------- + gradientCardGlassBg: gradientsSecondaryHover, + gradientCardGlassBgSubtle: gradientsSecondaryHover, + gradientGlobalBorderFade: gradientsSecondaryDefault, + gradientGlobalBorderRainbow: gradientsIllustrationsRainbow, + gradientGlobalButtonPrimaryBorderGlow: gradientButtonPrimaryBorderGlow, + gradientDashboardGraphNodeBorder: gradientsSecondaryDefault, + gradientGraphConnectorStroke: gradientsSecondaryHover, + gradientIconButtonBlueGlow: gradientsIllustrationsLightBlue, + gradientCardHighlightRadial: gradientsSecondaryHover, + + // --- Radial glows -------------------------------------------------------- + gradientGlowOrange: gradientsIllustrationsOrange, + gradientGlowPinkShadow: gradientsIllustrationsPink, + gradientBackgroundGlowBlue: gradientsIllustrationsBlue, + gradientPanelExecBorder: gradientsSecondaryDefault, + 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..10f5374f --- /dev/null +++ b/packages/open-ui-kit/src/types/gradient-vars.ts @@ -0,0 +1,72 @@ +/* + * 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; + gradientGlobalDividerFade: string; + gradientOverlayBlackFadeIn: string; + gradientWelcomeCardBgDark: string; + gradientAlertLineRed: string; + gradientDashboardGraphNodeFill: string; + gradientGraphConnectorFill: 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; + gradientGlobalBorderFade: 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; + 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..04a85a72 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; } From a91c025853dd7971ccdadb5a0b104245676a0a15 Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Mon, 3 Aug 2026 16:38:11 +0530 Subject: [PATCH 11/23] feat(typography): add gradient text variant (#7) Add a boolean gradient prop to Typography that fills text with a gradient via background-clip:text. Composes with any variant. New shared token gradientTextWhiteBlue (#FFFFFF -> #3F7DEF). Signed-off-by: Jatin Verma --- .../components/typography/typography.md | 18 ++++++++++ packages/open-ui-kit/src/components/index.ts | 2 +- .../typography/components/elements.tsx | 33 +++++++++++++++++++ .../typography/components/typography.tsx | 7 ++++ .../src/components/typography/index.ts | 7 ++++ .../{ => stories}/typography.stories.tsx | 24 ++++++++++++++ .../theme/midnight/midnight-gradient-vars.ts | 3 ++ .../style/__tests__/gradient-vars.test.ts | 3 ++ .../src/theme/style/color-palette.ts | 4 +++ .../src/theme/style/gradient-vars-base.ts | 20 ++++++++--- .../open-ui-kit/src/types/gradient-vars.ts | 2 ++ packages/open-ui-kit/src/types/theme.ts | 8 +++++ 12 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 packages/open-ui-kit/src/components/typography/components/elements.tsx create mode 100644 packages/open-ui-kit/src/components/typography/components/typography.tsx create mode 100644 packages/open-ui-kit/src/components/typography/index.ts rename packages/open-ui-kit/src/components/typography/{ => stories}/typography.stories.tsx (80%) 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/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/typography/components/elements.tsx b/packages/open-ui-kit/src/components/typography/components/elements.tsx new file mode 100644 index 00000000..de357923 --- /dev/null +++ b/packages/open-ui-kit/src/components/typography/components/elements.tsx @@ -0,0 +1,33 @@ +/* + * 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`. + */ +export const StyledTypography = styled(MuiTypography, { + shouldForwardProp: (prop) => prop !== "gradient", +})(({ theme, gradient }) => ({ + ...(gradient && { + background: theme.palette.gradients.gradientTextWhiteBlue, + WebkitBackgroundClip: "text", + backgroundClip: "text", + WebkitTextFillColor: "transparent", + // Render the gradient per line when the text wraps. + WebkitBoxDecorationBreak: "clone", + boxDecorationBreak: "clone", + }), +})) 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 80% 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..8c2bc564 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,27 @@ 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`. + */ +export const Gradient: Story = { + render: () => ( + + + Welcome Amy! + + + Gradient heading + + + Gradient subtitle + + + Gradient body text — the fill works on any variant. + + + ), +}; 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 index 2a6b3207..081994ec 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -10,6 +10,7 @@ import { baseGradientVars, gradientButtonPrimaryBorderGlow, gradientButtonPrimaryFill, + gradientTextWhiteBlue, } from "@/theme/style/gradient-vars-base"; import { gradientsRedPressed } from "@/theme/style/gradients"; import { @@ -57,6 +58,8 @@ export const midnightGradientVars: GradientVarsType = { // 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%)`, 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 index dd229feb..dc60381d 100644 --- 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 @@ -78,6 +78,9 @@ describe("midnight gradient values", () => { expect(g.gradientGlobalButtonPrimaryFill).toBe( "linear-gradient(90deg, #0745b8 0%, #2e6ee5 100%)", ); + expect(g.gradientTextWhiteBlue).toBe( + "linear-gradient(90deg, #ffffff 0%, #3f7def 100%)", + ); expect(g.gradientGaugeArcTeal).toBe( "linear-gradient(90deg, #29fcc4 0%, #00af2f 100%)", ); 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 48daa177..45fa2165 100644 --- a/packages/open-ui-kit/src/theme/style/color-palette.ts +++ b/packages/open-ui-kit/src/theme/style/color-palette.ts @@ -704,6 +704,10 @@ export const midnightGradientStops = { iconButtonBlueEnd: "#011d62", iconButtonGlowBlue: "#3974ff", + // Text (Figma variable: Gradient/Text-White-Blue) + textWhiteBlueStart: "#ffffff", + 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 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 index 26356838..34b5f5a9 100644 --- a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -32,11 +32,21 @@ import { export const gradientButtonPrimaryFill = `linear-gradient(90deg, ${stops.buttonPrimaryFillStart} 0%, ${stops.buttonPrimaryFillEnd} 100%)`; export const gradientButtonPrimaryBorderGlow = `linear-gradient(90deg, ${stops.buttonPrimaryGlowBlue} 0%, ${stops.buttonPrimaryGlowCyan} 50%, ${stops.buttonPrimaryGlowTeal} 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.textWhiteBlueEnd} 100%)`; + /* * Base gradient tokens — the fallback every theme starts from. * - * Apart from the two button gradients above, this deliberately contains NO new - * design values. + * 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 @@ -49,8 +59,9 @@ export const gradientButtonPrimaryBorderGlow = `linear-gradient(90deg, ${stops.b * 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 shared button - * gradients above reach the Midnight theme from this file. + * 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 --------------------------------------------------------------- @@ -71,6 +82,7 @@ export const baseGradientVars: GradientVarsType = { gradientGraphFlowTeal: gradientsIllustrationsGreen, gradientGraphFlowMaroon: gradientsRedPressed, gradientGlobalButtonPrimaryFill: gradientButtonPrimaryFill, + gradientTextWhiteBlue, gradientGlobalDividerFade: gradientsIllustrationsRainbow, gradientOverlayBlackFadeIn: gradientsBackgroundDark, gradientWelcomeCardBgDark: gradientsBackgroundDark, diff --git a/packages/open-ui-kit/src/types/gradient-vars.ts b/packages/open-ui-kit/src/types/gradient-vars.ts index 10f5374f..08a0b73f 100644 --- a/packages/open-ui-kit/src/types/gradient-vars.ts +++ b/packages/open-ui-kit/src/types/gradient-vars.ts @@ -39,6 +39,8 @@ export interface GradientVarsType { /** 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; diff --git a/packages/open-ui-kit/src/types/theme.ts b/packages/open-ui-kit/src/types/theme.ts index 04a85a72..4d775c28 100644 --- a/packages/open-ui-kit/src/types/theme.ts +++ b/packages/open-ui-kit/src/types/theme.ts @@ -113,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; + } } From dd6939d8f44b4519ecb3d108f82ec779edf07080 Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Mon, 3 Aug 2026 17:19:09 +0530 Subject: [PATCH 12/23] feat(activity-timeline): add gradient variant (#11) Add a gradient variant that renders glowing status dots, a per-step time, and a fade-out down older steps. New shared tokens gradientGlowGreen and gradientGlowRed; gradientGlowOrange geometry realigned to match. Signed-off-by: Jatin Verma --- .../activity-timeline/activity-timeline.md | 20 ++++++ .../components/activity-timeline-dot.tsx | 23 +++++- .../components/activity-timeline.tsx | 70 +++++++++++++++++++ .../activity-timeline/components/elements.tsx | 34 +++++++++ .../stories/activity-timeline.stories.tsx | 40 +++++++++++ .../activity-timeline/types/index.ts | 4 ++ .../activity-timeline/utils/utils.ts | 36 ++++++++++ .../gradients/theme-gradients.stories.tsx | 2 + .../theme/midnight/midnight-gradient-vars.ts | 7 +- .../style/__tests__/gradient-vars.test.ts | 13 ++++ .../src/theme/style/color-palette.ts | 4 ++ .../src/theme/style/gradient-vars-base.ts | 2 + .../open-ui-kit/src/types/gradient-vars.ts | 2 + 13 files changed, 255 insertions(+), 2 deletions(-) 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/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..eee089a0 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,9 +6,10 @@ 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 { getStepDotColor, getStepGlow } from "../utils/utils"; import { StyledTimelineDotRoot } from "./elements"; export interface ActivityTimelineDotProps extends BoxProps { @@ -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,23 @@ 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 => { + if (steps.length <= 1) { + return 1; + } + return Math.max(0.3, 1 - (stepIdx / (steps.length - 1)) * 0.7); + }, + [steps.length], + ); + const setPercent = useCallback( (stepIdx: number): number => { if (steps.length <= 1) { @@ -40,6 +55,61 @@ export const ActivityTimeline = ({ [steps.length], ); + if (variant === "gradient") { + return ( + + {steps.map((step, index) => ( + // Fade each slot, not the item, so the connector stays uniform. + + + + {step.titleStartIcon} + {step.title} + + + + + {index < steps.length - 1 && ( + + )} + + + {step.time && ( + + {step.time} + + )} + + + ))} + + ); + } + return ( ({ position: "relative", zIndex: 1, })) as ComponentType; + +// Overlaps the dots so the segments read as one continuous line. +export const StyledTimelineGlowConnector = styled(TimelineConnector, { + shouldForwardProp: (prop) => prop !== "lineColor", +})<{ lineColor?: string }>(({ lineColor }) => ({ + backgroundColor: lineColor, + width: "1px", + margin: "-4px 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. +export const StyledTimelineTimeContent = styled(TimelineContent)(() => ({ + flex: "0 0 48px", + 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..cc8ec4ac 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; + /** Optional time label shown beside the step in the gradient variant. */ + time?: string; } export interface ActivityTimelineProps extends Omit< @@ -38,6 +40,8 @@ export interface ActivityTimelineProps extends Omit< automaticProgress?: boolean; /** Controls the timeline title typography and vertical spacing. */ size?: "large" | "medium"; + /** Renders glowing status dots, a per-step time, and a fade-out when set to gradient. */ + variant?: "default" | "gradient"; /** Ordered steps rendered in the activity timeline. */ 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..e97d8d0c 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,39 @@ 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: + return theme.palette.vars?.controlIconDefault; + } +}; + +/** 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/gradients/theme-gradients.stories.tsx b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx index 2e62cd15..5695c652 100644 --- a/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx +++ b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx @@ -27,6 +27,8 @@ const STROKE_TOKENS: readonly TokenKey[] = [ const RADIAL_TOKENS: readonly TokenKey[] = [ "gradientGlowOrange", + "gradientGlowGreen", + "gradientGlowRed", "gradientGlowPinkShadow", "gradientBackgroundGlowBlue", "gradientPanelExecBorder", 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 index 081994ec..f3be643f 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -115,7 +115,12 @@ export const midnightGradientVars: GradientVarsType = { // matrix. Figma's intermediate stops are linear interpolations between the // designed stops, so only the designed stops are kept. - gradientGlowOrange: `radial-gradient(ellipse at 50% 67%, ${alpha(stops.glowOrangeStart, 0.8)} 0%, ${alpha(stops.glowOrangeEnd, 0.8)} 100%)`, + // The three status glows share one geometry: a circle centred on the top-left + // corner, matching the dot swatches. Orange was previously an approximated + // ellipse and is realigned here. + gradientGlowOrange: `radial-gradient(circle at 0% 0%, ${alpha(stops.glowOrangeStart, 0.8)} 0%, ${alpha(stops.glowOrangeEnd, 0.8)} 100%)`, + gradientGlowGreen: `radial-gradient(circle at 0% 0%, ${alpha(stops.glowGreenStart, 0.7)} 0%, ${alpha(stops.glowGreenEnd, 0.7)} 100%)`, + gradientGlowRed: `radial-gradient(circle 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%)`, gradientPanelExecBorder: `radial-gradient(ellipse at 50% 50%, ${stops.panelExecBorderBlue} 0%, ${stops.dataVizCyan} 33%, ${stops.globalBorderSlate} 66%, ${alpha(stops.globalBorderSlateWeak, 0.7)} 100%)`, 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 index dc60381d..eaccee36 100644 --- 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 @@ -92,6 +92,19 @@ describe("midnight gradient values", () => { ); }); + // 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 at 0% 0%, rgba(17, 255, 200, 0.7) 0%, rgba(179, 255, 129, 0.7) 100%)", + ); + expect(midnightGradientVars.gradientGlowOrange).toBe( + "radial-gradient(circle at 0% 0%, rgba(255, 157, 0, 0.8) 0%, rgba(255, 212, 142, 0.8) 100%)", + ); + expect(midnightGradientVars.gradientGlowRed).toBe( + "radial-gradient(circle 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. 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 45fa2165..8f58fcca 100644 --- a/packages/open-ui-kit/src/theme/style/color-palette.ts +++ b/packages/open-ui-kit/src/theme/style/color-palette.ts @@ -733,6 +733,10 @@ export const midnightGradientStops = { // Glows glowOrangeStart: "#ff9d00", glowOrangeEnd: "#ffd48e", + glowGreenStart: "#11ffc8", + glowGreenEnd: "#b3ff81", + glowRedStart: "#ff0329", + glowRedEnd: "#ff5746", glowBlueStart: "#4a6ac8", glowBlueMid: "#6abfff", glowBlueDeep: "#3b69bc", 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 index 34b5f5a9..3ef967db 100644 --- a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -104,6 +104,8 @@ export const baseGradientVars: GradientVarsType = { // --- Radial glows -------------------------------------------------------- gradientGlowOrange: gradientsIllustrationsOrange, + gradientGlowGreen: gradientsIllustrationsGreen, + gradientGlowRed: gradientsRedPressed, gradientGlowPinkShadow: gradientsIllustrationsPink, gradientBackgroundGlowBlue: gradientsIllustrationsBlue, gradientPanelExecBorder: gradientsSecondaryDefault, diff --git a/packages/open-ui-kit/src/types/gradient-vars.ts b/packages/open-ui-kit/src/types/gradient-vars.ts index 08a0b73f..91072005 100644 --- a/packages/open-ui-kit/src/types/gradient-vars.ts +++ b/packages/open-ui-kit/src/types/gradient-vars.ts @@ -67,6 +67,8 @@ export interface GradientVarsType { // --- Radial glows -------------------------------------------------------- gradientGlowOrange: string; + gradientGlowGreen: string; + gradientGlowRed: string; gradientGlowPinkShadow: string; gradientBackgroundGlowBlue: string; gradientPanelExecBorder: string; From 48fc25bd9865cbded9463bb46b7a4318a68ff2be Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Wed, 5 Aug 2026 12:56:21 +0530 Subject: [PATCH 13/23] gradient variants for spider charts (#8) Signed-off-by: Jatin Verma --- .../components/spider-chart/spider-chart.md | 23 ++ .../__tests__/spider-chart.test.tsx | 273 +++++++++++++++++- .../components/custom-gradient-radar.tsx | 115 ++++++++ .../spider-chart/components/spider-chart.tsx | 68 ++++- .../src/charts/spider-chart/index.ts | 2 + .../stories/spider-chart.stories.tsx | 81 +++++- .../styles/spider-chart.styles.ts | 85 +++++- .../spider-chart/types/spider-chart.types.ts | 34 +++ 8 files changed, 661 insertions(+), 20 deletions(-) create mode 100644 packages/open-ui-kit/src/charts/spider-chart/components/custom-gradient-radar.tsx 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/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..a66b45f8 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,30 @@ 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, + lightOrange600, + midnightGradientStops, + night700, + purple600, + 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 +89,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 +272,239 @@ 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, + purple600, + purpleAlpha40, + purple600, + ], + [ + "cyanBlue", + midnightTheme.palette.gradients.gradientDataVizCyanBlue, + night700, + blueAlpha40, + night700, + ], + [ + "orangeGold", + midnightTheme.palette.gradients.gradientDataVizOrangeGold, + lightOrange600, + lightAlphaOrange40, + lightOrange600, + ], + [ + "blueDark", + midnightTheme.palette.gradients.gradientDataVizBlueDark, + blue500, + "rgba(185, 171, 239, 0.76)", + // The one variant whose dot rings diverge from the outline color. + midnightGradientStops.dataVizBlue, + ], + ]; + + 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: night700, + 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..52358401 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,19 @@ * 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 { + blue500, + blueAlpha40, + lightAlphaOrange40, + lightOrange600, + midnightGradientStops, + night700, + purple600, + 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 +27,78 @@ 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` | `purple600` (9C4EEA) | `purpleAlpha40` (B76DFF66) | outline | + * | `cyanBlue` | `night700` (1C2B7F) | `blueAlpha40` (0051AF66) | outline | + * | `orangeGold` | `lightOrange600` | `lightAlphaOrange40` | outline | + * | `blueDark` | `blue500` (0051AF) | B9ABEF @ 76%, no palette name | `dataVizBlue` (3B82F6) | + * + * Blue-dark is the one variant whose dot rings are not the outline color: the + * frame strokes them with the ramp's own `3B82F6` stop. + * + * They are fixed palette values rather than theme vars for the same reason as + * the toast glow shadows: the ramp itself is Midnight-only, and design has not + * diverged the accents per theme. + */ +export const getSpiderChartGradient = ( + theme: Theme, + gradient: SpiderChartGradient, +): { background: string; stroke: string; dotFill: string; dotStroke: string } => { + const gradients = theme.palette.gradients; + + switch (gradient) { + case "cyanBlue": + return { + background: gradients.gradientDataVizCyanBlue, + stroke: night700, + dotFill: blueAlpha40, + dotStroke: night700, + }; + case "orangeGold": + return { + background: gradients.gradientDataVizOrangeGold, + stroke: lightOrange600, + dotFill: lightAlphaOrange40, + dotStroke: lightOrange600, + }; + case "blueDark": + return { + background: gradients.gradientDataVizBlueDark, + stroke: blue500, + dotFill: alpha("#b9abef", 0.76), + dotStroke: midnightGradientStops.dataVizBlue, + }; + case "pinkPurple": + default: + return { + background: gradients.gradientDataVizPinkPurple, + stroke: purple600, + dotFill: purpleAlpha40, + dotStroke: purple600, + }; + } +}; + 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; }; From c78a732c885398cddea7681d7804e78c555d9f1a Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Fri, 7 Aug 2026 18:43:36 +0530 Subject: [PATCH 14/23] fix: Midnight Theme | Tokens Fixes Signed-off-by: Yashwant Rautela --- .../components/card/__tests__/card.test.tsx | 1 + .../components/card/components/elements.tsx | 23 +++++--- .../components/card/stories/card.stories.tsx | 35 +++++++++--- .../src/components/card/styles/index.ts | 4 +- .../icon/__tests__/custom-icons.test.ts | 56 +++++++++++++++---- .../navigation/__tests__/navigation.test.tsx | 44 +++++++++++++++ .../navigation/components/navigation.tsx | 4 +- .../src/custom-icons/org-switcher.tsx | 49 +++++++++++----- 8 files changed, 171 insertions(+), 45 deletions(-) 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..7d094b5b 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"; 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 dbfc4d55..8cb7fe39 100644 --- a/packages/open-ui-kit/src/components/card/components/elements.tsx +++ b/packages/open-ui-kit/src/components/card/components/elements.tsx @@ -39,22 +39,27 @@ export const StyledCard = styled(MuiCard, { export const StyledCardActionArea = styled(MuiCardActionArea)(({ theme }) => ({ borderRadius: "8px", - "&:hover .MuiCard-root, &:focus-visible .MuiCard-root": { - ...cardInteractiveStyles(theme), - "& .MuiCardActionArea-focusHighlight": { - opacity: 0, - }, - }, + "&:hover .MuiCard-root, &:focus-visible .MuiCard-root": + cardInteractiveStyles(theme), "&:active .MuiCard-root": cardActiveStyles(theme), "&.Mui-disabled .MuiCard-root": cardDisabledStyles(theme), + // MUI paints a translucent overlay (`focusHighlight`) across the whole action + // area on hover and focus. It is a sibling of the card, not a descendant, so + // it cannot be reached from a `.MuiCard-root` rule, and MUI scopes its own + // rules as `&:hover .focusHighlight` / `&.Mui-focusVisible .focusHighlight`. + // A bare `& .focusHighlight` is one specificity step lower and loses to them, + // so each selector has to be matched directly to keep the overlay hidden. "& .MuiCardActionArea-focusHighlight": { opacity: 0, }, + "&:hover .MuiCardActionArea-focusHighlight": { + opacity: 0, + }, + "&.Mui-focusVisible .MuiCardActionArea-focusHighlight": { + opacity: 0, + }, "&:focus-visible": { outline: "none", - "& .MuiCardActionArea-focusHighlight": { - opacity: 0, - }, }, })) as ComponentType; 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 f618ec3d..fecd52fe 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 @@ -20,12 +20,16 @@ import { CardContent, CardDescription, CardHeader, + CardSubheader, Link, LinkType, Skeleton, Stack, + Tag, + TagStatus, Typography, } from "@/components"; +import { GeneralSize } from "@/common"; import { cardActiveStyles, cardSkeletonStyles } from "../styles"; import { DocsHeader } from "storybook/components/docs-header.stories"; @@ -83,7 +87,12 @@ const CardStats = () => ( color: theme.palette.vars.baseTextMedium, })} > - + ({ + color: theme.palette.vars.successIconDefault, + fontSize: 16, + })} + /> 10k @@ -131,23 +140,29 @@ const ImportCardContent = () => ( ({ alignItems: "center", + backgroundColor: theme.palette.vars.baseBackgroundMedium, border: `1px solid ${theme.palette.vars.controlBorderActive}`, borderRadius: "4px", + color: theme.palette.vars.controlIconDefault, display: "flex", - height: 32, + height: 40, justifyContent: "center", - width: 32, + width: 40, })} > - + Import Existing - + ({ + backgroundColor: theme.palette.vars.accentGWeak, + })} + type="info" + /> - - Upload A2A card, MCP config, or OASF file - + Upload A2A card, MCP config, or OASF file @@ -260,7 +275,9 @@ export const Metrics: Story = { Headline not clickable - + + 0% + ({ }); export const cardDisabledStyles = (theme: Theme): CSSObject => ({ - backgroundColor: theme.palette.vars.controlBackgroundWeak, - border: `1px solid ${theme.palette.vars.controlBorderWeak}`, + backgroundColor: theme.palette.vars.controlBackgroundDisabled, + border: `1px solid ${theme.palette.vars.controlBorderDisabled}`, boxShadow: theme.shadows[2], color: theme.palette.vars.baseTextDisabled, pointerEvents: "none" as const, 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 fc06da1a..3a91b097 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 @@ -6,6 +6,10 @@ import fs from "node:fs"; import path from "node:path"; +// The suite only reads sources, but it still needs the jest globals to be +// typed, and `typeRoots` in tsconfig.json does not reach the hoisted +// `@types/jest`. Every other suite picks them up through this import. +import "@testing-library/jest-dom"; const customIconsDir = path.resolve(__dirname, "../../../custom-icons"); @@ -25,8 +29,48 @@ 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; + +const QUOTED_STRING = /"[^"]*"|'[^']*'/g; + +function unquote(value: string) { + return value.slice(1, -1).trim().toLowerCase(); +} + +function isInheritedPaint(value: string) { + return value === "none" || value === "currentcolor"; +} + +// `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)); + } + + const expression = value.slice(1, -1); + + return ( + !LITERAL_COLOR.test(expression) && + (expression.match(QUOTED_STRING) ?? []).every((literal) => + isInheritedPaint(unquote(literal)), + ) + ); +} + describe("custom icons", () => { - it("uses currentColor for SVG fills and strokes", () => { + it("keeps SVG fills and strokes off hardcoded colors", () => { const badPaintAttributes = iconFiles.flatMap((filePath) => { const source = getSource(filePath); const matches = [ @@ -36,15 +80,7 @@ describe("custom icons", () => { ]; return matches - .filter((match) => { - const value = match[1].toLowerCase(); - return ( - value !== '"none"' && - value !== "'none'" && - value !== '"currentcolor"' && - value !== "'currentcolor'" - ); - }) + .filter((match) => !isAcceptablePaint(match[1])) .map( (match) => `${path.relative(customIconsDir, filePath)}: ${match[0]}`, ); diff --git a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx index f45baf28..c9e3fb24 100644 --- a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx +++ b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx @@ -229,4 +229,48 @@ describe("Navigation", () => { }); }); }); + + /* + * Figma `.Switcher` (179634:5059) resolves the bracket and the arrow to + * Brand/Icon/Secondary/Weak and the diamond to Brand/Icon/Secondary/Default. + * Two tones cannot ride on `currentColor`, since the switcher button passes + * down only one inherited colour — which is why the nav renders the complete + * `OrgSwitcher` rather than the single-tone `OrgSwitcherDefault`. + */ + describe("organization switcher mark", () => { + const switcherPaths = (container: HTMLElement) => + Array.from( + container.querySelectorAll( + 'button[aria-label="[Organization]"] svg path, button[aria-label="[Organization]"] path', + ), + ).map((path) => path.getAttribute("fill")); + + it.each([ + ["light", false, lightTheme], + ["dark", true, darkTheme], + ])("paints all three %s layers on the brand ramp", (_mode, dark, theme) => { + const { container } = wrap( + , + dark, + ); + const fills = switcherPaths(container); + + expect(fills).toEqual([ + theme.palette.vars.brandIconSecondaryWeak, + theme.palette.vars.brandIconSecondaryWeak, + theme.palette.vars.brandIconSecondaryDefault, + ]); + // The single-tone mark would have collapsed the layers onto one + // inherited colour. + expect(fills).not.toContain("currentColor"); + }); + + it("keeps the two tones distinguishable in both themes", () => { + for (const theme of [lightTheme, darkTheme]) { + expect(theme.palette.vars.brandIconSecondaryWeak).not.toBe( + theme.palette.vars.brandIconSecondaryDefault, + ); + } + }); + }); }); diff --git a/packages/open-ui-kit/src/components/navigation/components/navigation.tsx b/packages/open-ui-kit/src/components/navigation/components/navigation.tsx index 14500773..1c25c990 100644 --- a/packages/open-ui-kit/src/components/navigation/components/navigation.tsx +++ b/packages/open-ui-kit/src/components/navigation/components/navigation.tsx @@ -9,7 +9,7 @@ import { DashboardSelected, DashboardUnSelected, KeyboardArrowRight, - OrgSwitcherDefault, + OrgSwitcher, } from "@/custom-icons"; import { useEffect, useState, type ReactNode } from "react"; import type { @@ -153,7 +153,7 @@ export const Navigation = ({ selected={isOrganizationDrawerOpen} type="button" > - + {!isCompact ? ( <> diff --git a/packages/open-ui-kit/src/custom-icons/org-switcher.tsx b/packages/open-ui-kit/src/custom-icons/org-switcher.tsx index b29d8248..0ea199cc 100644 --- a/packages/open-ui-kit/src/custom-icons/org-switcher.tsx +++ b/packages/open-ui-kit/src/custom-icons/org-switcher.tsx @@ -4,15 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SvgIcon, SvgIconProps } from "@mui/material"; +import { SvgIcon, SvgIconProps, useTheme } from "@mui/material"; + +// The mark is drawn as three stacked layers, named after the +// Brand/Icon/Secondary ramp in Figma: the "Weak" bracket along the bottom, the +// "Medium" arrow on the right and the "Default" diamond on the left. The +// exports below build the partial marks by stacking those layers, so the +// geometry is declared once. +const BRACKET_PATH = + "M 10,10.583 C 10.55,10.583 11,11.033 11,11.583 L 11,13.583 L 19,13.583 C 19.55,13.583 20,14.033 20,14.583 L 20,17.583 C 20,18.143 19.55,18.583 19,18.583 C 18.45,18.583 18,18.143 18,17.583 L 18,15.583 L 11,15.583 L 11,17.583 C 11,18.143 10.55,18.583 10,18.583 C 9.45,18.583 9,18.143 9,17.583 L 9,15.583 L 2,15.583 L 2,17.583 C 2,18.143 1.55,18.583 1,18.583 C 0.45,18.583 0,18.143 0,17.583 L 0,14.583 C 0,14.033 0.45,13.583 1,13.583 L 9,13.583 L 9,11.583 C 9,11.033 9.45,10.583 10,10.583"; + +const ARROW_PATH = + "M 11.06,7.643 L 12.29,8.883 C 12.68,9.273 13.32,9.273 13.71,8.883 L 17.29,5.293 C 17.68,4.903 17.68,4.273 17.29,3.883 L 13.71,0.293 C 13.32,-0.097 12.68,-0.097 12.29,0.293 L 11.06,1.523 L 12.35,2.823 C 12.39,2.863 12.43,2.903 12.47,2.943 L 13,2.413 L 15.17,4.583 L 13,6.753 L 12.47,6.233 C 12.43,6.273 12.39,6.313 12.35,6.353 L 11.06,7.643"; + +const DIAMOND_PATH = + "M 6.29,0.293 C 6.68,-0.097 7.32,-0.097 7.71,0.293 L 11.29,3.883 C 11.68,4.273 11.68,4.903 11.29,5.293 L 7.71,8.883 C 7.32,9.273 6.68,9.273 6.29,8.883 L 2.71,5.293 C 2.32,4.903 2.32,4.273 2.71,3.883 L 6.29,0.293"; export function OrgSwitcherWeak(props: SvgIconProps) { return ( - + ); } @@ -20,10 +31,7 @@ export function OrgSwitcherWeak(props: SvgIconProps) { export function OrgSwitcherMedium(props: SvgIconProps) { return ( - + ); } @@ -31,10 +39,25 @@ export function OrgSwitcherMedium(props: SvgIconProps) { export function OrgSwitcherDefault(props: SvgIconProps) { return ( - + + + ); +} + +// The complete mark, matching the `.Switcher` frame (node 179634:5059) in the +// Outshift Spark Component Library. All three layers are painted, and the frame +// resolves the bracket and the arrow to Brand/Icon/Secondary/Weak while the +// diamond takes Brand/Icon/Secondary/Default. That two-tone ramp is why the +// layers cannot share `currentColor` — the caller only carries one inherited +// color. +export function OrgSwitcher(props: SvgIconProps) { + const { vars } = useTheme().palette; + + return ( + + + + ); } From 670859ca398c61fa0e4bdcce6723879a8557f428 Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 16:57:16 +0530 Subject: [PATCH 15/23] feat: Input Component Gradient Signed-off-by: Jatin Verma --- .../__tests__/input-field.test.tsx | 49 ++++++++++++++++- .../input-field/components/elements.tsx | 11 ++-- .../input-field/components/input-field.tsx | 3 +- .../stories/input-field.stories.tsx | 12 +++++ .../components/input-field/styles/index.ts | 53 +++++++++++++++++++ .../src/components/input-field/types/index.ts | 5 ++ .../theme/midnight/midnight-gradient-vars.ts | 6 +++ .../src/theme/style/gradient-vars-base.ts | 1 + .../open-ui-kit/src/types/gradient-vars.ts | 2 + 9 files changed, 136 insertions(+), 6 deletions(-) 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/theme/midnight/midnight-gradient-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts index f3be643f..08159233 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -97,6 +97,12 @@ export const midnightGradientVars: GradientVarsType = { // Measured: first stop renders #0a60ff, not the #0a66ff shown on the label. gradientGlobalBorderFade: `linear-gradient(90deg, ${stops.panelExecBorderBlue} 0%, ${stops.buttonPrimaryGlowCyan} 50%, ${stops.globalBorderSlate} 70%, ${alpha(stops.globalBorderSlateWeak, 0.7)} 100%)`, + // `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%)`, 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 index 3ef967db..bda05a4f 100644 --- a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -95,6 +95,7 @@ export const baseGradientVars: GradientVarsType = { gradientCardGlassBg: gradientsSecondaryHover, gradientCardGlassBgSubtle: gradientsSecondaryHover, gradientGlobalBorderFade: gradientsSecondaryDefault, + gradientInputBorderBlue: gradientsSecondaryDefault, gradientGlobalBorderRainbow: gradientsIllustrationsRainbow, gradientGlobalButtonPrimaryBorderGlow: gradientButtonPrimaryBorderGlow, gradientDashboardGraphNodeBorder: gradientsSecondaryDefault, diff --git a/packages/open-ui-kit/src/types/gradient-vars.ts b/packages/open-ui-kit/src/types/gradient-vars.ts index 91072005..0fde53ba 100644 --- a/packages/open-ui-kit/src/types/gradient-vars.ts +++ b/packages/open-ui-kit/src/types/gradient-vars.ts @@ -56,6 +56,8 @@ export interface GradientVarsType { /** Renamed: same stops as `gradientCardGlassBg` at 70% layer opacity. */ gradientCardGlassBgSubtle: 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; From 12c7b894fd8e7f62e351cbaaf7533e0d0f87a49f Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 16:59:25 +0530 Subject: [PATCH 16/23] feat: Toast Component Gradient support Signed-off-by: Jatin Verma --- docs/data/material/components/toast/toast.md | 15 ++++ .../components/toast/__tests__/toast.test.tsx | 69 +++++++++++++++++++ .../components/toast/components/elements.tsx | 57 +++++++++------ .../src/components/toast/components/toast.tsx | 2 + .../toast/stories/toast.stories.tsx | 40 +++++++++++ .../src/components/toast/styles/index.ts | 49 +++++++++++++ .../src/components/toast/types/index.ts | 6 ++ .../theme/midnight/midnight-gradient-vars.ts | 6 +- .../style/__tests__/gradient-vars.test.ts | 7 +- .../src/theme/style/color-palette.ts | 8 +++ 10 files changed, 232 insertions(+), 27 deletions(-) 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/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/theme/midnight/midnight-gradient-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts index 08159233..0d8df30d 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -95,8 +95,10 @@ export const midnightGradientVars: GradientVarsType = { // match until design confirms whether the label or the fill is authoritative. gradientCardGlassBgSubtle: `linear-gradient(90deg, ${alpha(stops.glassWhite, 0.3)} 0%, ${alpha(stops.glassWhiteWeak, 0.3)} 72%, ${alpha(stops.glassGray, 0)} 100%)`, - // Measured: first stop renders #0a60ff, not the #0a66ff shown on the label. - gradientGlobalBorderFade: `linear-gradient(90deg, ${stops.panelExecBorderBlue} 0%, ${stops.buttonPrimaryGlowCyan} 50%, ${stops.globalBorderSlate} 70%, ${alpha(stops.globalBorderSlateWeak, 0.7)} 100%)`, + // 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 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 index eaccee36..b2e66b94 100644 --- 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 @@ -164,10 +164,11 @@ describe("midnight gradient values", () => { expect(g.gradientGlobalBorderRainbow).toContain("#ff007f 83%"); }); - // The label says #0a66ff; the rendered stroke measures #0a60ff. - it("uses the measured first stop for the border fade", () => { + // 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, #0a60ff 0%, #00bceb 50%, #3d5066 70%, rgba(77, 99, 128, 0.7) 100%)", + "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 8f58fcca..e6c970ef 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,14 @@ export const gradientStopColors = { secondaryBlueHoverWeak: "#b3cbe7", } as const; +// 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)"; From a52b41cc8ac936ef068c88c43c3109542d896dff Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 17:00:38 +0530 Subject: [PATCH 17/23] feat: Gradient for Cards Component Signed-off-by: Jatin Verma --- .../material/components/card/AlertCard.js | 42 ++ .../material/components/card/AlertCard.tsx | 42 ++ .../material/components/card/CardWithImage.js | 38 ++ .../components/card/CardWithImage.tsx | 38 ++ .../material/components/card/ConnectorCard.js | 32 ++ .../components/card/ConnectorCard.tsx | 32 ++ .../material/components/card/DisabledCard.js | 2 +- .../material/components/card/DisabledCard.tsx | 2 +- .../material/components/card/GlassCard.js | 29 ++ .../material/components/card/GlassCard.tsx | 29 ++ docs/data/material/components/card/card.md | 63 ++- .../static/images/cards/welcome-card.jpg | Bin 0 -> 86322 bytes .../.storybook/assets/welcome-card.jpg | Bin 0 -> 86322 bytes .../components/card/__tests__/card.test.tsx | 428 ++++++++++++++++++ .../card/components/card-alert-header.tsx | 45 ++ .../src/components/card/components/card.tsx | 15 +- .../components/card/components/elements.tsx | 76 +++- .../open-ui-kit/src/components/card/index.ts | 3 + .../components/card/stories/card.stories.tsx | 136 ++++++ .../src/components/card/styles/index.ts | 357 +++++++++++++++ .../src/components/card/types/index.ts | 49 ++ .../theme/midnight/midnight-gradient-vars.ts | 86 +++- .../style/__tests__/gradient-vars.test.ts | 59 ++- .../src/theme/style/color-palette.ts | 55 +++ .../src/theme/style/gradient-vars-base.ts | 23 +- .../open-ui-kit/src/types/gradient-vars.ts | 8 + 26 files changed, 1652 insertions(+), 37 deletions(-) create mode 100644 docs/data/material/components/card/AlertCard.js create mode 100644 docs/data/material/components/card/AlertCard.tsx create mode 100644 docs/data/material/components/card/CardWithImage.js create mode 100644 docs/data/material/components/card/CardWithImage.tsx create mode 100644 docs/data/material/components/card/ConnectorCard.js create mode 100644 docs/data/material/components/card/ConnectorCard.tsx create mode 100644 docs/data/material/components/card/GlassCard.js create mode 100644 docs/data/material/components/card/GlassCard.tsx create mode 100644 docs/public/static/images/cards/welcome-card.jpg create mode 100644 packages/open-ui-kit/.storybook/assets/welcome-card.jpg create mode 100644 packages/open-ui-kit/src/components/card/components/card-alert-header.tsx 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/public/static/images/cards/welcome-card.jpg b/docs/public/static/images/cards/welcome-card.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0e81de32ecee82e44c3c68c6ecc2b6e0c6323dee GIT binary patch literal 86322 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%0DqyqNU8rC)JL5n=K5Lu;HDn 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0e81de32ecee82e44c3c68c6ecc2b6e0c6323dee GIT binary patch literal 86322 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%0DqyqNU8rC)JL5n=K5Lu;HDn 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 literal 0 HcmV?d00001 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..0b1c7e79 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 @@ -17,14 +17,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 +315,414 @@ 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", + ); + }); + }); }); 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..e4d8666f 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,80 @@ 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) : {}), + // These treatments all paint a translucent or gradient background; a hover + // colour would show through their semi-transparent stops. + ...(image || glass || alert || connector + ? {} + : { + "&:hover": { + backgroundColor: theme.palette.vars.baseBackgroundWeak, + }, + }), '&[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 +127,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 +150,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 +160,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..a9e345dc 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,350 @@ 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, + }, +}); + 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/theme/midnight/midnight-gradient-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts index 0d8df30d..7bc124b6 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -10,6 +10,7 @@ import { baseGradientVars, gradientButtonPrimaryBorderGlow, gradientButtonPrimaryFill, + gradientCardInsightBorder, gradientTextWhiteBlue, } from "@/theme/style/gradient-vars-base"; import { gradientsRedPressed } from "@/theme/style/gradients"; @@ -73,8 +74,28 @@ export const midnightGradientVars: GradientVarsType = { // 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%)`, - // TODO(verify): 166.51deg read from a 2:1 swatch. - gradientGraphConnectorFill: `linear-gradient(166.51deg, ${alpha(stops.graphConnectorBlue, 0.035)} 0%, ${alpha(stops.graphConnectorBlue, 0.016)} 100%)`, + /* + * 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%)`, @@ -87,13 +108,51 @@ export const midnightGradientVars: GradientVarsType = { // by pixel, then un-compositing against the card background. Direction and // stop offsets below are measured, not guessed. All nine are horizontal. - // Measured: alpha holds at 0.30 to ~72%, then falls linearly to 0. - gradientCardGlassBg: `linear-gradient(90deg, ${alpha(stops.glassWhite, 0.3)} 0%, ${alpha(stops.glassWhiteWeak, 0.3)} 72%, ${alpha(stops.glassGray, 0)} 100%)`, - // Figma labels this swatch "LINEAR - 70%", but it renders pixel-identical to - // `gradientCardGlassBg` — the 70% is not applied to the fill. Kept as a - // separate token because the design documents it as one; values intentionally - // match until design confirms whether the label or the fill is authoritative. - gradientCardGlassBgSubtle: `linear-gradient(90deg, ${alpha(stops.glassWhite, 0.3)} 0%, ${alpha(stops.glassWhiteWeak, 0.3)} 72%, ${alpha(stops.glassGray, 0)} 100%)`, + /* + * 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 @@ -111,7 +170,10 @@ export const midnightGradientVars: GradientVarsType = { // Shared with every theme — see `gradient-vars-base.ts`. gradientGlobalButtonPrimaryBorderGlow: gradientButtonPrimaryBorderGlow, gradientDashboardGraphNodeBorder: `linear-gradient(90deg, ${alpha(stops.globalBorderSlateWeak, 0.7)} 0%, ${stops.globalBorderSlate} 100%)`, - // Measured: alpha ramps UP left to right (6% -> 16%), opposite to the label order. + // 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. @@ -131,6 +193,8 @@ export const midnightGradientVars: GradientVarsType = { gradientGlowRed: `radial-gradient(circle 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%)`, - gradientPanelExecBorder: `radial-gradient(ellipse at 50% 50%, ${stops.panelExecBorderBlue} 0%, ${stops.dataVizCyan} 33%, ${stops.globalBorderSlate} 66%, ${alpha(stops.globalBorderSlateWeak, 0.7)} 100%)`, + // 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/style/__tests__/gradient-vars.test.ts b/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts index b2e66b94..3b5faf19 100644 --- 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 @@ -26,9 +26,10 @@ const THEMES: ReadonlyArray<[string, Theme]> = [ 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 }, - ); + expect({ + name, + keys: Object.keys(theme.palette.gradients).sort(), + }).toEqual({ name, keys: TOKEN_KEYS }); } }); @@ -38,9 +39,8 @@ describe("gradient vars contract", () => { 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 - ]; + const value = + theme.palette.gradients[key as keyof typeof baseGradientVars]; expect({ name, key, empty: !value }).toEqual({ name, key, @@ -127,8 +127,6 @@ describe("midnight gradient values", () => { it("uses a horizontal ramp for every stroke", () => { const strokes = [ - g.gradientCardGlassBg, - g.gradientCardGlassBgSubtle, g.gradientGlobalBorderFade, g.gradientGlobalBorderRainbow, g.gradientGlobalButtonPrimaryBorderGlow, @@ -142,24 +140,59 @@ describe("midnight gradient values", () => { } }); - it("holds the glass ramp flat until 72%", () => { + // 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( - "linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0%, rgba(241, 241, 241, 0.3) 72%, rgba(153, 153, 153, 0) 100%)", + "radial-gradient(100% 100% at 100% 0%, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.05) 100%)", ); }); - // Renders pixel-identical to the non-subtle variant despite its "70%" label. - it("keeps both glass variants identical", () => { - expect(g.gradientCardGlassBgSubtle).toBe(g.gradientCardGlassBg); + 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%"); }); 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 e6c970ef..9e3fdb97 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,52 @@ 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). @@ -736,6 +782,15 @@ export const midnightGradientStops = { 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 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 index bda05a4f..74fb28ef 100644 --- a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -33,7 +33,22 @@ export const gradientButtonPrimaryFill = `linear-gradient(90deg, ${stops.buttonP export const gradientButtonPrimaryBorderGlow = `linear-gradient(90deg, ${stops.buttonPrimaryGlowBlue} 0%, ${stops.buttonPrimaryGlowCyan} 50%, ${stops.buttonPrimaryGlowTeal} 100%)`; /* - * Text gradient — shared by every theme, like the button gradients above. + * 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`. @@ -89,11 +104,15 @@ export const baseGradientVars: GradientVarsType = { 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, @@ -109,6 +128,6 @@ export const baseGradientVars: GradientVarsType = { gradientGlowRed: gradientsRedPressed, gradientGlowPinkShadow: gradientsIllustrationsPink, gradientBackgroundGlowBlue: gradientsIllustrationsBlue, - gradientPanelExecBorder: gradientsSecondaryDefault, + 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 index 0fde53ba..b97a7453 100644 --- a/packages/open-ui-kit/src/types/gradient-vars.ts +++ b/packages/open-ui-kit/src/types/gradient-vars.ts @@ -47,6 +47,8 @@ export interface GradientVarsType { gradientAlertLineRed: string; gradientDashboardGraphNodeFill: string; gradientGraphConnectorFill: string; + /** `Graph-Connector-Glow`: radial lift from the bottom edge of the card. */ + gradientGraphConnectorGlow: string; gradientIconButtonBlue: string; // --- Strokes ------------------------------------------------------------- @@ -55,6 +57,12 @@ export interface GradientVarsType { 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; From 3b13c9b8678a59aa8802e6e8814632b4939d30fd Mon Sep 17 00:00:00 2001 From: Yashwant Rautela Date: Tue, 11 Aug 2026 11:50:21 +0530 Subject: [PATCH 18/23] fix: Navigation Component UI Fix Signed-off-by: Yashwant Rautela --- .../navigation/__tests__/navigation.test.tsx | 19 +++++++++++++++++++ .../src/components/navigation/styles/index.ts | 9 ++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx index c9e3fb24..e2c1ee05 100644 --- a/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx +++ b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx @@ -12,6 +12,7 @@ import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; import { Navigation, NavigationDrawer, NavigationSubNavigation } from ".."; import { + getNavigationDrawerItemStyles, getNavigationDrawerStyles, getNavigationCollapseButtonStyles, getNavigationItemStyles, @@ -188,6 +189,24 @@ describe("Navigation", () => { backgroundColor: "transparent", color: lightTheme.palette.vars.brandTextSecondary, }); + // Hovering a sub-menu option adds the fill but keeps the label on + // Brand/Text/Secondary; only selected holds Brand/Text/Primary. + expect(getNavigationDrawerItemStyles(lightTheme, false)).toMatchObject({ + backgroundColor: "transparent", + color: lightTheme.palette.vars.brandTextSecondary, + "&:hover": { + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryMedium, + color: lightTheme.palette.vars.brandTextSecondary, + }, + }); + expect(getNavigationDrawerItemStyles(lightTheme, true)).toMatchObject({ + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryMedium, + color: lightTheme.palette.vars.brandTextPrimary, + "&:hover": { + backgroundColor: lightTheme.palette.vars.brandBackgroundPrimaryMedium, + color: lightTheme.palette.vars.brandTextPrimary, + }, + }); expect( getNavigationCollapseButtonStyles(lightTheme, false), ).toMatchObject({ diff --git a/packages/open-ui-kit/src/components/navigation/styles/index.ts b/packages/open-ui-kit/src/components/navigation/styles/index.ts index 3698281c..2bd930b6 100644 --- a/packages/open-ui-kit/src/components/navigation/styles/index.ts +++ b/packages/open-ui-kit/src/components/navigation/styles/index.ts @@ -320,8 +320,9 @@ export const getNavigationDrawerItemStyles = ( borderRadius: "6px", // Tokens follow the Figma "Navigation" drawer item states (frame node // 179634:5030, `.drawer-item`). Default rests on Brand/Text/Secondary with no - // fill; hover and selected are the same treatment — - // Brand/Background/Primary/Medium behind Brand/Text/Primary. + // fill; hover adds the Brand/Background/Primary/Medium fill but keeps the + // label on Brand/Text/Secondary. Only selected paints Brand/Text/Primary, and + // it holds that ramp while hovered. backgroundColor: selected ? theme.palette.vars.brandBackgroundPrimaryMedium : "transparent", @@ -333,7 +334,9 @@ export const getNavigationDrawerItemStyles = ( textAlign: "left", "&:hover": { backgroundColor: theme.palette.vars.brandBackgroundPrimaryMedium, - color: theme.palette.vars.brandTextPrimary, + color: selected + ? theme.palette.vars.brandTextPrimary + : theme.palette.vars.brandTextSecondary, }, }); From f0910f39d7cdd72ae24b401bb3b97b87df4fb6b5 Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 17:01:43 +0530 Subject: [PATCH 19/23] Typography | Gradient Variant Signed-off-by: Jatin Verma --- .../typography/__tests__/typography.test.tsx | 83 +++++++++++++++++++ .../typography/components/elements.tsx | 20 +++++ .../style/__tests__/gradient-vars.test.ts | 2 +- .../src/theme/style/color-palette.ts | 4 + .../src/theme/style/gradient-vars-base.ts | 2 +- 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 packages/open-ui-kit/src/components/typography/__tests__/typography.test.tsx 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..f3b5bb37 --- /dev/null +++ b/packages/open-ui-kit/src/components/typography/__tests__/typography.test.tsx @@ -0,0 +1,83 @@ +/* + * 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", + }); + }); + + // Figma anchors the ramp to the text layer's own box — 858 wide over 440 + // of glyphs, so the text covers 51.28% of the ramp and ends on #9dbcf7. + // Fitting the box to the glyphs makes that independent of the container; + // 858 / 440 = 1.95 puts the last glyph back on the same stop at any size. + it("fits the box to the text and stretches the ramp past it", () => { + renderTypography(Welcome Amy!); + + expect(screen.getByText("Welcome Amy!")).toHaveStyle({ + width: "fit-content", + backgroundSize: "195% 100%", + backgroundRepeat: "no-repeat", + }); + }); + + it("leaves ungradiented text alone", () => { + renderTypography(Plain text); + + const el = screen.getByText("Plain text"); + expect(el).not.toHaveStyle({ width: "fit-content" }); + 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 index de357923..8919fd37 100644 --- a/packages/open-ui-kit/src/components/typography/components/elements.tsx +++ b/packages/open-ui-kit/src/components/typography/components/elements.tsx @@ -17,6 +17,23 @@ import { Typography as MuiTypography, styled } from "@mui/material"; * 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`. + * + * Two properties set the ramp's geometry, and both are needed. + * + * `width: fit-content` shrink-wraps the background box to the glyphs. Without + * it the box is the full block width, so `background-clip: text` reveals only + * the leading slice of the ramp — and which slice depends on the container and + * the string, so the same treatment rendered white-ish at 11% for a short + * subtitle and mid-ramp at 39% for an h1. + * + * `background-size: 195%` then stretches the ramp past the glyphs so it ends + * where Figma's does. Figma anchors the gradient to the text layer's own box: + * for `Welcome Amy!` (274455:49077) the SVG export puts the handles at + * x1 -1.617 / x2 856.383 — an 858 span over 440 of glyphs, so the text covers + * 51.3% of the ramp and stops at #9dbcf7 rather than the token's #3f7def. + * 858 / 440 = 1.95, and 100 / 195 = 0.513 puts the last glyph back on that + * same stop at any size. Fitting the box alone ran the full token and read far + * too blue. */ export const StyledTypography = styled(MuiTypography, { shouldForwardProp: (prop) => prop !== "gradient", @@ -26,6 +43,9 @@ export const StyledTypography = styled(MuiTypography, { WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", + width: "fit-content", + backgroundSize: "195% 100%", + backgroundRepeat: "no-repeat", // Render the gradient per line when the text wraps. WebkitBoxDecorationBreak: "clone", boxDecorationBreak: "clone", 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 index 3b5faf19..d6b59e36 100644 --- 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 @@ -79,7 +79,7 @@ describe("midnight gradient values", () => { "linear-gradient(90deg, #0745b8 0%, #2e6ee5 100%)", ); expect(g.gradientTextWhiteBlue).toBe( - "linear-gradient(90deg, #ffffff 0%, #3f7def 100%)", + "linear-gradient(90deg, #ffffff 0%, #9dbcf7 51.28%, #3f7def 100%)", ); expect(g.gradientGaugeArcTeal).toBe( "linear-gradient(90deg, #29fcc4 0%, #00af2f 100%)", 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 9e3fdb97..ee645ebc 100644 --- a/packages/open-ui-kit/src/theme/style/color-palette.ts +++ b/packages/open-ui-kit/src/theme/style/color-palette.ts @@ -760,6 +760,10 @@ export const midnightGradientStops = { // 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 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 index 74fb28ef..cb138752 100644 --- a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -55,7 +55,7 @@ export const gradientCardInsightBorder = `linear-gradient(135deg, ${stops.panelE * 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.textWhiteBlueEnd} 100%)`; +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. From 93db8504db7957b6cba594250855e07fd408cadf Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 17:02:23 +0530 Subject: [PATCH 20/23] Fixes for Activity Timeline | Gradient Variant Signed-off-by: Jatin Verma --- .../__tests__/activity-timeline.test.tsx | 122 ++++++++++++++++++ .../components/activity-timeline-dot.tsx | 9 +- .../components/activity-timeline.tsx | 28 ++-- .../activity-timeline/components/elements.tsx | 61 +++++++-- .../activity-timeline/types/index.ts | 10 +- .../activity-timeline/utils/utils.ts | 4 +- .../theme/midnight/midnight-gradient-vars.ts | 22 ++-- .../style/__tests__/gradient-vars.test.ts | 15 ++- 8 files changed, 225 insertions(+), 46 deletions(-) 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 eee089a0..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 @@ -10,7 +10,7 @@ import { Box, CircularProgress, useTheme, type BoxProps } from "@mui/material"; import { ActivityTimelineStepStatus } from "../types"; import { getActivityTimelineDotStyle } from "../styles"; import { getStepDotColor, getStepGlow } from "../utils/utils"; -import { StyledTimelineDotRoot } from "./elements"; +import { GLOW_DOT_SIZE, StyledTimelineDotRoot } from "./elements"; export interface ActivityTimelineDotProps extends BoxProps { /** Uses percent-driven progress rendering instead of status icons. */ @@ -39,13 +39,14 @@ export const ActivityTimelineDot = ({ if (glow) { // Gradient statuses fill the dot with the radial glow; the rest are solid. - const dotFill = getStepGlow(status, theme) ?? getStepDotColor(status, theme); + const dotFill = + getStepGlow(status, theme) ?? getStepDotColor(status, theme); return ( { - if (steps.length <= 1) { + 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 Math.max(0.3, 1 - (stepIdx / (steps.length - 1)) * 0.7); + return OLDEST_STEP_OPACITY[fromOldest]; }, [steps.length], ); @@ -57,18 +63,17 @@ export const ActivityTimeline = ({ 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 connector stays uniform. + // Fade each slot, not the item, so the rail keeps its own ramp. - + - {index < steps.length - 1 && ( - - )} {step.time && ( @@ -106,7 +106,7 @@ export const ActivityTimeline = ({ ))} - + ); } diff --git a/packages/open-ui-kit/src/components/activity-timeline/components/elements.tsx b/packages/open-ui-kit/src/components/activity-timeline/components/elements.tsx index 8c5ad9ca..8f6deb87 100644 --- a/packages/open-ui-kit/src/components/activity-timeline/components/elements.tsx +++ b/packages/open-ui-kit/src/components/activity-timeline/components/elements.tsx @@ -32,9 +32,12 @@ export const StyledTimelineItem = styled(TimelineItem)(() => ({ }, })) 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, { @@ -57,19 +60,54 @@ 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; -// Overlaps the dots so the segments read as one continuous line. -export const StyledTimelineGlowConnector = styled(TimelineConnector, { - shouldForwardProp: (prop) => prop !== "lineColor", -})<{ lineColor?: string }>(({ lineColor }) => ({ - backgroundColor: lineColor, - width: "1px", - margin: "-4px 0", -})) 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)( @@ -85,8 +123,9 @@ export const StyledTimelineOppositeContent = styled(TimelineOppositeContent)( ) 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 48px", + flex: `0 0 ${TIME_COLUMN_WIDTH}px`, minWidth: 0, margin: 0, padding: "0 0 40px 12px", 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 cc8ec4ac..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,7 +28,7 @@ export interface ActivityTimelineStep { content?: ReactNode; /** Expands accordion content by default when content is provided. */ defaultExpanded?: boolean; - /** Optional time label shown beside the step in the gradient variant. */ + /** Time label shown to the right of the line. Gradient variant only. */ time?: string; } @@ -40,8 +40,12 @@ export interface ActivityTimelineProps extends Omit< automaticProgress?: boolean; /** Controls the timeline title typography and vertical spacing. */ size?: "large" | "medium"; - /** Renders glowing status dots, a per-step time, and a fade-out when set to gradient. */ + /** + * `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. */ + /** 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 e97d8d0c..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 @@ -40,7 +40,9 @@ export const getStepDotColor = ( case ActivityTimelineStepStatus.Neutral: return theme.palette.vars?.controlIconMedium; default: - return theme.palette.vars?.controlIconDefault; + // 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; } }; 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 index 7bc124b6..5035d892 100644 --- a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -178,19 +178,23 @@ export const midnightGradientVars: GradientVarsType = { 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. - gradientCardHighlightRadial: `linear-gradient(90deg, ${stops.glassWhite} 0%, ${alpha(stops.glassGray, 0)} 100%)`, + // 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 -------------------------------------------------------- - // TODO(verify): geometry approximated from Figma's SVG `gradientTransform` - // matrix. Figma's intermediate stops are linear interpolations between the - // designed stops, so only the designed stops are kept. + // 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. Orange was previously an approximated - // ellipse and is realigned here. - gradientGlowOrange: `radial-gradient(circle at 0% 0%, ${alpha(stops.glowOrangeStart, 0.8)} 0%, ${alpha(stops.glowOrangeEnd, 0.8)} 100%)`, - gradientGlowGreen: `radial-gradient(circle at 0% 0%, ${alpha(stops.glowGreenStart, 0.7)} 0%, ${alpha(stops.glowGreenEnd, 0.7)} 100%)`, - gradientGlowRed: `radial-gradient(circle at 0% 0%, ${alpha(stops.glowRedStart, 0.4)} 0%, ${alpha(stops.glowRedEnd, 0.4)} 100%)`, + // 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`. 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 index d6b59e36..5f52a457 100644 --- 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 @@ -95,13 +95,13 @@ describe("midnight gradient values", () => { // 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 at 0% 0%, rgba(17, 255, 200, 0.7) 0%, rgba(179, 255, 129, 0.7) 100%)", + "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 at 0% 0%, rgba(255, 157, 0, 0.8) 0%, rgba(255, 212, 142, 0.8) 100%)", + "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 at 0% 0%, rgba(255, 3, 41, 0.4) 0%, rgba(255, 87, 70, 0.4) 100%)", + "radial-gradient(circle farthest-side at 0% 0%, rgba(255, 3, 41, 0.4) 0%, rgba(255, 87, 70, 0.4) 100%)", ); }); @@ -133,13 +133,20 @@ describe("midnight gradient values", () => { g.gradientDashboardGraphNodeBorder, g.gradientGraphConnectorStroke, g.gradientIconButtonBlueGlow, - g.gradientCardHighlightRadial, ]; for (const value of strokes) { expect(value.startsWith("linear-gradient(90deg,")).toBe(true); } }); + // 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", () => { From ea0ee09aa0a58cdcc4906ea53d8c8059a9bac721 Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 17:03:39 +0530 Subject: [PATCH 21/23] fix: Button | Gradient Variant Signed-off-by: Jatin Verma --- .../src/components/card/components/elements.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) 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 e4d8666f..cf87b6d7 100644 --- a/packages/open-ui-kit/src/components/card/components/elements.tsx +++ b/packages/open-ui-kit/src/components/card/components/elements.tsx @@ -60,15 +60,10 @@ export const StyledCard = styled(MuiCard, { ...(connector ? cardConnectorStyles(theme) : {}), ...(alert ? cardAlertStyles(theme, alert) : {}), ...(glow ? cardGlowStyles(theme) : {}), - // These treatments all paint a translucent or gradient background; a hover - // colour would show through their semi-transparent stops. - ...(image || glass || alert || connector - ? {} - : { - "&:hover": { - backgroundColor: theme.palette.vars.baseBackgroundWeak, - }, - }), + // 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< MuiCardProps & { From 241df36bbecd18538140f7323e5c85ac68882cf0 Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 11:49:19 +0530 Subject: [PATCH 22/23] fix: Gradient fixes Signed-off-by: Jatin Verma --- .../__tests__/spider-chart.test.tsx | 24 ++-- .../styles/spider-chart.styles.ts | 46 ++++---- .../button/stories/button.stories.tsx | 2 - .../components/card/__tests__/card.test.tsx | 57 ++++++++++ .../src/components/card/styles/index.ts | 20 ++++ .../icon/__tests__/custom-icons.test.ts | 104 ++++++++---------- .../style/__tests__/gradient-vars.test.ts | 10 +- .../src/theme/style/gradient-vars-base.ts | 22 +++- packages/open-ui-kit/tsconfig.localtest.json | 7 ++ scripts/prepare-release-package.cjs | 1 + 10 files changed, 195 insertions(+), 98 deletions(-) create mode 100644 packages/open-ui-kit/tsconfig.localtest.json 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 a66b45f8..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 @@ -19,10 +19,8 @@ import { blue500, blueAlpha40, lightAlphaOrange40, - lightOrange600, midnightGradientStops, night700, - purple600, purpleAlpha40, } from "@/theme/style/color-palette"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; @@ -280,31 +278,33 @@ describe("SpiderChart", () => { [ "pinkPurple", midnightTheme.palette.gradients.gradientDataVizPinkPurple, - purple600, + midnightTheme.palette.vars.infoBorderDefault, purpleAlpha40, - purple600, + midnightTheme.palette.vars.infoBorderDefault, ], [ "cyanBlue", midnightTheme.palette.gradients.gradientDataVizCyanBlue, - night700, + midnightTheme.palette.vars.accentHDefault, blueAlpha40, - night700, + midnightTheme.palette.vars.accentHDefault, ], [ "orangeGold", midnightTheme.palette.gradients.gradientDataVizOrangeGold, - lightOrange600, + midnightTheme.palette.vars.warningBorderDefault, lightAlphaOrange40, - lightOrange600, + midnightTheme.palette.vars.warningBorderDefault, ], [ "blueDark", midnightTheme.palette.gradients.gradientDataVizBlueDark, - blue500, + midnightTheme.palette.vars.interactivePrimaryDefaultActive, "rgba(185, 171, 239, 0.76)", - // The one variant whose dot rings diverge from the outline color. - midnightGradientStops.dataVizBlue, + // 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, ], ]; @@ -334,7 +334,7 @@ describe("SpiderChart", () => { expect(screen.getByTestId("radar-series")).toMatchObject({ dataset: expect.objectContaining({ color: lightTheme.palette.gradients.gradientDataVizCyanBlue, - stroke: night700, + stroke: lightTheme.palette.vars.accentHDefault, strokeWidth: String(SPIDER_GRADIENT_STROKE_WIDTH), dotRadius: String(SPIDER_GRADIENT_DOT_RADIUS), dotFill: blueAlpha40, 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 52358401..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 @@ -7,13 +7,8 @@ import { alpha, styled, type Theme } from "@mui/material"; import type { ComponentType, HTMLAttributes } from "react"; import { - blue500, blueAlpha40, lightAlphaOrange40, - lightOrange600, - midnightGradientStops, - night700, - purple600, purpleAlpha40, } from "@/theme/style/color-palette"; import type { SpiderChartGradient } from "../types/spider-chart.types"; @@ -46,55 +41,58 @@ export const SPIDER_GRADIENT_DOT_RADIUS = 2.5; * 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` | `purple600` (9C4EEA) | `purpleAlpha40` (B76DFF66) | outline | - * | `cyanBlue` | `night700` (1C2B7F) | `blueAlpha40` (0051AF66) | outline | - * | `orangeGold` | `lightOrange600` | `lightAlphaOrange40` | outline | - * | `blueDark` | `blue500` (0051AF) | B9ABEF @ 76%, no palette name | `dataVizBlue` (3B82F6) | + * | 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 strokes them with the ramp's own `3B82F6` stop. + * 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. * - * They are fixed palette values rather than theme vars for the same reason as - * the toast glow shadows: the ramp itself is Midnight-only, and design has not - * diverged the accents per theme. + * 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: night700, + stroke: vars.accentHDefault, dotFill: blueAlpha40, - dotStroke: night700, + dotStroke: vars.accentHDefault, }; case "orangeGold": return { background: gradients.gradientDataVizOrangeGold, - stroke: lightOrange600, + stroke: vars.warningBorderDefault, dotFill: lightAlphaOrange40, - dotStroke: lightOrange600, + dotStroke: vars.warningBorderDefault, }; case "blueDark": return { background: gradients.gradientDataVizBlueDark, - stroke: blue500, + stroke: vars.interactivePrimaryDefaultActive, dotFill: alpha("#b9abef", 0.76), - dotStroke: midnightGradientStops.dataVizBlue, + dotStroke: vars.interactivePrimaryDefaultDefault, }; case "pinkPurple": default: return { background: gradients.gradientDataVizPinkPurple, - stroke: purple600, + stroke: vars.infoBorderDefault, dotFill: purpleAlpha40, - dotStroke: purple600, + dotStroke: vars.infoBorderDefault, }; } }; 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 18f38f8d..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 @@ -267,7 +267,6 @@ export const Secondary: Story = { render: (args) => , }; -/** Gradient background fill — Figma token `Gradient/Global-Button-Primary/Fill`. */ export const Gradient: Story = { args: { ...defaultArgs, @@ -284,7 +283,6 @@ export const Outlined: Story = { render: (args) => , }; -/** Gradient border ring — Figma token `Gradient/Global-Button-Primary/Border-Glow`. */ export const GradientOutlined: Story = { args: { ...defaultArgs, 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 0b1c7e79..c66a3dea 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 @@ -724,5 +724,62 @@ describe("Card", () => { "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/styles/index.ts b/packages/open-ui-kit/src/components/card/styles/index.ts index a9e345dc..39f01453 100644 --- a/packages/open-ui-kit/src/components/card/styles/index.ts +++ b/packages/open-ui-kit/src/components/card/styles/index.ts @@ -396,6 +396,26 @@ export const cardImageStyles = (theme: Theme, image: string): CSSObject => ({ 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 => ({ 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 a6f9bbba..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,66 +29,54 @@ function getSource(filePath: string) { return fs.readFileSync(filePath, "utf8"); } -// The dashboard nav icon paints a multi-tone ramp that a single inherited -// color can't express, so it's exempt from the `currentColor` rule (see the -// comment above DashboardMark in navigation/dashboard.tsx). -const MULTI_TONE_ICON_FILES = new Set([ - path.join("navigation", "dashboard.tsx"), -]); - -// OutshiftBrand paints the Outshift mark in fixed brand colors instead of -// `currentColor` by design (see the comment above the component in -// brand-logos.tsx): the mark must keep its brand colors regardless of theme. -// AgntcyBrand and CiscoBrand in the same file are still held to the normal -// rule. -const OUTSHIFT_BRAND_FILE = "brand-logos.tsx"; -const OUTSHIFT_BRAND_COLOR_IDENTIFIERS = new Set([ - "{outshiftLogoLightBlue}", - "{outshiftLogoGreen}", - "{outshiftLogoOrange}", - "{outshiftBlue}", - "{vars.brandLogoSecondary}", -]); +function stripQuotes(value: string) { + return value.replace(/^['"]/, "").replace(/['"]$/, "").trim(); +} + +// `none` and `currentColor` are the only paint keywords an icon may hardcode. +function isPaintKeyword(value: string) { + const keyword = stripQuotes(value).toLowerCase(); + + return keyword === "none" || keyword === "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$]*)*$/; + +function isAllowedPaint(value: string) { + const trimmed = value.trim(); + + 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 isPaintKeyword(trimmed); +} describe("custom icons", () => { - it("uses currentColor for SVG fills and strokes", () => { - const badPaintAttributes = iconFiles - .filter( - (filePath) => - !MULTI_TONE_ICON_FILES.has(path.relative(customIconsDir, filePath)), - ) - .flatMap((filePath) => { - const source = getSource(filePath); - const matches = [ - ...source.matchAll( - /\b(?:fill|stroke)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g, - ), - ]; - const isOutshiftBrandFile = - path.relative(customIconsDir, filePath) === OUTSHIFT_BRAND_FILE; - - return matches - .filter((match) => { - if ( - isOutshiftBrandFile && - OUTSHIFT_BRAND_COLOR_IDENTIFIERS.has(match[1]) - ) { - return false; - } - - const value = match[1].toLowerCase(); - return ( - value !== '"none"' && - value !== "'none'" && - value !== '"currentcolor"' && - value !== "'currentcolor'" - ); - }) - .map( - (match) => - `${path.relative(customIconsDir, filePath)}: ${match[0]}`, - ); - }); + it("never hardcodes SVG fill and stroke colors", () => { + const badPaintAttributes = iconFiles.flatMap((filePath) => { + const source = getSource(filePath); + const matches = [ + ...source.matchAll( + /\b(?:fill|stroke)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g, + ), + ]; + + return matches + .filter((match) => !isAllowedPaint(match[1])) + .map( + (match) => `${path.relative(customIconsDir, filePath)}: ${match[0]}`, + ); + }); expect(badPaintAttributes).toEqual([]); }); 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 index 5f52a457..34833be5 100644 --- 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 @@ -129,7 +129,6 @@ describe("midnight gradient values", () => { const strokes = [ g.gradientGlobalBorderFade, g.gradientGlobalBorderRainbow, - g.gradientGlobalButtonPrimaryBorderGlow, g.gradientDashboardGraphNodeBorder, g.gradientGraphConnectorStroke, g.gradientIconButtonBlueGlow, @@ -139,6 +138,15 @@ describe("midnight gradient values", () => { } }); + // 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", () => { 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 index cb138752..632a8387 100644 --- a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -30,7 +30,27 @@ import { * Source: Figma frames 274405:44106 and I274405:38087;25258:78851. */ export const gradientButtonPrimaryFill = `linear-gradient(90deg, ${stops.buttonPrimaryFillStart} 0%, ${stops.buttonPrimaryFillEnd} 100%)`; -export const gradientButtonPrimaryBorderGlow = `linear-gradient(90deg, ${stops.buttonPrimaryGlowBlue} 0%, ${stops.buttonPrimaryGlowCyan} 50%, ${stops.buttonPrimaryGlowTeal} 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. 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 From 0c3f81cddaa41ad3125e3ef60110627fe4136b41 Mon Sep 17 00:00:00 2001 From: Jatin Verma Date: Tue, 11 Aug 2026 17:05:45 +0530 Subject: [PATCH 23/23] feat: Gauge Chart | Gradient Variants Signed-off-by: Jatin Verma --- .../components/gauge-chart/gauge-chart.md | 19 ++ .../__tests__/gauge-chart.test.tsx | 245 +++++++++++++++++- .../gauge-chart/gauge-chart.stories.tsx | 117 +++++++-- .../src/charts/gauge-chart/gauge-chart.tsx | 203 ++++++++++++++- .../src/charts/gauge-chart/styles.ts | 123 +++++++++ 5 files changed, 669 insertions(+), 38 deletions(-) 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/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, +});