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/docs/data/material/components/activity-timeline/activity-timeline.md b/docs/data/material/components/activity-timeline/activity-timeline.md index 39ed8bc8..43a45d02 100644 --- a/docs/data/material/components/activity-timeline/activity-timeline.md +++ b/docs/data/material/components/activity-timeline/activity-timeline.md @@ -81,6 +81,7 @@ For audit trails, server events, or retryable workflows, pass explicit step stat | `steps` | `ActivityTimelineStep[]` | - | Ordered list of timeline steps. | | `automaticProgress` | `boolean` | `false` | Calculates progress from step position instead of using only step status. | | `size` | `'medium' \| 'large'` | `'large'` | Controls text sizing and spacing. | +| `variant` | `'default' \| 'gradient'` | `'default'` | Renders glowing status dots, a per-step time, and a fade-out down older steps. | ## Step shape @@ -90,9 +91,28 @@ interface ActivityTimelineStep { title: string; subTitle?: string; content?: React.ReactNode; + time?: string; } ``` +## Gradient variant + +Set `variant="gradient"` to show events with glowing status dots and a time +against the timeline. Older steps fade out down the list. + +```tsx + +``` + ## Accessibility Keep titles short and descriptive so the sequence is easy to scan. diff --git a/docs/data/material/components/button/ButtonIconOnlyGradient.js b/docs/data/material/components/button/ButtonIconOnlyGradient.js new file mode 100644 index 00000000..d7c6c91d --- /dev/null +++ b/docs/data/material/components/button/ButtonIconOnlyGradient.js @@ -0,0 +1,27 @@ +import * as React from "react"; +import MicNoneOutlinedIcon from "@mui/icons-material/MicNoneOutlined"; +import { Button, Stack, ThemeMode, ThemeProvider } from "@open-ui-kit/core"; + +export default function ButtonIconOnlyGradient() { + return ( + + + + + + + + ); +} diff --git a/docs/data/material/components/button/ButtonIconOnlyGradient.tsx b/docs/data/material/components/button/ButtonIconOnlyGradient.tsx new file mode 100644 index 00000000..d7c6c91d --- /dev/null +++ b/docs/data/material/components/button/ButtonIconOnlyGradient.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import MicNoneOutlinedIcon from "@mui/icons-material/MicNoneOutlined"; +import { Button, Stack, ThemeMode, ThemeProvider } from "@open-ui-kit/core"; + +export default function ButtonIconOnlyGradient() { + return ( + + + + + + + + ); +} diff --git a/docs/data/material/components/button/button.md b/docs/data/material/components/button/button.md index 1fac8e20..1058abe1 100644 --- a/docs/data/material/components/button/button.md +++ b/docs/data/material/components/button/button.md @@ -64,6 +64,13 @@ For icon-only buttons, pass the icon as the only child and provide an accessible {{"demo": "ButtonIcons.js", "bg": true}} +### Icon Button AI + +An icon-only button on the `gradient` variant gets its own treatment rather than the text button scaled down: a round control filled with the `Icon-Button-Blue` gradient and ringed by `Icon-Button-Blue-Glow`. +It applies automatically — pass the icon as the only child, as above, and set `variant="gradient"`. + +{{"demo": "ButtonIconOnlyGradient.js", "bg": true}} + ## States Buttons support disabled and loading states through the standard button API. diff --git a/docs/data/material/components/card/AlertCard.js b/docs/data/material/components/card/AlertCard.js new file mode 100644 index 00000000..322a8c55 --- /dev/null +++ b/docs/data/material/components/card/AlertCard.js @@ -0,0 +1,42 @@ +import * as React from "react"; +import { + Card, + CardAlertHeader, + CardContent, + CardDescription, + CardHeader, + Stack, + ThemeMode, + ThemeProvider, +} from "@open-ui-kit/core"; + +export default function AlertCard() { + return ( + + + + CRITICAL ALERT + + + + The system detected a revision loop during itinerary optimization + within a high-density semantic cluster. The agent produced an + itinerary that violated walking constraints, triggering an + optimization cycle before producing the final output. + + + + + WARNING + + + + Initial itinerary violated constraints and required a revision + pass. Consider improving constraint conditioning upstream. + + + + + + ); +} diff --git a/docs/data/material/components/card/AlertCard.tsx b/docs/data/material/components/card/AlertCard.tsx new file mode 100644 index 00000000..322a8c55 --- /dev/null +++ b/docs/data/material/components/card/AlertCard.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { + Card, + CardAlertHeader, + CardContent, + CardDescription, + CardHeader, + Stack, + ThemeMode, + ThemeProvider, +} from "@open-ui-kit/core"; + +export default function AlertCard() { + return ( + + + + CRITICAL ALERT + + + + The system detected a revision loop during itinerary optimization + within a high-density semantic cluster. The agent produced an + itinerary that violated walking constraints, triggering an + optimization cycle before producing the final output. + + + + + WARNING + + + + Initial itinerary violated constraints and required a revision + pass. Consider improving constraint conditioning upstream. + + + + + + ); +} diff --git a/docs/data/material/components/card/CardWithImage.js b/docs/data/material/components/card/CardWithImage.js new file mode 100644 index 00000000..55c90b56 --- /dev/null +++ b/docs/data/material/components/card/CardWithImage.js @@ -0,0 +1,38 @@ +import * as React from "react"; +import { + Button, + Card, + CardActions, + CardContent, + CardDescription, + CardHeader, + ThemeMode, + ThemeProvider, +} from "@open-ui-kit/core"; + +export default function CardWithImage() { + return ( + + + + + + Get clear, AI-powered explanations for events, anomalies, or + performance changes. + + + + + + + + ); +} diff --git a/docs/data/material/components/card/CardWithImage.tsx b/docs/data/material/components/card/CardWithImage.tsx new file mode 100644 index 00000000..55c90b56 --- /dev/null +++ b/docs/data/material/components/card/CardWithImage.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import { + Button, + Card, + CardActions, + CardContent, + CardDescription, + CardHeader, + ThemeMode, + ThemeProvider, +} from "@open-ui-kit/core"; + +export default function CardWithImage() { + return ( + + + + + + Get clear, AI-powered explanations for events, anomalies, or + performance changes. + + + + + + + + ); +} diff --git a/docs/data/material/components/card/ConnectorCard.js b/docs/data/material/components/card/ConnectorCard.js new file mode 100644 index 00000000..ea43391a --- /dev/null +++ b/docs/data/material/components/card/ConnectorCard.js @@ -0,0 +1,32 @@ +import * as React from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + ThemeMode, + ThemeProvider, + Typography, +} from "@open-ui-kit/core"; + +export default function ConnectorCard() { + return ( + + + ({ color: theme.palette.vars.baseTextMedium })} + > + Divergent Planning Paths + + + + + The Itinerary Planner and Schedule Planner increasingly disagree on + the ordering of the same set of activities. + + + + + ); +} diff --git a/docs/data/material/components/card/ConnectorCard.tsx b/docs/data/material/components/card/ConnectorCard.tsx new file mode 100644 index 00000000..ea43391a --- /dev/null +++ b/docs/data/material/components/card/ConnectorCard.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + ThemeMode, + ThemeProvider, + Typography, +} from "@open-ui-kit/core"; + +export default function ConnectorCard() { + return ( + + + ({ color: theme.palette.vars.baseTextMedium })} + > + Divergent Planning Paths + + + + + The Itinerary Planner and Schedule Planner increasingly disagree on + the ordering of the same set of activities. + + + + + ); +} diff --git a/docs/data/material/components/card/DisabledCard.js b/docs/data/material/components/card/DisabledCard.js index e61e46d5..2a9cd87c 100644 --- a/docs/data/material/components/card/DisabledCard.js +++ b/docs/data/material/components/card/DisabledCard.js @@ -10,7 +10,7 @@ import { export default function DisabledCard() { return ( - + diff --git a/docs/data/material/components/card/DisabledCard.tsx b/docs/data/material/components/card/DisabledCard.tsx index e61e46d5..2a9cd87c 100644 --- a/docs/data/material/components/card/DisabledCard.tsx +++ b/docs/data/material/components/card/DisabledCard.tsx @@ -10,7 +10,7 @@ import { export default function DisabledCard() { return ( - + diff --git a/docs/data/material/components/card/GlassCard.js b/docs/data/material/components/card/GlassCard.js new file mode 100644 index 00000000..f5b73a1f --- /dev/null +++ b/docs/data/material/components/card/GlassCard.js @@ -0,0 +1,29 @@ +import * as React from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + ThemeMode, + ThemeProvider, +} from "@open-ui-kit/core"; + +export default function GlassCard() { + return ( + + + + + + Implement a layered request strategy with automatic retries, + provider failover, and timeout tuning to reduce sensitivity to + transient outages. + + + + + ); +} diff --git a/docs/data/material/components/card/GlassCard.tsx b/docs/data/material/components/card/GlassCard.tsx new file mode 100644 index 00000000..f5b73a1f --- /dev/null +++ b/docs/data/material/components/card/GlassCard.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + ThemeMode, + ThemeProvider, +} from "@open-ui-kit/core"; + +export default function GlassCard() { + return ( + + + + + + Implement a layered request strategy with automatic retries, + provider failover, and timeout tuning to reduce sensitivity to + transient outages. + + + + + ); +} diff --git a/docs/data/material/components/card/card.md b/docs/data/material/components/card/card.md index 0f5200dc..f96c3b3b 100644 --- a/docs/data/material/components/card/card.md +++ b/docs/data/material/components/card/card.md @@ -66,10 +66,55 @@ Keep headings short and put supporting metadata in the subheader or content area {{"demo": "CardDenseContent.js", "bg": true}} +## Alerts + +Pass `alert` with `"critical"` or `"warning"` to render the card as an alert. +Both severities share one translucent surface at the larger alert radius and padding; `critical` additionally draws a rainbow gradient border, and each severity carries its own accent colour. + +Pair it with `CardAlertHeader`, which renders the severity label and a right-aligned timestamp. +The label picks up the accent colour from the parent card, so the severity is declared once on `Card`. + +{{"demo": "AlertCard.js", "bg": true}} + +Keep the label short and in the alert's own words — `CRITICAL ALERT`, `WARNING` — and put the actionable detail in the title. +The `timestamp` slot is optional; omit it when the alert has no meaningful age. + +## Graph connector + +Pass `connector` for the graph-connector surface. +It stacks the `Graph-Connector` fill and glow gradients over a backdrop blur and edges the card with the matching 1px gradient stroke, at a tighter 6px radius than the other treatments. + +{{"demo": "ConnectorCard.js", "bg": true}} + +Use it for the small cards that hang off a graph or flow diagram, where the surface should read as part of the canvas rather than as a raised panel. + +## Glass + +Pass `glass` for the frosted-glass surface. +It fills the card with the `Gradient/Card-Glass-BG` token over a backdrop blur, adds a white hairline, and drops a soft shadow beneath it. + +{{"demo": "GlassCard.js", "bg": true}} + +The fill is translucent, so the card picks up whatever sits behind it. +In the design it sits directly on the app surface, which is where it reads best. +The backdrop blur only has a visible effect when there is imagery or a pattern behind the card; over a flat colour the translucent fill alone carries the treatment. + +## Image background + +Pass `image` to use a photo as the card surface. +The photo is layered at half strength over the `Gradient/Welcome-Card-BG-Dark` fill, under two scrims that keep the copy legible over the picture: one runs top to bottom (`Gradient/Overlay-Black-Fade-In`), the other runs left to right and clears by the midpoint of the card, so the title and body copy land on flat colour rather than on the image. +The card also switches to the larger radius and padding the design uses for these promotional surfaces, and its text primitives inherit the light-on-photo colour. + +{{"demo": "CardWithImage.js", "bg": true}} + +Use it for a single hero or welcome surface, not for cards in a list. +Do not combine `image` with `glow`; the gradient ring is designed for the plain card surface. +The gradients are only design-approved in the Midnight theme — the other themes fall back to a generic dark background gradient. + ## Disabled appearance -Cards do not have a dedicated `disabled` prop. -When a card represents unavailable content, make that state explicit in surrounding logic and apply a muted style. +Use the `disabled` prop when a card represents unavailable content. +It applies the muted surface treatment and sets `aria-disabled` on the card so assistive technology reports the group as unavailable. {{"demo": "DisabledCard.js", "bg": true}} @@ -92,6 +137,20 @@ Card primitives support the underlying card props. | `CardActionArea` | `CardActionAreaProps` | Makes the card surface interactive. | | `CardDescription` | `TypographyProps` | Body text with Open UI Kit card description styling. | | `CardSubheader` | `TypographyProps` | Compact supporting text with Open UI Kit card subheader styling. | +| `CardAlertHeader` | `StackProps` | Severity label and timestamp row for alert cards. | + +`Card` adds three props on top of `CardProps`: + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `alert` | `"warning" \| "critical"` | — | Applies the alert treatment. `critical` adds the rainbow gradient border. | +| `connector` | `boolean` | `false` | Applies the graph-connector treatment. | +| `disabled` | `boolean` | `false` | Applies the disabled treatment and sets `aria-disabled`. | +| `glass` | `boolean` | `false` | Applies the frosted-glass treatment. | +| `glow` | `boolean` | `false` | Applies the gradient border and blue glow treatment. | +| `image` | `string` | — | Background image URL. Applies the image treatment described above. | + +`alert`, `connector`, `glass`, `glow`, and `image` are decorative treatments for the same surface — use one at a time. ## Accessibility diff --git a/docs/data/material/components/gauge-chart/gauge-chart.md b/docs/data/material/components/gauge-chart/gauge-chart.md index d5167b8f..31fe1eb1 100644 --- a/docs/data/material/components/gauge-chart/gauge-chart.md +++ b/docs/data/material/components/gauge-chart/gauge-chart.md @@ -50,11 +50,30 @@ export function GaugeChartExample() { } ``` +## Gradient variant + +Set `variant` on the chart to use one of the three design-approved gauge ramps. +The ramp strokes a 270° value arc over an equal-weight track, a matching glow renders behind the value, and the value gains a muted `%` suffix. + +```tsx + +``` + +| `variant` | Gradient token | Glow | +| --- | --- | --- | +| `amber` | `Gradient/Gauge-Arc-Amber` | `FBAF45` | +| `teal` | `Gradient/Gauge-Arc-Teal` | `00B98D` | +| `blue` | `Gradient/Icon-Subtract-Blue` | `187ADC` | + +The ramps are design-approved in the Midnight theme. +The data item's `color` is unused while `variant` is set. + ## Storybook scenarios Storybook is the source of truth for interactive examples, controls, and visual state checks. Start with the closest story, then adapt the props to match your product flow. +- `Gradient` — the three gauge ramps in one frame, with the frame's labels and values. - Dedicated Storybook coverage is still being expanded for this export. ## Behavior notes diff --git a/docs/data/material/components/spider-chart/spider-chart.md b/docs/data/material/components/spider-chart/spider-chart.md index 36b98110..03a129bf 100644 --- a/docs/data/material/components/spider-chart/spider-chart.md +++ b/docs/data/material/components/spider-chart/spider-chart.md @@ -50,11 +50,34 @@ export function SpiderChartExample() { } ``` +## Gradient variant + +Set `gradient` on a radar series to use one of the four design-approved data-viz ramps. +The ramp fills the radar area, its paired accent draws the outline, and every data vertex gets a ring in the same accent. + +```tsx + +``` + +| `gradient` | Gradient token | +| --- | --- | +| `pinkPurple` | `Gradient/Data-Viz-Pink-Purple` | +| `cyanBlue` | `Gradient/Data-Viz-Cyan-Blue` | +| `orangeGold` | `Gradient/Data-Viz-Orange-Gold` | +| `blueDark` | `Gradient/Data-Viz-Blue-Dark` | + +The ramps are design-approved in the Midnight theme; the other themes fall back to the closest gradient the library already ships. +Use one ramp per series, and pass `background`, `stroke`, or `dot` on the series to override any part of the treatment. + ## Storybook scenarios Storybook is the source of truth for interactive examples, controls, and visual state checks. Start with the closest story, then adapt the props to match your product flow. +- `Gradient` — the four data-viz ramps, one agent per ramp. - Dedicated Storybook coverage is still being expanded for this export. ## Behavior notes diff --git a/docs/data/material/components/toast/toast.md b/docs/data/material/components/toast/toast.md index d536fccf..98eb50aa 100644 --- a/docs/data/material/components/toast/toast.md +++ b/docs/data/material/components/toast/toast.md @@ -63,6 +63,20 @@ Start with the closest story, then adapt the props to match your product flow. - Without Action - All Types - All Types Without Title +- Glow / With header +- Glow / Without header + +## Glow + +Pass `glow` for the "Toast message Glow" treatment: a `Global-Border/Fade` gradient border with a blue glow cast from behind the toast. + +It comes in two forms, picked automatically from whether a `title` is set: + +- **With header** — title above the message, carrying the stronger glow. +- **Without header** — message only, with the softer glow. + +Use it for AI-generated insights and summaries, where the toast reports something the system noticed rather than the outcome of a user action. +Keep the status `type` at its default; the gradient border replaces the status border, so pairing the two reads as two competing signals. ## Behavior notes @@ -78,6 +92,7 @@ Use the exported TypeScript props for implementation details and keep local over | Prop | Type | Description | | --- | --- | --- | | `Toast` props | Component-specific props | Controls the supported behavior, slots, state, and styling for Toast. | +| `glow` | `boolean` | Applies the glow treatment. The glow is stronger when `title` is also set. | | `children` | `React.ReactNode` | Content rendered inside the component when the component supports composition. | | `className` | `string` | Adds a class to the root slot for product-level styling hooks. | | `sx` | `SxProps` | Applies local style overrides while still using the active Open UI Kit theme. | diff --git a/docs/data/material/components/typography/typography.md b/docs/data/material/components/typography/typography.md index adafd2cc..5a20ae49 100644 --- a/docs/data/material/components/typography/typography.md +++ b/docs/data/material/components/typography/typography.md @@ -48,6 +48,23 @@ export function TypographyExample() { } ``` +## Gradient text + +Set the `gradient` prop to fill the text with a gradient instead of a flat color. +It is a boolean, so it composes with any `variant`. + +```tsx +import { Typography } from '@open-ui-kit/core'; + +export function GradientTextExample() { + return ( + + Welcome Amy! + + ); +} +``` + ## Storybook scenarios Storybook is the source of truth for interactive examples, controls, and visual state checks. @@ -69,6 +86,7 @@ Use the exported TypeScript props for implementation details and keep local over | Prop | Type | Description | | --- | --- | --- | | `Typography` props | Component-specific props | Controls the supported behavior, slots, state, and styling for Typography. | +| `gradient` | `boolean` | Fills the text with a gradient (via `background-clip: text`) instead of a flat color. Composes with any `variant`. | | `children` | `React.ReactNode` | Content rendered inside the component when the component supports composition. | | `className` | `string` | Adds a class to the root slot for product-level styling hooks. | | `sx` | `SxProps` | Applies local style overrides while still using the active Open UI Kit theme. | diff --git a/docs/public/static/images/cards/welcome-card.jpg b/docs/public/static/images/cards/welcome-card.jpg new file mode 100644 index 00000000..0e81de32 Binary files /dev/null and b/docs/public/static/images/cards/welcome-card.jpg differ diff --git a/packages/open-ui-kit/.storybook/assets/welcome-card.jpg b/packages/open-ui-kit/.storybook/assets/welcome-card.jpg new file mode 100644 index 00000000..0e81de32 Binary files /dev/null and b/packages/open-ui-kit/.storybook/assets/welcome-card.jpg differ diff --git a/packages/open-ui-kit/.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/.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/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/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/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/charts/gauge-chart/__tests__/gauge-chart.test.tsx b/packages/open-ui-kit/src/charts/gauge-chart/__tests__/gauge-chart.test.tsx index 96f79de5..e9fb5797 100644 --- a/packages/open-ui-kit/src/charts/gauge-chart/__tests__/gauge-chart.test.tsx +++ b/packages/open-ui-kit/src/charts/gauge-chart/__tests__/gauge-chart.test.tsx @@ -9,9 +9,22 @@ import "@testing-library/jest-dom"; import type { ComponentProps, CSSProperties, ReactNode } from "react"; import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; +import { + blue300, + blue500, + green500, + greyAlpha40, + lightOrange500, + midnightGradientStops, +} from "@/theme/style/color-palette"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; import { GaugeChart } from "../gauge-chart"; -import { barShadow, gaugeWrapper } from "../styles"; +import { + barShadow, + gaugeWrapper, + getGaugeChartGradient, + type GaugeChartGradient, +} from "../styles"; import type { ChartDataItem } from "../../common/types"; jest.mock("recharts", () => ({ @@ -49,6 +62,7 @@ jest.mock("recharts", () => ({ ), Pie: ({ + activeShape, children, data, dataKey, @@ -58,7 +72,8 @@ jest.mock("recharts", () => ({ startAngle, strokeWidth, }: { - children: ReactNode; + activeShape?: (props: Record) => ReactNode; + children?: ReactNode; data: Array<{ fill: string; value: number }>; dataKey: string; endAngle: number; @@ -68,18 +83,30 @@ jest.mock("recharts", () => ({ strokeWidth: number; }) => ( + {/* Recharts resolves the sector's geometry against the chart box before + handing it to `activeShape`; the mock stands in for that, centring + the 132px gauge the way `cx`/`cy` of `50%` would. */} + {activeShape?.({ + cx: 66, + cy: 66, + endAngle, + fill: data[0]?.fill, + innerRadius, + outerRadius, + startAngle, + })} {children} ), @@ -221,6 +248,61 @@ describe("GaugeChart", () => { }); }); + it.each([ + [100, "successBackgroundDefault"], + [76, "successBackgroundDefault"], + [75, "warningBackgroundDefault"], + [51, "warningBackgroundDefault"], + [50, "severeWarningBorderDefault"], + [26, "severeWarningBorderDefault"], + [25, "negativeBackgroundDefault"], + [0, "negativeBackgroundDefault"], + ] as const)( + "colors the arc from the value when no color is given (%i%% -> %s)", + (value, token) => { + renderGauge(false, { data: [{ name: "Score", value }] }); + + expect(screen.getByTestId("pie")).toMatchObject({ + dataset: expect.objectContaining({ + mainFill: lightTheme.palette.vars[token], + }), + }); + }, + ); + + it("bands the status ramp against maxValue rather than the raw value", () => { + // 30/40 is 75%, the warning band; the raw 30 would band to severe warning. + renderGauge(false, { + maxValue: 40, + data: [{ name: "Score", value: 30 }], + }); + + expect(screen.getByTestId("pie")).toMatchObject({ + dataset: expect.objectContaining({ + mainFill: lightTheme.palette.vars.warningBackgroundDefault, + }), + }); + }); + + it("keeps an explicit data color ahead of the status ramp", () => { + // 25% would band to negative if the item did not name a color. + renderGauge(false, { + data: [ + { + name: "Score", + value: 25, + color: lightTheme.palette.vars.accentADefault, + }, + ], + }); + + expect(screen.getByTestId("pie")).toMatchObject({ + dataset: expect.objectContaining({ + mainFill: lightTheme.palette.vars.accentADefault, + }), + }); + }); + it("applies token-aware shadow and custom sizing", () => { renderGauge(false, { styleProps: { @@ -255,4 +337,155 @@ describe("GaugeChart", () => { expect(screen.getByText("Good")).toBeInTheDocument(); }); + + // Figma: `Gauge Chart` (274417:44466), one widget per `gradient-token` + // swatch, with the paired `Solid` swatch as the glow. + describe("gradient treatment", () => { + const variants: [GaugeChartGradient, string, string, number, string][] = [ + [ + "amber", + midnightGradientStops.gaugeArcAmber, + midnightGradientStops.gaugeArcAmber, + 0.78, + lightOrange500, + ], + [ + "teal", + midnightGradientStops.gaugeArcTealStart, + midnightGradientStops.gaugeArcTealEnd, + 1, + green500, + ], + ["blue", midnightGradientStops.iconSubtractBlue, blue500, 1, blue300], + ]; + + it.each(variants)( + "resolves the %s label to its arc stops and glow", + (gradient, from, to, toOpacity, glow) => { + expect(getGaugeChartGradient(gradient)).toEqual({ + from, + to, + toOpacity, + glow, + }); + }, + ); + + it("rings the ramped arc over an equal-weight track with a glow behind", () => { + const { container } = renderGauge(false, { variant: "teal" }); + + // The gradient treatment drops the dividers, but stays on the same + // PieChart wrapper and `Pie`s the default gauge uses. + expect(container.querySelectorAll("line")).toHaveLength(0); + expect(screen.getByTestId("pie-chart")).toBeInTheDocument(); + + const [track, arc] = screen.getAllByTestId("pie"); + + // The frame's 5.275px arc stroke, normalized from its 171.7px gauge to + // the default 132px one, is shared by the track — one line weight + // through the junction — and both rings are centred on a radius inset + // so the stroke's outer edge clears the svg viewport by a pixel instead + // of being shaved flat against it. + const strokeThickness = (132 * 5.275) / 171.704; + const arcRadius = (132 - strokeThickness) / 2 - 1; + + expect(track.dataset.mainFill).toBe(greyAlpha40); + expect(Number(track.dataset.innerRadius)).toBeCloseTo( + arcRadius - strokeThickness / 2, + 2, + ); + expect(Number(track.dataset.outerRadius)).toBeCloseTo( + arcRadius + strokeThickness / 2, + 2, + ); + // The outer edge lands a pixel inside the 132px box on every side. + expect(Number(track.dataset.outerRadius)).toBeCloseTo(132 / 2 - 1, 2); + // The full 270° sweep: 225° clockwise to -45°. + expect(Number(track.dataset.startAngle)).toBe(225); + expect(Number(track.dataset.endAngle)).toBe(-45); + + const gradientDef = container.querySelector("linearGradient"); + expect(arc.dataset.mainFill).toBe(`url(#${gradientDef?.id})`); + expect(Number(arc.dataset.innerRadius)).toBeCloseTo( + arcRadius - strokeThickness / 2, + 2, + ); + expect(Number(arc.dataset.outerRadius)).toBeCloseTo( + arcRadius + strokeThickness / 2, + 2, + ); + // 75 of 100 fills three quarters of the 270° sweep. + expect(Number(arc.dataset.startAngle)).toBe(225); + expect(Number(arc.dataset.endAngle)).toBeCloseTo(225 - 0.75 * 270, 2); + + // Each `Pie` paints its sector as a stroke down the middle of the ring, + // so both ends carry a round cap and the thickness cannot drift. + const point = (angle: number) => { + const radian = (angle * Math.PI) / 180; + return `${66 + arcRadius * Math.cos(radian)},${ + 66 - arcRadius * Math.sin(radian) + }`; + }; + // Swept clockwise (`sweep-flag` 1) from 225°, taking the large arc past + // a half turn. + const expectedPath = (sweep: number) => + `M${point(225)}A${arcRadius},${arcRadius},0,${ + sweep > 180 ? 1 : 0 + },1,${point(225 - sweep)}`; + + const trackPath = track.querySelector("path") as SVGPathElement; + const arcPath = arc.querySelector("path") as SVGPathElement; + + expect(trackPath).toHaveAttribute("d", expectedPath(270)); + expect(trackPath).toHaveAttribute("stroke", greyAlpha40); + expect(trackPath).toHaveAttribute("stroke-linecap", "round"); + expect(Number(trackPath.getAttribute("stroke-width"))).toBeCloseTo( + strokeThickness, + 2, + ); + + expect(arcPath).toHaveAttribute("d", expectedPath(0.75 * 270)); + expect(arcPath).toHaveAttribute("stroke", `url(#${gradientDef?.id})`); + expect(arcPath).toHaveAttribute("stroke-linecap", "round"); + expect(Number(arcPath.getAttribute("stroke-width"))).toBeCloseTo( + strokeThickness, + 2, + ); + + const stopElements = gradientDef?.querySelectorAll("stop"); + expect(stopElements?.[0]).toHaveAttribute( + "stop-color", + midnightGradientStops.gaugeArcTealStart, + ); + expect(stopElements?.[1]).toHaveAttribute( + "stop-color", + midnightGradientStops.gaugeArcTealEnd, + ); + + const glow = container.querySelector("svg") + ?.previousElementSibling as HTMLElement; + expect(glow).toHaveStyle({ background: green500 }); + expect(glow.style.filter).toContain("blur"); + }); + + it("renders the value with a muted % suffix", () => { + renderGauge(false, { variant: "amber" }); + + expect(screen.getByText("75")).toBeInTheDocument(); + expect(screen.getByText("%")).toBeInTheDocument(); + }); + + it("drops the value arc at zero without dropping the track", () => { + const { container } = renderGauge(false, { + variant: "blue", + data: [{ name: "Score", value: 0, color: "#000" }], + }); + + // A zero sweep would otherwise leave a lone round cap sitting at 225°. + const pies = screen.getAllByTestId("pie"); + expect(pies).toHaveLength(1); + expect(pies[0].dataset.mainFill).toBe(greyAlpha40); + expect(container.querySelectorAll("path")).toHaveLength(1); + }); + }); }); diff --git a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx index 6674fb0e..3b3f5c16 100644 --- a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx +++ b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.stories.tsx @@ -45,6 +45,12 @@ const meta: Meta = { control: false, description: "Optional width, height, and label-position overrides.", }, + variant: { + control: "select", + options: ["amber", "teal", "blue"], + description: + "Applies the design-approved gradient treatment: ramped 270° arc, ambient glow, and a % suffix.", + }, }, }; @@ -112,38 +118,26 @@ const CriticalTemplate = (args: Partial) => { ); }; -const StatesTemplate = () => { - const theme = useTheme(); - - return ( - - {[100, 75, 50, 25].map((value, index) => { - const color = - index === 0 - ? theme.palette.vars.successBackgroundDefault - : index === 1 - ? theme.palette.vars.warningBackgroundDefault - : theme.palette.vars.negativeBackgroundDefault; - - return ( - - - - ); - })} - - ); -}; +/* + * Each gauge omits its data item's `color`, so the arc comes from the + * component's own status ramp: success at 100, warning at 75, severe warning + * at 50, negative at 25. + */ +const StatesTemplate = () => ( + + {[100, 75, 50, 25].map((value) => ( + + + + ))} + +); const WithLabelTemplate = () => { const theme = useTheme(); @@ -182,6 +176,59 @@ const CustomMaxTemplate = () => { ); }; +/* + * Gradient treatment — Figma `Gauge Chart` (274417:44466). + * + * One entry per widget in the frame, with the frame's labels and values: + * each pairs a `gradient-token` arc swatch with the `Solid` glow swatch + * behind the value. + */ +const gradientVariants: { + label: string; + variant: NonNullable; + value: number; +}[] = [ + { label: "Trip Planner", variant: "amber", value: 50 }, + { label: "Aether", variant: "teal", value: 82 }, + { label: "E-Commerce App", variant: "blue", value: 67 }, +]; + +const GradientTemplate = ({ + label, + variant, + value, +}: { + label: string; + variant: GaugeChartProps["variant"]; + value: number; +}) => { + const theme = useTheme(); + + return ( + + + {label} + + + + + + + Overall Performance + + + + ); +}; + export const Default: Story = { render: (args) => , args: { @@ -189,6 +236,22 @@ export const Default: Story = { }, }; +/** + * The three design-approved gauge ramps, one widget per `variant`: amber + * (`Gradient/Gauge-Arc-Amber`), teal (`Gradient/Gauge-Arc-Teal`), and blue + * (`Gradient/Icon-Subtract-Blue`), each glowing its paired solid behind the + * value. + */ +export const Gradient: Story = { + render: () => ( + + {gradientVariants.map((variant) => ( + + ))} + + ), +}; + export const Warning: Story = { render: (args) => , args: { diff --git a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx index 4db1070c..710acac1 100644 --- a/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx +++ b/packages/open-ui-kit/src/charts/gauge-chart/gauge-chart.tsx @@ -6,11 +6,25 @@ import styled from "@emotion/styled"; import { Box, Typography, useTheme } from "@mui/material"; -import type { ReactNode } from "react"; +import { useId, type ReactNode } from "react"; import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; -import { barShadow, boxStyle, gaugeLabel, gaugeWrapper } from "./styles"; +import { + barShadow, + boxStyle, + GAUGE_GRADIENT_TRACK_COLOR, + gaugeGlow, + gaugeGradientSuffix, + gaugeGradientValue, + gaugeLabel, + gaugeWrapper, + getGaugeChartGradient, + getGaugeStatusColor, + type GaugeChartGradient, +} from "./styles"; import { ChartDataItem, ChartProps } from "../common/types"; +export type { GaugeChartGradient } from "./styles"; + // Dividers Configuration const NUM_DIVIDERS = 51; const SMALL_DIVIDER_LENGTH = 2.2; @@ -21,15 +35,110 @@ const DIVIDER_MARGIN_FROM_CHART = 10; const START_ANGLE = 240; const END_ANGLE = -60; +/* + * Gradient treatment — Figma `Gauge Chart` (274417:44466). + * + * The frame's gauge is a 270° ring from the bottom-left (225°) clockwise to + * the bottom-right (-45°): a translucent track under a round-capped arc + * carrying the ramp. Both rings stay `Pie`s, so the treatment shares its + * rendering path with the default gauge, but each paints its sector through + * `renderGaugeArc` — see there for why a filled sector cannot carry the + * frame's round cap. + * + * The track deliberately shares the value arc's 5.275px stroke (the frame + * draws a 2.433px hairline instead) so the ring keeps one line weight through + * the junction, matching the default gauge's even ring. The stroke width is + * normalized to the frame's 171.7px gauge so it stays exact at any size. + * + * The ring's radius is derived from the box rather than taken from the frame: + * the frame's 84.18px radius plus half the stroke overflows its own 171.7px + * bounds (86.8 > 85.85), and an svg clips at its viewport — shaving the + * stroke flat wherever the ring meets the box edge (90°, 180°, 0°). Insetting + * the radius by half the stroke plus a pixel of padding keeps the full stroke + * inside the box all the way around. + */ +const GRADIENT_START_ANGLE = 225; +const GRADIENT_SWEEP_ANGLE = 270; +const GRADIENT_STROKE_RATIO = 5.275 / 171.704; +const GRADIENT_EDGE_PADDING = 1; + +/** The sector geometry recharts hands a `Pie`'s shape renderer. */ +interface GaugeArcShapeProps { + cx: number; + cy: number; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; + fill: string; +} + +/** + * Draws a `Pie`'s sector as a stroke down the middle of the ring. The sector + * still owns the geometry; only the way it is painted changes, because a + * filled sector cannot carry a round cap — recharts approximates one from + * `cornerRadius` by trimming the outer edge by `asin(cr / mid)` and the inner + * edge by `asin(cr / (inner - cr))`, two different angles joined by a straight + * line, which leaves the flat, slightly skewed end the frame does not have. + * A stroke carries the thickness and both round caps exactly, and neither can + * drift between the ring's edges along the arc. + */ +const renderGaugeArc = (props: unknown) => { + // Recharts types a shape renderer's props as `unknown`; what it passes is + // the resolved sector. + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = + props as GaugeArcShapeProps; + + const radius = (innerRadius + outerRadius) / 2; + const point = (angle: number) => { + const radian = (angle * Math.PI) / 180; + return `${cx + radius * Math.cos(radian)},${cy - radius * Math.sin(radian)}`; + }; + // The gauge fills from its start angle downwards, which is clockwise on + // screen — SVG's `sweep-flag` of 1. + const largeArc = startAngle - endAngle > 180 ? 1 : 0; + + return ( + + ); +}; + const StyledResponsiveContainer = styled(ResponsiveContainer)` display: flex; justify-content: center; align-items: center; `; -export interface GaugeChartProps extends ChartProps { +/** + * A gauge's single metric. Unlike the shared `ChartDataItem`, `color` is + * optional — omit it and the default gauge derives the arc color from the + * value through `getGaugeStatusColor`. + */ +export interface GaugeChartDataItem extends Omit { + color?: string; +} + +export interface GaugeChartProps extends Omit { + /** + * Single metric to plot. Widened rather than narrowed to + * `GaugeChartDataItem[]` because `ExtendedChartProps` intersects these props + * with the other charts', where the shared union still has to be accepted. + */ + data: ChartProps["data"] | GaugeChartDataItem[]; /** Highest target value used to calculate how much of the gauge arc is filled. */ maxValue?: number; + /** + * Applies the gradient treatment: a 270° ring with the named ramp filling + * the value arc, an ambient glow behind the value, and a muted `%` suffix. + * Replaces the default arc, dividers, and the data item's `color`. + */ + variant?: GaugeChartGradient; /** Optional content shown below the numeric value inside the gauge. */ customLabelComponent?: ReactNode; /** Optional dimensional overrides for compact or expanded gauge layouts. */ @@ -46,18 +155,27 @@ export interface GaugeChartProps extends ChartProps { export const GaugeChart = ({ data, maxValue = 100, + variant, // prop for gradiant gauge chart variant eg: amber, teal, blue customLabelComponent, styleProps, }: GaugeChartProps) => { const theme = useTheme(); + // `useId` wraps its value in colons, which are not valid in a `url(#...)` + // reference. + const gradientId = `gauge-gradient-${useId().replace(/:/g, "")}`; - const [valueItem] = data as ChartDataItem[]; + const [valueItem] = data as GaugeChartDataItem[]; const clampedValue = Math.min(valueItem.value, maxValue); + // Scoped to the PieChart below: the gradient treatment paints its arc from + // the variant's ramp and never reads the data item's color. + const arcColor = + valueItem.color ?? + getGaugeStatusColor(theme, (clampedValue / maxValue) * 100); const gaugeData = [ // Main Bar { value: (clampedValue / maxValue) * 100, - fill: valueItem.color, + fill: arcColor, }, // Background Bar { @@ -114,6 +232,81 @@ export const GaugeChart = ({ ); + if (variant) { + const config = getGaugeChartGradient(variant); + const strokeThickness = width * GRADIENT_STROKE_RATIO; + const arcRadius = + (Math.min(width, height) - strokeThickness) / 2 - GRADIENT_EDGE_PADDING; + const valueSweep = (clampedValue / maxValue) * GRADIENT_SWEEP_ANGLE; + + return ( + +

    +
    + + + {/* Figma runs the ramp horizontally across the value arc's own + bounding box, which is what objectBoundingBox units give the + stroked arc. */} + + + + + + + {valueSweep > 0 && ( + + )} + + + {Math.round(valueItem.value)} + % + + + {customLabelComponent && customLabelComponent} + +
    + + ); + } + return (
    diff --git a/packages/open-ui-kit/src/charts/gauge-chart/styles.ts b/packages/open-ui-kit/src/charts/gauge-chart/styles.ts index e9cf38dd..efabac4b 100644 --- a/packages/open-ui-kit/src/charts/gauge-chart/styles.ts +++ b/packages/open-ui-kit/src/charts/gauge-chart/styles.ts @@ -6,6 +6,15 @@ import type { CSSProperties } from "react"; import type { Theme } from "@mui/material/styles"; +import { + blue300, + blue500, + green500, + grey200, + greyAlpha40, + lightOrange500, + midnightGradientStops as stops, +} from "@/theme/style/color-palette"; export const gaugeWrapper = ({ height, @@ -35,3 +44,117 @@ export const boxStyle = { left: "50%", transform: "translateX(-50%)", }; + +/** + * Arc color for the default gauge when the data item does not name one — the + * status ramp the `States` widget paints across its 100 / 75 / 50 / 25 gauges. + * + * Each band runs from its threshold up to the next, so the ramp holds for any + * `value` / `maxValue` pair rather than only those four readings. The bounds + * sit one point above each widget reading so that 75, 50, and 25 land in the + * band below — the widget's own colors — rather than topping out their band: + * + * | Filled | Token | Widget reading | + * | ------- | ------------------------------- | -------------- | + * | `>= 76` | `Success/Background/Default` | 100 | + * | `>= 51` | `Warning/Background/Default` | 75 | + * | `>= 26` | `Severe-Warning/Border/Default` | 50 | + * | `< 26` | `Negative/Background/Default` | 25 | + * + * The gradient treatment replaces the arc with its own ramp, so this applies + * to the default gauge only. + */ +export const getGaugeStatusColor = (theme: Theme, filledPercent: number) => { + const { vars } = theme.palette; + + if (filledPercent >= 76) return vars.successBackgroundDefault; + if (filledPercent >= 51) return vars.warningBackgroundDefault; + if (filledPercent >= 26) return vars.severeWarningBorderDefault; + + return vars.negativeBackgroundDefault; +}; + +/** + * Design-approved gauge ramps for the gradient treatment. + * + * Each key is the `gradient-token` swatch label on the matching widget in + * Figma `Gauge Chart` (274417:44466), and the paired `Solid` swatch is the + * ambient glow behind the value: + * + * | Key | Figma token | Arc stops | Glow | + * | ------- | ---------------------------- | ---------------------------------- | ---------------- | + * | `amber` | `Gradient/Gauge-Arc-Amber` | FFAE4C 100% → FFAE4C 78% | `lightOrange500` | + * | `teal` | `Gradient/Gauge-Arc-Teal` | 29FCC4 → 00AF2F | `green500` | + * | `blue` | `Gradient/Icon-Subtract-Blue`| 5096FF → `blue500` | `blue300` | + */ +export type GaugeChartGradient = "amber" | "teal" | "blue"; + +/** Track ring under the gradient arc: the frame's `3C4551` at 40%. */ +export const GAUGE_GRADIENT_TRACK_COLOR = greyAlpha40; + +/** + * Arc stops and glow for a gauge ramp. The stops are raw palette values + * rather than the `gradientGaugeArc*` theme vars because the arc is an SVG + * stroke — a CSS gradient string cannot feed `` stops — and + * the ramp itself is Midnight-only, so design has not diverged it per theme. + */ +export const getGaugeChartGradient = ( + gradient: GaugeChartGradient, +): { from: string; to: string; toOpacity: number; glow: string } => { + switch (gradient) { + case "teal": + return { + from: stops.gaugeArcTealStart, + to: stops.gaugeArcTealEnd, + toOpacity: 1, + glow: green500, + }; + case "blue": + return { + from: stops.iconSubtractBlue, + to: blue500, + toOpacity: 1, + glow: blue300, + }; + case "amber": + default: + return { + from: stops.gaugeArcAmber, + to: stops.gaugeArcAmber, + toOpacity: 0.78, + glow: lightOrange500, + }; + } +}; + +/** + * Ambient glow behind the gauge value — Figma `Ellipse 1940`: an 82 × 57.5 + * ellipse with a 65px gaussian blur, centered ~44px below the ring center at + * the frame's 171.7px gauge. All lengths scale with the gauge width. + */ +export const gaugeGlow = (color: string, width: number): CSSProperties => ({ + position: "absolute", + left: "50%", + top: "50%", + width: `${width * (164 / 171.704)}px`, + height: `${width * (115 / 171.704)}px`, + transform: `translate(-50%, calc(-50% + ${width * (44 / 171.704)}px))`, + borderRadius: "50%", + background: color, + filter: `blur(${width * (65.019 / 171.704)}px)`, + pointerEvents: "none", +}); + +/** Gauge value in the gradient treatment: 62.4px Inter Medium at 171.7px. */ +export const gaugeGradientValue = (width: number): CSSProperties => ({ + fontSize: `${width * (62.418 / 171.704)}px`, + fontWeight: 500, + lineHeight: 1.1, +}); + +/** The `%` suffix: 41.6px at 171.7px, in the frame's muted `grey200`. */ +export const gaugeGradientSuffix = (width: number): CSSProperties => ({ + fontSize: `${width * (41.612 / 171.704)}px`, + fontWeight: 500, + color: grey200, +}); diff --git a/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx b/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx index 86a226a9..3408a20e 100644 --- a/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx +++ b/packages/open-ui-kit/src/charts/spider-chart/__tests__/spider-chart.test.tsx @@ -14,9 +14,28 @@ import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; +import { midnightTheme } from "@/theme/midnight/midnight-theme"; +import { + blue500, + blueAlpha40, + lightAlphaOrange40, + midnightGradientStops, + night700, + purpleAlpha40, +} from "@/theme/style/color-palette"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; +import CustomGradientRadar from "../components/custom-gradient-radar"; import { SpiderChart } from "../components/spider-chart"; -import type { ExtendedDataPoint, RadarType } from "../types/spider-chart.types"; +import { + getSpiderChartGradient, + SPIDER_GRADIENT_DOT_RADIUS, + SPIDER_GRADIENT_STROKE_WIDTH, +} from "../styles/spider-chart.styles"; +import type { + ExtendedDataPoint, + RadarType, + SpiderChartGradient, +} from "../types/spider-chart.types"; jest.mock("recharts", () => ({ ResponsiveContainer: ({ @@ -68,12 +87,16 @@ jest.mock("recharts", () => ({ dataKey, fill, name, + shape, + stroke, strokeWidth, }: { color: string; dataKey: string; fill: string; name: string; + shape: unknown; + stroke?: string; strokeWidth: number; }) => (
    ({ data-data-key={dataKey} data-fill={fill} data-name={name} + // Recharts clones the shape element with the Radar props, so the dot + // geometry is asserted where it is authored: on the element. + data-dot-radius={ + isValidElement(shape) + ? String((shape.props as { dotRadius?: number }).dotRadius ?? "") + : "" + } + data-dot-fill={ + isValidElement(shape) + ? ((shape.props as { dotFill?: string }).dotFill ?? "") + : "" + } + data-stroke={stroke ?? ""} data-stroke-width={strokeWidth} data-testid="radar-series" /> @@ -234,6 +270,241 @@ describe("SpiderChart", () => { }); }); + // Figma: `Spider Chart` (274417:44533), one widget per `gradient-token` + // swatch. Stroke and dot colors are the widget's own data polygon and + // vertex rings. + describe("gradient treatment", () => { + const variants: [SpiderChartGradient, string, string, string, string][] = [ + [ + "pinkPurple", + midnightTheme.palette.gradients.gradientDataVizPinkPurple, + midnightTheme.palette.vars.infoBorderDefault, + purpleAlpha40, + midnightTheme.palette.vars.infoBorderDefault, + ], + [ + "cyanBlue", + midnightTheme.palette.gradients.gradientDataVizCyanBlue, + midnightTheme.palette.vars.accentHDefault, + blueAlpha40, + midnightTheme.palette.vars.accentHDefault, + ], + [ + "orangeGold", + midnightTheme.palette.gradients.gradientDataVizOrangeGold, + midnightTheme.palette.vars.warningBorderDefault, + lightAlphaOrange40, + midnightTheme.palette.vars.warningBorderDefault, + ], + [ + "blueDark", + midnightTheme.palette.gradients.gradientDataVizBlueDark, + midnightTheme.palette.vars.interactivePrimaryDefaultActive, + "rgba(185, 171, 239, 0.76)", + // The one variant whose dot rings diverge from the outline color: the + // frame rings them a step lighter (558BFF against the outline's + // 1469CC in Midnight). + midnightTheme.palette.vars.interactivePrimaryDefaultDefault, + ], + ]; + + it.each(variants)( + "resolves the %s label to its theme gradient and accents", + (gradient, background, stroke, dotFill, dotStroke) => { + expect(getSpiderChartGradient(midnightTheme, gradient)).toEqual({ + background, + stroke, + dotFill, + dotStroke, + }); + }, + ); + + it("fills, outlines and dots the radar from the named ramp", () => { + renderSpiderChart(false, { + radars: [ + { + name: "Concierge Agent", + dataKey: "variableA", + gradient: "cyanBlue", + }, + ], + }); + + expect(screen.getByTestId("radar-series")).toMatchObject({ + dataset: expect.objectContaining({ + color: lightTheme.palette.gradients.gradientDataVizCyanBlue, + stroke: lightTheme.palette.vars.accentHDefault, + strokeWidth: String(SPIDER_GRADIENT_STROKE_WIDTH), + dotRadius: String(SPIDER_GRADIENT_DOT_RADIUS), + dotFill: blueAlpha40, + }), + }); + }); + + it("lets explicit background, stroke and dot props win over the ramp", () => { + renderSpiderChart(false, { + radars: [ + { + name: "Coverage", + dataKey: "variableA", + gradient: "cyanBlue", + background: "linear-gradient(90deg, red 0%, blue 100%)", + stroke: "#ff0000", + dot: false, + }, + ], + }); + + expect(screen.getByTestId("radar-series")).toMatchObject({ + dataset: expect.objectContaining({ + color: "linear-gradient(90deg, red 0%, blue 100%)", + stroke: "#ff0000", + dotRadius: "0", + }), + }); + }); + + it("leaves radars without a gradient unstroked and undotted", () => { + renderSpiderChart(); + + expect(screen.getByTestId("radar-series")).toMatchObject({ + dataset: expect.objectContaining({ + stroke: "", + strokeWidth: "0", + dotRadius: "", + }), + }); + }); + }); + + // The suite mocks recharts, so the shape never renders through `Radar`. + describe("gradient radar shape", () => { + const points = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ]; + const path = "M0 0 L10 0 L10 10Z"; + + const renderShape = ( + props: Partial> = {}, + ) => + render( + , + ); + + it("clips the ramp to the polygon, outlines it and rings each vertex", () => { + const { container } = renderShape(); + + const clip = container.querySelector("clipPath"); + expect(clip?.querySelector("path")).toHaveAttribute("d", path); + + const foreignObject = container.querySelector("foreignObject"); + expect(foreignObject).toHaveAttribute( + "clip-path", + `url(#${clip?.getAttribute("id")})`, + ); + // The ramp spans the polygon's bounding box, matching Figma's + // object-bounding-box gradient fill. + expect(foreignObject).toHaveAttribute("x", "0"); + expect(foreignObject).toHaveAttribute("y", "0"); + expect(foreignObject).toHaveAttribute("width", "10"); + expect(foreignObject).toHaveAttribute("height", "10"); + expect(foreignObject?.querySelector("div")).toHaveStyle({ + background: midnightTheme.palette.gradients.gradientDataVizCyanBlue, + }); + + const outline = container.querySelector("svg > path"); + expect(outline).toHaveAttribute("d", path); + expect(outline).toHaveAttribute("stroke", night700); + expect(outline).toHaveAttribute( + "stroke-width", + String(SPIDER_GRADIENT_STROKE_WIDTH), + ); + expect(outline).toHaveAttribute("fill", "none"); + + const dots = container.querySelectorAll("circle"); + expect(dots).toHaveLength(points.length); + expect(dots[0]).toHaveAttribute("cx", "0"); + expect(dots[0]).toHaveAttribute("cy", "0"); + expect(dots[0]).toHaveAttribute("r", String(SPIDER_GRADIENT_DOT_RADIUS)); + expect(dots[0]).toHaveAttribute("fill", blueAlpha40); + expect(dots[0]).toHaveAttribute("stroke", night700); + expect(dots[0]).toHaveAttribute( + "stroke-width", + String(SPIDER_GRADIENT_STROKE_WIDTH), + ); + }); + + it("falls back to solid outline-colored dots without a dotFill", () => { + const { container } = renderShape({ dotFill: undefined }); + + expect(container.querySelector("circle")).toHaveAttribute( + "fill", + night700, + ); + }); + + // Blue-dark rings its dots in the ramp's 3B82F6 stop, not the outline + // color. + it("lets dotStroke diverge from the outline color", () => { + const { container } = renderShape({ + stroke: blue500, + dotStroke: midnightGradientStops.dataVizBlue, + }); + + expect(container.querySelector("svg > path")).toHaveAttribute( + "stroke", + blue500, + ); + expect(container.querySelector("circle")).toHaveAttribute( + "stroke", + midnightGradientStops.dataVizBlue, + ); + }); + + it("keeps the outline but drops the dots at a zero radius", () => { + const { container } = renderShape({ dotRadius: 0 }); + + expect(container.querySelector("svg > path")).toBeInTheDocument(); + expect(container.querySelectorAll("circle")).toHaveLength(0); + }); + + it("renders nothing without points", () => { + const { container } = renderShape({ points: [] }); + + expect(container).toBeEmptyDOMElement(); + }); + + // Four gradient radars share one page in the Figma frame, so a constant + // clip id would let them resolve each other's polygon. + it("gives each instance its own clip id", () => { + const { container } = render( + <> + + + , + ); + + const ids = [...container.querySelectorAll("clipPath")].map((clip) => + clip.getAttribute("id"), + ); + + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + expect(ids.every((id) => id && !id.includes(":"))).toBe(true); + }); + }); + it("uses a safe domain when data has no numeric values", () => { renderSpiderChart(false, { data: [{ subject: "Empty" }], diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-gradient-radar.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-gradient-radar.tsx new file mode 100644 index 00000000..ba416bf5 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-gradient-radar.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useId } from "react"; + +/** + * Radar polygon for the gradient treatment — Figma `Spider Chart` + * (274417:44533). + * + * Three layers, painted bottom up: + * 1. the data-viz ramp, clipped to the polygon. A CSS gradient cannot be an + * SVG `fill`, so the ramp is painted by a `foreignObject` div and masked + * with a `clipPath`, the same technique as `custom-conical-gradient` — + * except the div is sized to the polygon's bounding box, not the chart. + * Figma fills the vector with an object-bounding-box gradient (pink for + * roughly the top fifth of the shape, purple below), so the ramp must + * span the shape itself or the polygon only ever shows the middle slice + * of the chart-wide ramp. + * 2. the outline, a stroked copy of the same path. + * 3. a ring on each data vertex: a translucent `dotFill` disc under a + * `dotStroke` ring (the outline color unless the frame diverges, as + * blue-dark does) at the outline's stroke width, exactly the frame's + * Ellipse nodes. + * + * Recharts clones this element with the `Radar` props, so every value below + * arrives from the `Radar` element rather than from the caller — which is why + * the rings are keyed on `dotRadius`/`dotFill` rather than a `dot` flag: + * `dot` is a `Radar` prop of its own, and Recharts would draw a second set. + * + * The clip id is derived from `useId` rather than a constant, so several + * gradient radars on one page — a series comparison, or the four variants of + * the frame side by side — do not resolve each other's clip path. + */ +const CustomGradientRadar = ({ + points = [], + color, + stroke, + strokeWidth = 0, + dotRadius = 0, + dotFill, + dotStroke, +}: { + points?: { x: number; y: number }[]; + color?: string; + stroke?: string; + strokeWidth?: number; + dotRadius?: number; + dotFill?: string; + dotStroke?: string; +}) => { + // `useId` wraps its value in colons, which are not valid in a `url(#...)` + // reference. + const clipId = `spider-gradient-radar-${useId().replace(/:/g, "")}`; + + if (!points.length) return null; + + const path = + points.map((p, i) => (i ? `L${p.x} ${p.y}` : `M${p.x} ${p.y}`)).join(" ") + + "Z"; + + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + + return ( + + + + + +
    + + {stroke && strokeWidth > 0 && ( + + )} + {stroke && + dotRadius > 0 && + points.map((p, i) => ( + + ))} + + ); +}; + +export default CustomGradientRadar; diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx index 72eca621..4ce42f0c 100644 --- a/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx +++ b/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx @@ -15,12 +15,18 @@ import { } from "recharts"; import { useTheme, type Theme } from "@mui/material/styles"; import CustomConicalGradient from "./custom-conical-gradient"; +import CustomGradientRadar from "./custom-gradient-radar"; import CustomLines from "./custom-lines"; import CustomPolarGrid from "./custom-polar-grid"; import CustomLabels from "./custom-radar-labels"; import CustomRadarTick from "./custom-radar-tick"; import CustomTooltip from "./custom-tooltip"; -import { StyledRadarChart } from "../styles/spider-chart.styles"; +import { + getSpiderChartGradient, + SPIDER_GRADIENT_DOT_RADIUS, + SPIDER_GRADIENT_STROKE_WIDTH, + StyledRadarChart, +} from "../styles/spider-chart.styles"; import { ExtendedDataPoint, SpiderChartProps, @@ -129,21 +135,51 @@ export const SpiderChart = ({ .map((_, i) => i * angleStep + computedAngleOffset)} /> - {radars.map((radar, index) => ( - - ))} + {radars.map((radar, index) => { + // The gradient treatment only supplies defaults: an explicit + // `background`, `stroke` or `dot` on the radar still wins. + const gradient = radar.gradient + ? getSpiderChartGradient(theme, radar.gradient) + : undefined; + const stroke = radar.stroke ?? gradient?.stroke; + const showDots = radar.dot ?? Boolean(gradient); + + return ( + + ) : ( + CustomConicalGradient + )) + } + /> + ); + })} {showTooltip && ( = { }, radars: { control: false, - description: "Radar series definitions and token colors.", + description: + "Radar series definitions and token colors. Set `gradient` on a series for the design-approved data-viz ramps.", }, scale: { control: "number", @@ -187,3 +189,78 @@ export const CustomTooltipContent: Story = { export const WithoutTooltip: Story = { render: () => , }; + +/* + * Gradient treatment — Figma `Spider Chart` (274417:44533). + * + * The frame scores one agent per widget on the same six axes, and pairs each + * widget with the `gradient-token` swatch its radar is filled from. The four + * stories below keep that pairing: the agent names and axes are the frame's, + * and each `gradient` key is the swatch label. + */ +const agentData: ExtendedDataPoint[] = [ + { subject: "Cost", variableA: 82 }, + { subject: "Tool Utilization Accuracy", variableA: 74 }, + { subject: "Response Completeness", variableA: 61 }, + { subject: "Intent Recognition Accuracy", variableA: 88 }, + { subject: "Answer Relevancy", variableA: 70 }, + { subject: "Groundedness", variableA: 79 }, +]; + +// Offsets follow the chart's sorted axis order (Answer Relevancy at the top, +// clockwise). The two long labels land on the left, where start-anchored text +// would run into the polygon, so they are pulled out and past the grid corner. +const agentLabelOffsets = [ + { cx: 34, cy: 10 }, // Answer Relevancy + { cx: -4, cy: 12 }, // Cost + { cx: -10, cy: -22 }, // Groundedness + { cx: -30, cy: 0 }, // Intent Recognition Accuracy + { cx: -48, cy: 28 }, // Response Completeness + { cx: -48, cy: -26 }, // Tool Utilization Accuracy +]; + +const gradientVariants: { label: string; gradient: SpiderChartGradient }[] = [ + { label: "Concierge Agent", gradient: "pinkPurple" }, + { label: "Scheduling Agent", gradient: "cyanBlue" }, + { label: "Moderator Agent", gradient: "orangeGold" }, + { label: "Itinerary Planner", gradient: "blueDark" }, +]; + +const GradientVariant = ({ + label, + gradient, +}: { + label: string; + gradient: SpiderChartGradient; +}) => ( + + ({ color: theme.palette.vars.baseTextDefault })} + > + {label} + + + + + +); + +/** + * The four design-approved data-viz ramps. Each fills the radar with its + * `Gradient/Data-Viz-*` token, outlines it in the ramp's paired accent, and + * rings every data vertex in the same accent. + */ +export const Gradient: Story = { + render: () => ( + + {gradientVariants.map((variant) => ( + + ))} + + ), +}; diff --git a/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts b/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts index 30f69580..0f63b460 100644 --- a/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts +++ b/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts @@ -4,8 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { styled } from "@mui/material"; +import { alpha, styled, type Theme } from "@mui/material"; import type { ComponentType, HTMLAttributes } from "react"; +import { + blueAlpha40, + lightAlphaOrange40, + purpleAlpha40, +} from "@/theme/style/color-palette"; +import type { SpiderChartGradient } from "../types/spider-chart.types"; export const StyledTooltip = styled("div")(({ theme }) => ({ width: "max-content", @@ -16,6 +22,81 @@ export const StyledTooltip = styled("div")(({ theme }) => ({ padding: "2px 8px", })) as ComponentType>; +/** + * Outline width and vertex-dot geometry for the gradient treatment. + * + * Figma `Spider Chart` (274417:44533) is drawn at 1.333× (the widget border + * reads 1.333px): every data polygon and dot ring carries a 2.678px stroke, + * i.e. 2px at 1×. The dots read 9.27px across, i.e. 6.95px at 1× — a ring + * whose inner radius is 2.5 under the shared 2px stroke. + */ +export const SPIDER_GRADIENT_STROKE_WIDTH = 2; +export const SPIDER_GRADIENT_DOT_RADIUS = 2.5; + +/** + * Gradient treatment — Figma `Spider Chart` (274417:44533). + * + * The frame pairs each Summary widget with a `gradient-token` swatch. The + * swatch documents the fill ramp — all four labels already exist as theme + * gradients — while the outline and dot colors are read off the widget's own + * data polygon and vertex rings, and every one is a named palette value: + * + * | Ramp | Outline (`stroke`) | Dot fill | Dot ring | + * | ------------ | ---------------------------------- | ----------------------------- | -------- | + * | `pinkPurple` | `infoBorderDefault` | `purpleAlpha40` (B76DFF66) | outline | + * | `cyanBlue` | `accentHDefault` | `blueAlpha40` (0051AF66) | outline | + * | `orangeGold` | `warningBorderDefault` | `lightAlphaOrange40` | outline | + * | `blueDark` | `interactivePrimaryDefaultActive` | B9ABEF @ 76%, no palette name | `interactivePrimaryDefaultDefault` | + * + * Blue-dark is the one variant whose dot rings are not the outline color. The + * frame (OXP `Itinerary Planner`) rings them a step lighter than the outline — + * 558BFF against 1469CC in Midnight — so the two take the Default and Active + * ends of the same Interactive/Primary ramp rather than one shared value. + * + * The outlines are theme vars, so they retone with the theme rather than + * staying pinned to the Midnight frame. The dot fills are still fixed palette + * alphas: design has not diverged those per theme. + */ +export const getSpiderChartGradient = ( + theme: Theme, + gradient: SpiderChartGradient, +): { background: string; stroke: string; dotFill: string; dotStroke: string } => { + const gradients = theme.palette.gradients; + const { vars } = theme.palette; + + switch (gradient) { + case "cyanBlue": + return { + background: gradients.gradientDataVizCyanBlue, + stroke: vars.accentHDefault, + dotFill: blueAlpha40, + dotStroke: vars.accentHDefault, + }; + case "orangeGold": + return { + background: gradients.gradientDataVizOrangeGold, + stroke: vars.warningBorderDefault, + dotFill: lightAlphaOrange40, + dotStroke: vars.warningBorderDefault, + }; + case "blueDark": + return { + background: gradients.gradientDataVizBlueDark, + stroke: vars.interactivePrimaryDefaultActive, + dotFill: alpha("#b9abef", 0.76), + dotStroke: vars.interactivePrimaryDefaultDefault, + }; + case "pinkPurple": + default: + return { + background: gradients.gradientDataVizPinkPurple, + stroke: vars.infoBorderDefault, + dotFill: purpleAlpha40, + dotStroke: vars.infoBorderDefault, + }; + } +}; + export const StyledRadarChart = styled("div")({ width: "100%", height: "100%", diff --git a/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts b/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts index 2445f4dd..98987a9d 100644 --- a/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts +++ b/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts @@ -18,6 +18,26 @@ export type ExtendedDataPoint = { variableA?: number; } & DataPoint; +/** + * Design-approved data-viz ramps for a radar series. + * + * Each key is the `gradient-token` label on the matching widget in Figma + * `Spider Chart` (274417:44533), camelCased the same way `GradientVarsType` + * camelCases the Figma path: + * + * | Key | Figma token | Theme token | + * | ------------ | ------------------------------- | ---------------------------- | + * | `pinkPurple` | `Gradient/Data-Viz-Pink-Purple` | `gradientDataVizPinkPurple` | + * | `cyanBlue` | `Gradient/Data-Viz-Cyan-Blue` | `gradientDataVizCyanBlue` | + * | `orangeGold` | `Gradient/Data-Viz-Orange-Gold` | `gradientDataVizOrangeGold` | + * | `blueDark` | `Gradient/Data-Viz-Blue-Dark` | `gradientDataVizBlueDark` | + */ +export type SpiderChartGradient = + | "pinkPurple" + | "cyanBlue" + | "orangeGold" + | "blueDark"; + export type RadarType = { /** Legend and tooltip name for the radar series. */ name: string; @@ -27,6 +47,20 @@ export type RadarType = { fill?: string; /** CSS gradient or tokenized background used by the custom radar shape. */ background?: string; + /** + * Applies the gradient treatment: the named data-viz ramp fills the radar + * area, the ramp's paired accent draws the outline, and each data vertex + * gets a ring in the same accent over a translucent fill. Sets + * `background`, `stroke` and `dot` unless those are given explicitly. + */ + gradient?: SpiderChartGradient; + /** + * Outline color for the radar polygon. Defaults to the accent paired with + * `gradient`; without either, the polygon has no outline. + */ + stroke?: string; + /** Renders a ring at each data vertex, stroked in the outline color. */ + dot?: boolean; /** Optional Recharts shape override for the radar polygon. */ shape?: ReactElement; }; diff --git a/packages/open-ui-kit/src/colors/colors.mdx b/packages/open-ui-kit/src/colors/colors.mdx index 9fe4298c..7f46b08e 100644 --- a/packages/open-ui-kit/src/colors/colors.mdx +++ b/packages/open-ui-kit/src/colors/colors.mdx @@ -1,6 +1,9 @@ import { Meta } from "@storybook/addon-docs/blocks"; import { DocsHeader } from "storybook/components/docs-header.stories"; -import { ColorPaletteSection, paletteToSwatches } from "./color-palette-section"; +import { + ColorPaletteSection, + paletteToSwatches, +} from "./color-palette-section"; import { surfaceLightPalette, surfaceDarkPalette, @@ -174,6 +177,7 @@ import {
    +
    diff --git a/packages/open-ui-kit/src/components/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/__tests__/activity-timeline.test.tsx b/packages/open-ui-kit/src/components/activity-timeline/__tests__/activity-timeline.test.tsx index 4e8518f5..aea6afcf 100644 --- a/packages/open-ui-kit/src/components/activity-timeline/__tests__/activity-timeline.test.tsx +++ b/packages/open-ui-kit/src/components/activity-timeline/__tests__/activity-timeline.test.tsx @@ -10,6 +10,7 @@ import "@testing-library/jest-dom"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; +import { midnightTheme } from "@/theme/midnight/midnight-theme"; import { ActivityTimeline } from "../components/activity-timeline"; import { ActivityTimelineDot } from "../components/activity-timeline-dot"; import { getActivityTimelineDotStyle } from "../styles"; @@ -201,6 +202,127 @@ describe("ActivityTimeline", () => { }); }); + describe("gradient variant", () => { + const keyEvents = [ + { + status: ActivityTimelineStepStatus.Complete, + title: "Newest", + time: "11:40", + }, + { + status: ActivityTimelineStepStatus.InProgress, + title: "Second", + time: "8:45", + }, + { + status: ActivityTimelineStepStatus.Neutral, + title: "Third", + time: "7:42", + }, + { + status: ActivityTimelineStepStatus.Error, + title: "Fourth", + time: "7:02", + }, + { + status: ActivityTimelineStepStatus.Inactive, + title: "Oldest", + time: "6:15", + }, + ]; + + const renderKeyEvents = (steps = keyEvents) => + render( + + + , + ); + + const slotOpacity = (title: string) => + screen.getByText(title).closest(".MuiTimelineOppositeContent-root"); + + it("renders a time beside every step", () => { + renderKeyEvents(); + expect(screen.getByText("11:40")).toBeInTheDocument(); + expect(screen.getByText("6:15")).toBeInTheDocument(); + }); + + // Figma holds every step above the oldest two at full strength, then drops + // to 50% and 30% — it is not a ramp spread across the whole list. + it("fades only the oldest two steps", () => { + renderKeyEvents(); + + expect(slotOpacity("Newest")).toHaveStyle({ opacity: "1" }); + expect(slotOpacity("Second")).toHaveStyle({ opacity: "1" }); + expect(slotOpacity("Third")).toHaveStyle({ opacity: "1" }); + expect(slotOpacity("Fourth")).toHaveStyle({ opacity: "0.5" }); + expect(slotOpacity("Oldest")).toHaveStyle({ opacity: "0.3" }); + }); + + it("keeps the newest step solid in a two-step list", () => { + renderKeyEvents(keyEvents.slice(0, 2)); + + expect(slotOpacity("Newest")).toHaveStyle({ opacity: "1" }); + expect(slotOpacity("Second")).toHaveStyle({ opacity: "0.3" }); + }); + + // One rail for the list, so the ramp does not restart at every dot. + it("draws no per-step connector", () => { + const { container } = renderKeyEvents(); + expect( + container.querySelectorAll(".MuiTimelineConnector-root"), + ).toHaveLength(0); + }); + + it("leaves the default variant on per-step connectors", () => { + const { container } = renderWithTheme(); + expect( + container.querySelectorAll(".MuiTimelineConnector-root").length, + ).toBeGreaterThan(0); + }); + + const rail = (container: HTMLElement) => + container.querySelector(".MuiTimeline-root")?.className; + + // A lone step has nothing to connect, so it gets no dangling line — the + // default variant leaves a single step without a connector too. + it("drops the rail for a single step", () => { + const { container } = renderKeyEvents(keyEvents.slice(0, 1)); + expect(rail(container)).not.toBe(rail(renderKeyEvents().container)); + }); + + it("renders an empty step list without throwing", () => { + expect(() => renderKeyEvents([])).not.toThrow(); + }); + + it("keeps the time column and the rail on one width", () => { + const { container } = renderKeyEvents(); + const time = container.querySelectorAll(".MuiTimelineContent-root")[0]; + expect(time).toHaveStyle({ flex: "0 0 48px" }); + }); + + it("carries the Figma rail ramp on the Midnight theme", () => { + expect(midnightTheme.palette.gradients.gradientCardHighlightRadial).toBe( + "linear-gradient(180deg, #ffffff 0%, rgba(153, 153, 153, 0) 100%)", + ); + }); + + // Figma's dot export ends the ramp one box-width from the corner, so the + // glow must not fall back to the CSS default of `farthest-corner`. + it("closes the dot glow on the far side, not the far corner", () => { + const g = midnightTheme.palette.gradients; + for (const glow of [ + g.gradientGlowGreen, + g.gradientGlowOrange, + g.gradientGlowRed, + ]) { + expect( + glow.startsWith("radial-gradient(circle farthest-side at 0% 0%,"), + ).toBe(true); + } + }); + }); + describe("dark theme token coverage", () => { it("renders status steps in dark mode without throwing", () => { renderWithTheme(, true); diff --git a/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx b/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx index c44e38d9..52d4a8af 100644 --- a/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx +++ b/packages/open-ui-kit/src/components/activity-timeline/components/activity-timeline-dot.tsx @@ -6,10 +6,11 @@ import DoneIcon from "@mui/icons-material/Done"; import CloseIcon from "@mui/icons-material/Close"; -import { CircularProgress, useTheme, type BoxProps } from "@mui/material"; +import { Box, CircularProgress, useTheme, type BoxProps } from "@mui/material"; import { ActivityTimelineStepStatus } from "../types"; import { getActivityTimelineDotStyle } from "../styles"; -import { StyledTimelineDotRoot } from "./elements"; +import { getStepDotColor, getStepGlow } from "../utils/utils"; +import { GLOW_DOT_SIZE, StyledTimelineDotRoot } from "./elements"; export interface ActivityTimelineDotProps extends BoxProps { /** Uses percent-driven progress rendering instead of status icons. */ @@ -18,12 +19,15 @@ export interface ActivityTimelineDotProps extends BoxProps { percent?: number; /** Visual state for the dot. */ status?: ActivityTimelineStepStatus; + /** Renders a small solid dot with a radial glow (gradient variant). */ + glow?: boolean; } export const ActivityTimelineDot = ({ automaticProgress = false, percent, status = ActivityTimelineStepStatus.Inactive, + glow = false, ...props }: ActivityTimelineDotProps) => { const theme = useTheme(); @@ -33,6 +37,24 @@ export const ActivityTimelineDot = ({ const isInProgress = effectiveStatus === ActivityTimelineStepStatus.InProgress; + if (glow) { + // Gradient statuses fill the dot with the radial glow; the rest are solid. + const dotFill = + getStepGlow(status, theme) ?? getStepDotColor(status, theme); + return ( + + + + ); + } + return ( { const theme = useTheme(); const isMedium = size === "medium"; + // Older steps fade down the list. + const stepOpacity = useCallback( + (stepIdx: number): number => { + const fromOldest = steps.length - 1 - stepIdx; + // The newest step stays solid however short the list is. + if (stepIdx === 0 || fromOldest >= OLDEST_STEP_OPACITY.length) { + return 1; + } + return OLDEST_STEP_OPACITY[fromOldest]; + }, + [steps.length], + ); + const setPercent = useCallback( (stepIdx: number): number => { if (steps.length <= 1) { @@ -40,6 +61,55 @@ export const ActivityTimeline = ({ [steps.length], ); + if (variant === "gradient") { + return ( + 1} + sx={[ + ...(Array.isArray(props.sx) ? props.sx : props.sx ? [props.sx] : []), + ]} + > + {steps.map((step, index) => ( + // Fade each slot, not the item, so the rail keeps its own ramp. + + + + {step.titleStartIcon} + {step.title} + + + + + + + {step.time && ( + + {step.time} + + )} + + + ))} + + ); + } + return ( {step.content} 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 de08fb42..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 @@ -10,10 +10,12 @@ import { TimelineConnector, TimelineContent, TimelineItem, + TimelineOppositeContent, TimelineSeparator, type TimelineConnectorProps, type TimelineContentProps, type TimelineItemProps, + type TimelineOppositeContentProps, type TimelineProps, type TimelineSeparatorProps, } from "@mui/lab"; @@ -30,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, { @@ -55,7 +60,75 @@ export const StyledTimelineDotRoot = styled(Box)(() => ({ alignItems: "center", display: "inline-flex", justifyContent: "center", - margin: "-1px 0px", + margin: `${DOT_ROOT_TOP_MARGIN}px 0px`, position: "relative", zIndex: 1, })) as ComponentType; + +/** Gradient-variant geometry, from Figma `Key Events` (274455:53816). */ +export const GLOW_DOT_SIZE = 7.142; +const TIME_COLUMN_WIDTH = 48; +const RAIL_WIDTH = 1; +const FIRST_DOT_CENTER = + SEPARATOR_TOP_MARGIN + DOT_ROOT_TOP_MARGIN + GLOW_DOT_SIZE / 2; + +/* + * Gradient variant: one rail for the whole list, not a connector per step. + * + * Figma draws the line as a single vector (274455:53823) with a white -> + * transparent stroke, so the fade has to run unbroken from the first dot to the + * bottom. Per-item connectors would restart the ramp at every dot. + * + * The rail is pinned to the dot axis — the time column plus half a dot in from + * the inline end — because that is where the separator sits between the two + * content slots. It stays behind the dots, which paint at `zIndex: 1`. + * + * `showRail` is off for a single step: there is nothing to connect, and the + * default variant leaves a lone step without a connector too. + */ +export const StyledGradientTimeline = styled(StyledTimeline, { + shouldForwardProp: (prop) => prop !== "showRail", +})<{ showRail?: boolean }>(({ theme, showRail }) => ({ + position: "relative", + ...(showRail && { + "&::before": { + content: '""', + position: "absolute", + top: `${FIRST_DOT_CENTER}px`, + bottom: 0, + // Logical, not `right`, so the rail follows the flex row in RTL. + insetInlineEnd: `${TIME_COLUMN_WIDTH + GLOW_DOT_SIZE / 2 - RAIL_WIDTH / 2}px`, + width: `${RAIL_WIDTH}px`, + background: theme.palette.gradients?.gradientCardHighlightRadial, + // Figma runs the ramp over 1065.96 on a 720.86 rail, so the line still + // has about a third of its opacity left at the bottom, not zero. + backgroundSize: "100% 147.9%", + backgroundRepeat: "no-repeat", + zIndex: 0, + }, + }), +})) as ComponentType; + +// Event text sits to the left of the line. +export const StyledTimelineOppositeContent = styled(TimelineOppositeContent)( + () => ({ + flex: 1, + // `minWidth: 0` keeps every row the same width, so the line stays on one axis. + minWidth: 0, + margin: 0, + padding: "0 16px 40px 0", + textAlign: "left", + overflowWrap: "anywhere", + }), +) as ComponentType; + +// Time sits to the right of the line, at a fixed width so the line stays aligned. +// The rail above is positioned off this width, so the two must not drift apart. +export const StyledTimelineTimeContent = styled(TimelineContent)(() => ({ + flex: `0 0 ${TIME_COLUMN_WIDTH}px`, + minWidth: 0, + margin: 0, + padding: "0 0 40px 12px", + textAlign: "left", + whiteSpace: "nowrap", +})) as ComponentType; diff --git a/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx b/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx index f5f294de..0fedd15d 100644 --- a/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx +++ b/packages/open-ui-kit/src/components/activity-timeline/stories/activity-timeline.stories.tsx @@ -159,6 +159,34 @@ const completeProgressSteps: ActivityTimelineStep[] = [ }, ]; +const keyEventsSteps: ActivityTimelineStep[] = [ + { + status: ActivityTimelineStepStatus.Complete, + title: "Treated 29h booking as within 24h window", + time: "11:40", + }, + { + status: ActivityTimelineStepStatus.InProgress, + title: 'cited "silver members can cancel anytime" — not in policy', + time: "8:45", + }, + { + status: ActivityTimelineStepStatus.Neutral, + title: "confirmed $585 / charged $1200 — cabin upgrade applied to both pax", + time: "7:42", + }, + { + status: ActivityTimelineStepStatus.Error, + title: "changed destination LGA → JFK on flight modification", + time: "7:02", + }, + { + status: ActivityTimelineStepStatus.Inactive, + title: "London alert", + time: "7:02", + }, +]; + const StoryPanel = ({ children }: { children: ReactNode }) => ( {children} ); @@ -281,3 +309,15 @@ export const AutomaticProgress: Story = { ), }; + +export const Gradient: Story = { + name: "Gradient (Key Events)", + render: () => ( + + + Key Events + + + + ), +}; diff --git a/packages/open-ui-kit/src/components/activity-timeline/types/index.ts b/packages/open-ui-kit/src/components/activity-timeline/types/index.ts index 7c5ba222..b3947ca7 100644 --- a/packages/open-ui-kit/src/components/activity-timeline/types/index.ts +++ b/packages/open-ui-kit/src/components/activity-timeline/types/index.ts @@ -28,6 +28,8 @@ export interface ActivityTimelineStep { content?: ReactNode; /** Expands accordion content by default when content is provided. */ defaultExpanded?: boolean; + /** Time label shown to the right of the line. Gradient variant only. */ + time?: string; } export interface ActivityTimelineProps extends Omit< @@ -38,6 +40,12 @@ export interface ActivityTimelineProps extends Omit< automaticProgress?: boolean; /** Controls the timeline title typography and vertical spacing. */ size?: "large" | "medium"; - /** Ordered steps rendered in the activity timeline. */ + /** + * `gradient` renders the "Key Events" style: glowing status dots on a single + * line that fades down the list, with each step's `time` beside it. Steps run + * newest first, and the oldest two dim to 50% and 30%. + */ + variant?: "default" | "gradient"; + /** Ordered steps rendered in the activity timeline, newest first. */ steps: ActivityTimelineStep[]; } diff --git a/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts b/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts index 1a5565f1..16e0c283 100644 --- a/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts +++ b/packages/open-ui-kit/src/components/activity-timeline/utils/utils.ts @@ -24,3 +24,41 @@ export const setStepColor = ( return theme.palette.vars?.interactiveTertiaryActive; } }; + +/** Solid center color for a gradient-variant dot, by status. */ +export const getStepDotColor = ( + status: ActivityTimelineStepStatus, + theme: Theme, +): string => { + switch (status) { + case ActivityTimelineStepStatus.Complete: + return theme.palette.vars?.excellentIconDefault; + case ActivityTimelineStepStatus.InProgress: + return theme.palette.vars?.warningIconDefault; + case ActivityTimelineStepStatus.Error: + return theme.palette.vars?.negativeIconDefault; + case ActivityTimelineStepStatus.Neutral: + return theme.palette.vars?.controlIconMedium; + default: + // Figma's inactive dot (274455:53846) is pure white, not the off-white + // `controlIconDefault` the rest of the timeline uses for text. + return theme.palette.vars?.controlIconStrong; + } +}; + +/** Radial glow fill for a gradient dot, or `undefined` when it renders solid. */ +export const getStepGlow = ( + status: ActivityTimelineStepStatus, + theme: Theme, +): string | undefined => { + switch (status) { + case ActivityTimelineStepStatus.Complete: + return theme.palette.gradients?.gradientGlowGreen; + case ActivityTimelineStepStatus.InProgress: + return theme.palette.gradients?.gradientGlowOrange; + case ActivityTimelineStepStatus.Error: + return theme.palette.gradients?.gradientGlowRed; + default: + return undefined; + } +}; diff --git a/packages/open-ui-kit/src/components/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": { diff --git a/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx b/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx index d3bb344d..afe3ca66 100644 --- a/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx +++ b/packages/open-ui-kit/src/components/button/__tests__/button.test.tsx @@ -9,6 +9,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import { ImageGrid } from "@/custom-icons"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; +import { midnightTheme } from "@/theme/midnight/midnight-theme"; import { Button } from "../components/button"; const renderButton = ( @@ -57,17 +58,99 @@ describe("Button", () => { ).not.toThrow(); }); + it("renders gradient variant without throwing", () => { + expect(() => + renderButton({ variant: "gradient", children: "Gradient" }), + ).not.toThrow(); + }); + it("renders outlined variant without throwing", () => { expect(() => renderButton({ variant: "outlined", children: "Outlined" }), ).not.toThrow(); }); + it("renders gradientOutlined variant without throwing", () => { + expect(() => + renderButton({ + variant: "gradientOutlined", + children: "Gradient Outlined", + }), + ).not.toThrow(); + }); + it("renders tertariary variant without throwing", () => { expect(() => renderButton({ variant: "tertariary", children: "Tertiary" }), ).not.toThrow(); }); + + it("applies the MUI variant class for the gradient variants", () => { + const { getByRole, unmount } = renderButton({ + variant: "gradient", + children: "Gradient", + }); + expect(getByRole("button").className).toContain("MuiButton-gradient"); + unmount(); + + const ring = renderButton({ + variant: "gradientOutlined", + children: "Gradient Outlined", + }); + expect(ring.getByRole("button").className).toContain( + "MuiButton-gradientOutlined", + ); + ring.unmount(); + }); + }); + + // The button gradients are deliberately shared across every theme rather than + // overridden per theme, so the variants must look the same everywhere. + describe("gradient variants render on every theme", () => { + const GRADIENT_VARIANTS = ["gradient", "gradientOutlined"] as const; + const MODES = [ + ThemeMode.Light, + ThemeMode.Dark, + ThemeMode.IoC, + ThemeMode.Midnight, + ]; + + const renderIn = ( + mode: ThemeMode, + variant: (typeof GRADIENT_VARIANTS)[number], + ) => + render( + + + , + ); + + it("paints a gradient fill on every theme", () => { + for (const mode of MODES) { + const { getByRole, unmount } = renderIn(mode, "gradient"); + const background = getComputedStyle(getByRole("button")).background; + expect({ + mode, + gradient: background.includes("linear-gradient"), + }).toEqual({ mode, gradient: true }); + unmount(); + } + }); + + it("stays visible on every theme", () => { + for (const mode of MODES) { + for (const variant of GRADIENT_VARIANTS) { + const { getByRole, unmount } = renderIn(mode, variant); + const display = getComputedStyle(getByRole("button")).display; + expect({ mode, variant, hidden: display === "none" }).toEqual({ + mode, + variant, + hidden: false, + }); + unmount(); + } + } + }); }); describe("sizes", () => { @@ -257,6 +340,60 @@ describe("Button", () => { }); }); + // Figma: `Icon Button AI` (274421:47620), the `Button - Dictation` control. + describe("icon-only gradient variant (Icon Button AI)", () => { + it("carries both classes the treatment is keyed on", () => { + renderButton({ variant: "gradient", children: }); + + const button = screen.getByRole("button"); + // The style hangs off `.MuiButton-gradient.OuiButton-iconOnly`, so a + // gradient button only picks it up once it is also detected icon-only. + expect(button).toHaveClass("MuiButton-gradient"); + expect(button).toHaveClass("OuiButton-iconOnly"); + }); + + it("does not apply to a gradient button with a label", () => { + renderButton({ variant: "gradient", children: "Save" }); + + expect(screen.getByRole("button")).not.toHaveClass("OuiButton-iconOnly"); + }); + + it("renders on every theme without throwing", () => { + for (const mode of [ + ThemeMode.Light, + ThemeMode.Dark, + ThemeMode.Midnight, + ThemeMode.IoC, + ]) { + expect(() => + render( + + + , + ), + ).not.toThrow(); + } + }); + + it("resolves to the icon-button tokens, not the primary fill", () => { + // Both already existed in the theme; the variant introduces no new + // gradient, it just reaches for the icon-button pair instead. + const g = midnightTheme.palette.gradients; + + expect(g.gradientIconButtonBlue).toBe( + "linear-gradient(180deg, #043abc 0%, #113ca1 54.12%, #011d62 120.67%)", + ); + expect(g.gradientIconButtonBlueGlow).toBe( + "linear-gradient(90deg, #3974ff 0%, rgba(57, 116, 255, 0) 100%)", + ); + expect(g.gradientIconButtonBlue).not.toBe( + g.gradientGlobalButtonPrimaryFill, + ); + }); + }); + describe("light theme token coverage", () => { it("renders all variants in light mode without throwing", () => { const variants = [ diff --git a/packages/open-ui-kit/src/components/button/components/elements.tsx b/packages/open-ui-kit/src/components/button/components/elements.tsx index 62ab4ebf..e29a821f 100644 --- a/packages/open-ui-kit/src/components/button/components/elements.tsx +++ b/packages/open-ui-kit/src/components/button/components/elements.tsx @@ -11,270 +11,401 @@ import { styled, } from "@mui/material"; -export const StyledButton = styled(MuiButton)(({ theme }) => ({ - color: theme.palette.vars.baseTextInverse, - textTransform: "none", - transition: "none", - borderRadius: "4px", - width: "max-content", - maxWidth: "100%", - whiteSpace: "normal", - overflowWrap: "break-word", - textAlign: "center", - alignItems: "center", - height: "auto", - "& .MuiButton-startIcon": { - marginLeft: "0px", - }, - "& .MuiButton-endIcon": { - marginRight: "0px", - }, - "&.OuiButton-iconOnly": { +export const StyledButton = styled(MuiButton)(({ theme }) => { + return { + color: theme.palette.vars.baseTextInverse, + textTransform: "none", + transition: "none", + borderRadius: "4px", + width: "max-content", + maxWidth: "100%", + whiteSpace: "normal", + overflowWrap: "break-word", + textAlign: "center", + alignItems: "center", + height: "auto", + "& .MuiButton-startIcon": { + marginLeft: "0px", + }, + "& .MuiButton-endIcon": { + marginRight: "0px", + }, + "&.OuiButton-iconOnly": { + "&.MuiButton-sizeLarge": { + padding: "8px", + minWidth: "40px", + width: "40px", + height: "40px", + minHeight: "40px", + }, + "&.MuiButton-sizeMedium": { + padding: "6px", + minWidth: "32px", + width: "32px", + height: "32px", + minHeight: "32px", + }, + "&.MuiButton-sizeSmall": { + padding: "2px", + minWidth: "24px", + width: "24px", + height: "24px", + minHeight: "24px", + }, + }, "&.MuiButton-sizeLarge": { - padding: "8px", - minWidth: "40px", - width: "40px", - height: "40px", + fontFamily: "Inter, sans-serif", + fontWeight: 600, + fontSize: "16px", + lineHeight: "125%", minHeight: "40px", + height: "auto", + padding: "10px 16px", + }, + "&.OuiButton-hasIcon.MuiButton-sizeLarge": { + padding: "8px 16px", }, "&.MuiButton-sizeMedium": { - padding: "6px", - minWidth: "32px", - width: "32px", - height: "32px", + fontFamily: "Inter, sans-serif", + fontWeight: 600, + fontSize: "14px", + lineHeight: "125%", minHeight: "32px", + height: "auto", + padding: "7px 16px", }, "&.MuiButton-sizeSmall": { - padding: "2px", - minWidth: "24px", - width: "24px", - height: "24px", + fontFamily: "Inter, sans-serif", + fontWeight: 600, + fontSize: "14px", + lineHeight: "125%", minHeight: "24px", + height: "auto", + padding: "3px 12px", }, - }, - "&.MuiButton-sizeLarge": { - fontFamily: "Inter, sans-serif", - fontWeight: 600, - fontSize: "16px", - lineHeight: "125%", - minHeight: "40px", - height: "auto", - padding: "10px 16px", - }, - "&.OuiButton-hasIcon.MuiButton-sizeLarge": { - padding: "8px 16px", - }, - "&.MuiButton-sizeMedium": { - fontFamily: "Inter, sans-serif", - fontWeight: 600, - fontSize: "14px", - lineHeight: "125%", - minHeight: "32px", - height: "auto", - padding: "7px 16px", - }, - "&.MuiButton-sizeSmall": { - fontFamily: "Inter, sans-serif", - fontWeight: 600, - fontSize: "14px", - lineHeight: "125%", - minHeight: "24px", - height: "auto", - padding: "3px 12px", - }, - "&.MuiButton-sizeLarge svg": { - fontSize: "24px", - }, - "&.MuiButton-sizeMedium svg, &.MuiButton-sizeSmall svg": { - fontSize: "20px", - }, - "&.MuiButton-primarySizeLarge, &.MuiButton-primarySizeMedium": { - paddingRight: "16px", - paddingLeft: "16px", - "&:active": { - paddingRight: "15px", - paddingLeft: "15px", + "&.MuiButton-sizeLarge svg": { + fontSize: "24px", }, - }, - "&.MuiButton-primarySizeSmall:active": { - paddingRight: "11px", - paddingLeft: "11px", - }, - // Primary - "&.MuiButton-primary": { - background: theme.palette.vars.interactivePrimaryDefaultDefault, - "&.Mui-disabled": { - background: theme.palette.vars.interactivePrimaryDefaultDisabled, - color: theme.palette.vars.interactivePrimaryWeakDefault, - opacity: 0.35, + "&.MuiButton-sizeMedium svg, &.MuiButton-sizeSmall svg": { + fontSize: "20px", }, - "&:hover": { - background: theme.palette.vars.interactivePrimaryDefaultHover, + "&.MuiButton-primarySizeLarge, &.MuiButton-primarySizeMedium": { + paddingRight: "16px", + paddingLeft: "16px", + "&:active": { + paddingRight: "15px", + paddingLeft: "15px", + }, }, - "&:active": { - background: theme.palette.vars.interactivePrimaryDefaultActive, - border: `1px solid ${theme.palette.vars.interactivePrimaryDefaultDefault}`, + "&.MuiButton-primarySizeSmall:active": { + paddingRight: "11px", + paddingLeft: "11px", }, - "&:focus-visible": { - outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, - outlineOffset: "2px", - }, - "&.MuiButton-loading": { - opacity: 1, + // Primary + "&.MuiButton-primary": { background: theme.palette.vars.interactivePrimaryDefaultDefault, - }, - }, - // Secondary - "&.MuiButton-secondary": { - background: theme.palette.vars.interactiveSecondaryDefaultDefault, - "&.Mui-disabled": { - background: theme.palette.vars.interactiveSecondaryDefaultDisabled, - color: theme.palette.vars.interactiveInverseTextDefault, - opacity: 0.35, - }, - "&:hover": { - background: theme.palette.vars.interactiveSecondaryDefaultHover, - }, - "&:active": { - background: theme.palette.vars.interactiveSecondaryDefaultActive, - border: `1px solid ${theme.palette.vars.interactiveSecondaryDefaultDefault}`, - }, - "&:focus-visible": { - outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, - outlineOffset: "2px", + "&.Mui-disabled": { + background: theme.palette.vars.interactivePrimaryDefaultDisabled, + color: theme.palette.vars.interactivePrimaryWeakDefault, + opacity: 0.35, + }, + "&:hover": { + background: theme.palette.vars.interactivePrimaryDefaultHover, + }, + "&:active": { + background: theme.palette.vars.interactivePrimaryDefaultActive, + border: `1px solid ${theme.palette.vars.interactivePrimaryDefaultDefault}`, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + }, + "&.MuiButton-loading": { + opacity: 1, + background: theme.palette.vars.interactivePrimaryDefaultDefault, + }, + }, + // Secondary + "&.MuiButton-secondary": { background: theme.palette.vars.interactiveSecondaryDefaultDefault, - }, - "&.MuiButton-loading": { - opacity: 1, - background: theme.palette.vars.interactiveSecondaryDefaultDefault, - }, - }, - // Outlined - "&.MuiButton-outlined": { - border: `2px solid ${theme.palette.vars.interactiveTertiaryDefault}`, - background: "none", - color: theme.palette.vars.interactiveTextInDefault, - "&:hover": { - border: `2px solid ${theme.palette.vars.interactiveTertiaryHover}`, - color: theme.palette.vars.interactiveTextInHover, - }, - "&:active": { - border: `2px solid ${theme.palette.vars.interactiveTertiaryActive}`, - color: theme.palette.vars.interactiveTextInActive, - }, - "&:focus-visible": { - outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, - outlineOffset: "2px", - }, - "&.Mui-disabled": { - border: `2px solid ${theme.palette.vars.interactiveTertiaryDisabled}`, - color: theme.palette.vars.baseTextWeak, - opacity: 0.3, - }, - "&.MuiButton-loading": { - opacity: 1, + "&.Mui-disabled": { + background: theme.palette.vars.interactiveSecondaryDefaultDisabled, + color: theme.palette.vars.interactiveInverseTextDefault, + opacity: 0.35, + }, + "&:hover": { + background: theme.palette.vars.interactiveSecondaryDefaultHover, + }, + "&:active": { + background: theme.palette.vars.interactiveSecondaryDefaultActive, + border: `1px solid ${theme.palette.vars.interactiveSecondaryDefaultDefault}`, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + background: theme.palette.vars.interactiveSecondaryDefaultDefault, + }, + "&.MuiButton-loading": { + opacity: 1, + background: theme.palette.vars.interactiveSecondaryDefaultDefault, + }, + }, + // Gradient — background fill + // Figma: `Gradient/Global-Button-Primary/Fill` (274405:44106). + // Figma specifies only the resting state; hover/active are derived by + // brightness so the ramp stays intact in every theme that defines one. + "&.MuiButton-gradient": { + background: theme.palette.gradients.gradientGlobalButtonPrimaryFill, + color: theme.palette.vars.baseTextStrong, + border: "none", + "&:hover": { + background: theme.palette.gradients.gradientGlobalButtonPrimaryFill, + filter: "brightness(1.08)", + }, + "&:active": { + background: theme.palette.gradients.gradientGlobalButtonPrimaryFill, + filter: "brightness(0.92)", + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + }, + "&.Mui-disabled": { + background: theme.palette.gradients.gradientGlobalButtonPrimaryFill, + color: theme.palette.vars.baseTextStrong, + opacity: 0.35, + }, + "&.MuiButton-loading": { + opacity: 1, + background: theme.palette.gradients.gradientGlobalButtonPrimaryFill, + color: theme.palette.vars.baseTextStrong, + }, + }, + // Gradient — icon-only ("Icon Button AI") + // Figma: `Icon Button AI` (274421:47620), the `Button - Dictation` control. + // + // The icon-only form is its own design, not the text button scaled down: + // a pill-round control filled with `Gradient/Icon-Button-Blue` rather than + // the primary fill, ringed by `Gradient/Icon-Button-Blue-Glow`. Both + // tokens already exist in the theme, so this adds no new gradient. + // + // It wins over the `.MuiButton-gradient` rules above on specificity (two + // classes to their one), so ordering in this object does not matter, and + // the nested states override their single-class counterparts the same way. + "&.MuiButton-gradient.OuiButton-iconOnly": { + position: "relative", + background: theme.palette.gradients.gradientIconButtonBlue, + // Figma uses a 58px radius on a 32px control — fully round at every size. + borderRadius: "50%", + border: "none", + color: theme.palette.vars.baseTextStrong, + // The ring is a gradient, so it cannot be a `border-color`; same + // mask-composite technique as `gradientOutlined` below, at 1px. + "&::before": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "1px", + background: theme.palette.gradients.gradientIconButtonBlueGlow, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + }, + "&:hover": { + background: theme.palette.gradients.gradientIconButtonBlue, + filter: "brightness(1.08)", + }, + "&:active": { + background: theme.palette.gradients.gradientIconButtonBlue, + filter: "brightness(0.92)", + }, + "&.Mui-disabled": { + background: theme.palette.gradients.gradientIconButtonBlue, + color: theme.palette.vars.baseTextStrong, + opacity: 0.35, + }, + "&.MuiButton-loading": { + opacity: 1, + background: theme.palette.gradients.gradientIconButtonBlue, + color: theme.palette.vars.baseTextStrong, + }, + }, + // Outlined + "&.MuiButton-outlined": { border: `2px solid ${theme.palette.vars.interactiveTertiaryDefault}`, + background: "none", color: theme.palette.vars.interactiveTextInDefault, - }, - }, - // Tertiary - "&.MuiButton-tertariary": { - background: "none", - color: theme.palette.vars.interactivePrimaryDefaultDefault, - "&:hover": { - color: theme.palette.vars.interactivePrimaryDefaultHover, - }, - "&:active": { - color: theme.palette.vars.interactivePrimaryDefaultActive, - }, - "&:focus-visible": { - outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, - outlineOffset: "2px", - }, - "&.Mui-disabled": { - color: theme.palette.vars.interactivePrimaryDefaultDisabled, - }, - "&.MuiButton-loading": { - opacity: 1, + "&:hover": { + border: `2px solid ${theme.palette.vars.interactiveTertiaryHover}`, + color: theme.palette.vars.interactiveTextInHover, + }, + "&:active": { + border: `2px solid ${theme.palette.vars.interactiveTertiaryActive}`, + color: theme.palette.vars.interactiveTextInActive, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + }, + "&.Mui-disabled": { + border: `2px solid ${theme.palette.vars.interactiveTertiaryDisabled}`, + color: theme.palette.vars.baseTextWeak, + opacity: 0.3, + }, + "&.MuiButton-loading": { + opacity: 1, + border: `2px solid ${theme.palette.vars.interactiveTertiaryDefault}`, + color: theme.palette.vars.interactiveTextInDefault, + }, + }, + // Gradient — border ring + // Figma: `Gradient/Global-Button-Primary/Border-Glow` (I274405:38087;25258:78851). + // + // A gradient cannot be assigned to `border-color`, and `border-image` ignores + // `border-radius`. The mask-composite ring below keeps the interior genuinely + // transparent (so it works on any surface) and follows the rounded corners. + "&.MuiButton-gradientOutlined": { + position: "relative", + border: "none", + background: "none", + color: theme.palette.vars.baseTextDefault, + "&::before": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "2px", + background: + theme.palette.gradients.gradientGlobalButtonPrimaryBorderGlow, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + }, + "&:hover": { + color: theme.palette.vars.baseTextStrong, + "&::before": { filter: "brightness(1.12)" }, + }, + "&:active": { + color: theme.palette.vars.baseTextStrong, + "&::before": { filter: "brightness(0.9)" }, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + }, + "&.Mui-disabled": { + color: theme.palette.vars.baseTextWeak, + opacity: 0.3, + }, + "&.MuiButton-loading": { + opacity: 1, + color: theme.palette.vars.baseTextDefault, + }, + }, + // Tertiary + "&.MuiButton-tertariary": { + background: "none", color: theme.palette.vars.interactivePrimaryDefaultDefault, - }, - }, - // Negative color — primary - "&.MuiButton-primaryNegative": { - background: theme.palette.vars.negativeBackgroundDefault, - "&.Mui-disabled": { - opacity: 0.35, - background: theme.palette.vars.negativeBackgroundDisabled, - color: theme.palette.vars.negativeTextInDefault, - }, - "&:hover": { - color: theme.palette.vars.baseTextInverse, - background: theme.palette.vars.negativeBackgroundHover, - }, - "&:active": { - background: theme.palette.vars.negativeBackgroundActive, - border: `1px solid ${theme.palette.vars.negativeBorderDefault}`, - }, - "&:focus-visible": { - outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, - outlineOffset: "2px", - }, - "&.MuiButton-loading": { - opacity: 1, - color: theme.palette.vars.baseTextInverse, + "&:hover": { + color: theme.palette.vars.interactivePrimaryDefaultHover, + }, + "&:active": { + color: theme.palette.vars.interactivePrimaryDefaultActive, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + }, + "&.Mui-disabled": { + color: theme.palette.vars.interactivePrimaryDefaultDisabled, + }, + "&.MuiButton-loading": { + opacity: 1, + color: theme.palette.vars.interactivePrimaryDefaultDefault, + }, + }, + // Negative color — primary + "&.MuiButton-primaryNegative": { background: theme.palette.vars.negativeBackgroundDefault, - }, - }, - // Negative color — outlined - "&.MuiButton-outlinedNegative": { - border: `2px solid ${theme.palette.vars.negativeBorderDefault}`, - background: "none", - color: theme.palette.vars.negativeBackgroundActive, - "&:hover": { - border: `2px solid ${theme.palette.vars.negativeBackgroundHover}`, - color: theme.palette.vars.negativeBackgroundHover, - }, - "&:active": { - border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`, - color: theme.palette.vars.negativeBackgroundActive, - }, - "&:focus-visible": { - outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, - outlineOffset: "2px", - color: theme.palette.vars.negativeBackgroundActive, - border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`, - }, - "&.Mui-disabled": { - border: `2px solid ${theme.palette.vars.negativeBackgroundDisabled}`, - color: theme.palette.vars.negativeBackgroundDisabled, - opacity: 0.35, - }, - "&.MuiButton-loading": { - opacity: 1, + "&.Mui-disabled": { + opacity: 0.35, + background: theme.palette.vars.negativeBackgroundDisabled, + color: theme.palette.vars.negativeTextInDefault, + }, + "&:hover": { + color: theme.palette.vars.baseTextInverse, + background: theme.palette.vars.negativeBackgroundHover, + }, + "&:active": { + background: theme.palette.vars.negativeBackgroundActive, + border: `1px solid ${theme.palette.vars.negativeBorderDefault}`, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + }, + "&.MuiButton-loading": { + opacity: 1, + color: theme.palette.vars.baseTextInverse, + background: theme.palette.vars.negativeBackgroundDefault, + }, + }, + // Negative color — outlined + "&.MuiButton-outlinedNegative": { border: `2px solid ${theme.palette.vars.negativeBorderDefault}`, + background: "none", color: theme.palette.vars.negativeBackgroundActive, - }, - }, - // Negative color — tertiary - "&.MuiButton-tertariaryNegative": { - background: "none", - color: theme.palette.vars.negativeTextDefault, - "&:hover": { - color: theme.palette.vars.negativeBackgroundHover, - }, - "&:active": { - color: theme.palette.vars.negativeBackgroundActive, - }, - "&:focus-visible": { - outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, - outlineOffset: "2px", - }, - "&.Mui-disabled": { - color: theme.palette.vars.negativeBackgroundDisabled, - }, - "&.MuiButton-loading": { - opacity: 1, + "&:hover": { + border: `2px solid ${theme.palette.vars.negativeBackgroundHover}`, + color: theme.palette.vars.negativeBackgroundHover, + }, + "&:active": { + border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`, + color: theme.palette.vars.negativeBackgroundActive, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + color: theme.palette.vars.negativeBackgroundActive, + border: `2px solid ${theme.palette.vars.negativeBackgroundActive}`, + }, + "&.Mui-disabled": { + border: `2px solid ${theme.palette.vars.negativeBackgroundDisabled}`, + color: theme.palette.vars.negativeBackgroundDisabled, + opacity: 0.35, + }, + "&.MuiButton-loading": { + opacity: 1, + border: `2px solid ${theme.palette.vars.negativeBorderDefault}`, + color: theme.palette.vars.negativeBackgroundActive, + }, + }, + // Negative color — tertiary + "&.MuiButton-tertariaryNegative": { + background: "none", color: theme.palette.vars.negativeTextDefault, - }, - }, -})) as ComponentType; + "&:hover": { + color: theme.palette.vars.negativeBackgroundHover, + }, + "&:active": { + color: theme.palette.vars.negativeBackgroundActive, + }, + "&:focus-visible": { + outline: `2px solid ${theme.palette.vars.excellentBorderActive}`, + outlineOffset: "2px", + }, + "&.Mui-disabled": { + color: theme.palette.vars.negativeBackgroundDisabled, + }, + "&.MuiButton-loading": { + opacity: 1, + color: theme.palette.vars.negativeTextDefault, + }, + }, + }; +}) as ComponentType; diff --git a/packages/open-ui-kit/src/components/button/stories/button.stories.tsx b/packages/open-ui-kit/src/components/button/stories/button.stories.tsx index 35eeefbc..0d2bd1f0 100644 --- a/packages/open-ui-kit/src/components/button/stories/button.stories.tsx +++ b/packages/open-ui-kit/src/components/button/stories/button.stories.tsx @@ -53,7 +53,14 @@ const meta: Meta = { }, variant: { control: "radio", - options: ["primary", "secondary", "outlined", "tertariary"], + options: [ + "primary", + "secondary", + "gradient", + "outlined", + "gradientOutlined", + "tertariary", + ], }, }, decorators: [ @@ -241,7 +248,9 @@ const sizes = ["large", "medium", "small"] as const; const variants: Array<{ label: string; variant: ButtonVariant }> = [ { label: "Primary", variant: "primary" }, { label: "Secondary", variant: "secondary" }, + { label: "Gradient", variant: "gradient" }, { label: "Outlined", variant: "outlined" }, + { label: "Gradient Outlined", variant: "gradientOutlined" }, { label: "Tertiary", variant: "tertariary" }, ]; @@ -258,6 +267,14 @@ export const Secondary: Story = { render: (args) => , }; +export const Gradient: Story = { + args: { + ...defaultArgs, + variant: "gradient", + }, + render: (args) => , +}; + export const Outlined: Story = { args: { ...defaultArgs, @@ -266,6 +283,14 @@ export const Outlined: Story = { render: (args) => , }; +export const GradientOutlined: Story = { + args: { + ...defaultArgs, + variant: "gradientOutlined", + }, + render: (args) => , +}; + export const Tertiary: Story = { args: { ...defaultArgs, @@ -334,6 +359,19 @@ export const IconOnly: Story = { render: (args) => , }; +/** + * Icon-only carries its own gradient treatment + */ +export const IconButtonAI: Story = { + name: "Icon Button AI", + args: { + ...defaultArgs, + children: , + variant: "gradient", + }, + render: (args) => , +}; + export const Disabled: Story = { args: { ...defaultArgs, diff --git a/packages/open-ui-kit/src/components/button/types/index.ts b/packages/open-ui-kit/src/components/button/types/index.ts index 539ce7d0..ccef06c8 100644 --- a/packages/open-ui-kit/src/components/button/types/index.ts +++ b/packages/open-ui-kit/src/components/button/types/index.ts @@ -9,7 +9,7 @@ import type { ButtonProps as MuiButtonProps } from "@mui/material"; export interface ButtonProps extends Omit { /** Button label, icon, or composed content rendered inside the control. */ children?: MuiButtonProps["children"]; - /** Visual style for the action: primary, secondary, outlined, or tertariary. */ + /** Visual style for the action: primary, secondary, gradient, outlined, gradientOutlined, or tertariary. */ variant?: MuiButtonProps["variant"]; /** Button scale. Large min height is 40px, medium is 32px, and small is 24px. Width grows with label text until constrained, then height grows. */ size?: MuiButtonProps["size"]; diff --git a/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx b/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx index c99ba830..9abafea1 100644 --- a/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx +++ b/packages/open-ui-kit/src/components/card/__tests__/card.test.tsx @@ -7,6 +7,7 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; +import type { Theme } from "@mui/material/styles"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; @@ -17,14 +18,32 @@ import { CardActions, CardActionArea, } from "../components/card"; +import CardAlertHeader from "../components/card-alert-header"; import CardDescription from "../components/card-description"; import CardSubheader from "../components/card-subheader"; import { cardActiveStyles, + cardAlertAccent, + cardAlertAccentVar, + cardAlertStyles, + cardConnectorStyles, cardDisabledStyles, + cardGlassStyles, + cardGlowStyles, + cardImageSideFade, + cardImageStyles, cardInteractiveStyles, cardRootStyles, } from "../styles"; +import { midnightTheme } from "@/theme/midnight/midnight-theme"; +import { + cardAlertShadow, + cardConnectorBlur, + cardConnectorShadow, + cardGlassBlur, + cardGlassShadow, + cardInsightGlow, +} from "@/theme/style/color-palette"; const renderCard = (ui: React.ReactElement, dark = false) => render( @@ -297,4 +316,471 @@ describe("Card", () => { ).not.toThrow(); }); }); + + // Figma: `Card/Basic Interactive` (274405:44327), border token + // `Gradient/Panel-Exec-Border`. + describe("glow treatment", () => { + it("paints the border as a mask-composite ring, not a border property", () => { + const styles = cardGlowStyles(midnightTheme); + const ring = styles["&::before"] as Record; + + // A gradient cannot be a border-color, so the border must be removed and + // the ramp drawn by the ring instead. + expect(styles.border).toBe("none"); + expect(ring.background).toBe( + midnightTheme.palette.gradients.gradientPanelExecBorder, + ); + expect(ring.padding).toBe("1px"); + expect(ring.maskComposite).toBe("exclude"); + // Must follow the card's 8px radius rather than a hardcoded value. + expect(ring.borderRadius).toBe("inherit"); + // Must not sit above the card's non-positioned children. + expect(ring.zIndex).toBe(0); + expect(ring.pointerEvents).toBe("none"); + }); + + it("applies the blue glow shadow", () => { + expect(cardGlowStyles(midnightTheme).boxShadow).toBe(cardInsightGlow); + expect(cardInsightGlow).toBe("0px 4px 17px rgba(10, 96, 255, 0.4)"); + }); + + it("resolves the border to the measured diagonal ramp", () => { + // Sampling the rendered Figma card showed a 135deg diagonal, despite the + // swatch being labelled "Radial". + const border = midnightTheme.palette.gradients.gradientPanelExecBorder; + expect(border).toContain("linear-gradient(135deg"); + expect(border).not.toContain("radial-gradient"); + }); + + it("renders without throwing and keeps `glow` off the DOM node", () => { + expect(() => + renderCard( + + + , + ), + ).not.toThrow(); + + expect(screen.getByTestId("glow-card")).not.toHaveAttribute("glow"); + }); + }); + + // Figma: `Alerts Card` (274421:47415) — critical (274421:47325) and warning + // (274421:47332). That group is scaled 0.869x, so every measurement below is + // the reported value divided through. + describe("alert treatment", () => { + it("shares one surface and geometry across both severities", () => { + for (const severity of ["critical", "warning"] as const) { + const styles = cardAlertStyles(midnightTheme, severity); + + expect(styles.background).toBe("rgba(255, 255, 255, 0.05)"); + expect(styles.borderRadius).toBe("24px"); + expect(styles.padding).toBe("20px"); + expect(styles.gap).toBe("4px"); + expect(styles.boxShadow).toBe(cardAlertShadow); + // A rounded overflow clip is antialiased at the corners and eats into + // the gradient ring's own antialiasing, thinning the arcs. Nothing in + // an alert card overflows, so the clip is left off. + expect(styles.overflow).toBeUndefined(); + } + + // It must differ from the default card, not just repeat it. + expect(cardRootStyles(midnightTheme).borderRadius).toBe("8px"); + }); + + it("gives only critical the rainbow gradient ring", () => { + const critical = cardAlertStyles(midnightTheme, "critical"); + const warning = cardAlertStyles(midnightTheme, "warning"); + const ring = critical["&::before"] as Record; + + // The swatch is labelled `Global-Border/Fade`, but its stops are the + // rainbow ramp — see the note in `midnight-gradient-vars.ts`. + expect(ring.background).toBe( + midnightTheme.palette.gradients.gradientGlobalBorderRainbow, + ); + expect(critical.border).toBe("none"); + // The frame's card is mirrored, so the ramp renders orange -> blue while + // the token is authored blue -> orange. Sampled at the rendered top edge. + expect(ring.transform).toBe("scaleX(-1)"); + expect(ring.padding).toBe("1px"); + expect(ring.maskComposite).toBe("exclude"); + expect(ring.borderRadius).toBe("inherit"); + expect(ring.zIndex).toBe(0); + + // The design context for the warning card reports no border at all. + expect(warning["&::before"]).toBeUndefined(); + expect(warning.border).toBeUndefined(); + }); + + it("resolves the ring to the four stops the swatch documents", () => { + const ramp = midnightTheme.palette.gradients.gradientGlobalBorderRainbow; + + expect(ramp).toContain("#0a60ff"); + expect(ramp).toContain("#02c8ff"); + expect(ramp).toContain("#ff007f"); + expect(ramp).toContain("#ff9000"); + }); + + it("publishes the accent colour for CardAlertHeader to read", () => { + expect(cardAlertAccent("critical")).toBe("#eb4651"); + expect(cardAlertAccent("warning")).toBe("#ffae4c"); + + // Passed down as a custom property so the severity is declared once, on + // the card, rather than repeated on the header. + expect( + cardAlertStyles(midnightTheme, "critical")[cardAlertAccentVar], + ).toBe("#eb4651"); + }); + + it("renders the meta row and keeps `alert` off the DOM node", () => { + renderCard( + + CRITICAL ALERT + + , + ); + + const card = screen.getByTestId("alert-card"); + expect(card).not.toHaveAttribute("alert"); + expect(card).toHaveAttribute("data-card-alert", "critical"); + expect(screen.getByText("CRITICAL ALERT")).toBeInTheDocument(); + expect(screen.getByText("4m ago")).toBeInTheDocument(); + }); + + it("omits the timestamp when none is given", () => { + renderCard( + + WARNING + , + ); + + expect(screen.getByText("WARNING")).toBeInTheDocument(); + expect(screen.queryByText("3h ago")).not.toBeInTheDocument(); + }); + + it("does not mark cards without an alert", () => { + renderCard(Content); + + expect(screen.getByTestId("plain")).not.toHaveAttribute( + "data-card-alert", + ); + }); + }); + + // Figma: `Section 3` (274455:54313). The card there exports as one SVG whose + // defs carry all three `Graph-Connector` gradients verbatim, so these are + // exact rather than sampled. + describe("connector treatment", () => { + it("stacks the glow over the fill, with the glow painted first", () => { + const styles = cardConnectorStyles(midnightTheme); + const g = midnightTheme.palette.gradients; + + // CSS paints the first background layer on top, matching the export's + // paint order: linear fill, then the radial glow above it. + expect(styles.background).toBe( + `${g.gradientGraphConnectorGlow}, ${g.gradientGraphConnectorFill}`, + ); + expect(styles.backdropFilter).toBe(`blur(${cardConnectorBlur})`); + expect(styles.boxShadow).toBe(cardConnectorShadow); + // The export's path turns its corner over 6px in both axes. + expect(styles.borderRadius).toBe("6px"); + }); + + it("anchors the glow at the bottom edge of the card", () => { + // translate(107.5 211) on a 215x207 path = 50% 100%; the -90deg rotated + // (176.14, 182.947) scale gives 85% radii in both axes. + expect(midnightTheme.palette.gradients.gradientGraphConnectorGlow).toBe( + "radial-gradient(85% 85% at 50% 100%, rgba(199, 211, 234, 0.064) 0%, rgba(199, 211, 234, 0.008) 100%)", + ); + }); + + it("uses the card-accurate fill angle, not the swatch reading", () => { + // The export's fill vector (0,4) -> (116.6,256.4) is 155.21deg. Figma + // normalises gradient transforms to the layer box, so the 120x60 swatch + // squeezed the same token to the 166.51deg previously recorded. + const fill = midnightTheme.palette.gradients.gradientGraphConnectorFill; + + expect(fill).toContain("linear-gradient(155.21deg"); + expect(fill).not.toContain("166.51deg"); + // 0.16 fill-opacity baked in: 0.22 -> 0.035, 0.10 -> 0.016. + expect(fill).toContain("rgba(199, 211, 234, 0.035) 0%"); + expect(fill).toContain("rgba(199, 211, 234, 0.016) 100%"); + }); + + it("edges the card with the gradient stroke as a ring", () => { + const styles = cardConnectorStyles(midnightTheme); + const ring = styles["&::after"] as Record; + + expect(styles.border).toBe("none"); + expect(ring.background).toBe( + midnightTheme.palette.gradients.gradientGraphConnectorStroke, + ); + expect(ring.padding).toBe("1px"); + expect(ring.maskComposite).toBe("exclude"); + // Same corner-thinning reason as the alert treatment. + expect(styles.overflow).toBeUndefined(); + }); + + it("renders without throwing and keeps `connector` off the DOM node", () => { + expect(() => + renderCard( + + + , + ), + ).not.toThrow(); + + expect(screen.getByTestId("connector-card")).not.toHaveAttribute( + "connector", + ); + }); + }); + + // Figma: `Glass Card` (274490:55387). Every value below is read straight off + // the frame's SVG exports (nodes 274490:55140 and 274490:55139), at the + // mockup's 0.8928 scale divided out — none of it is pixel-sampled. + describe("glass treatment", () => { + it("fills with the glass gradient token over a backdrop blur", () => { + const styles = cardGlassStyles(midnightTheme); + + expect(styles.background).toBe( + midnightTheme.palette.gradients.gradientCardGlassBg, + ); + // Without the blur it is a flat translucent panel, not glass. The 40px + // comes from the export's own `backdrop-filter: blur(35.71px)` / 0.8928. + expect(styles.backdropFilter).toBe(`blur(${cardGlassBlur})`); + expect(cardGlassBlur).toBe("40px"); + expect(styles.boxShadow).toBe(cardGlassShadow); + }); + + it("anchors the fill radial at the top-right corner, 40% -> 5% white", () => { + const fill = midnightTheme.palette.gradients.gradientCardGlassBg; + + expect(fill).toBe( + "radial-gradient(100% 100% at 100% 0%, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.05) 100%)", + ); + }); + + it("layers the Glow-Teal flair behind the content", () => { + const styles = cardGlassStyles(midnightTheme); + const flair = styles["&::before"] as Record; + + expect(flair.background).toBe( + midnightTheme.palette.gradients.gradientDashboardCardFillCyanPurple, + ); + // The token itself: cyan 37% at the top of the streak, periwinkle 73% + // at the bottom, straight from the export's gradient def. The -83.247% + // anchor is not decoration: Figma defines the ramp over the layer box + // (y -19.1782 -> 102.235) but only paints the crescent inside it + // (y 35.9785 -> 102.235), so the visible top edge is already 45.429% of + // the way to periwinkle and never shows pure cyan. + expect(midnightTheme.palette.gradients.gradientDashboardCardFillCyanPurple).toBe( + "linear-gradient(180deg, rgba(0, 187, 255, 0.37) -83.247%, rgba(161, 166, 254, 0.73) 100%)", + ); + // A bottom-anchored crescent: flat along the bottom edge, arcing up + // mid-card and falling toward both ends — the dome comes from the + // elliptical top radii. Peak and bleed are solved from the export path's + // cubic (t=0.50983 -> y=35.9785) against the 188-tall card. + expect(flair.inset).toBe("66.111% 0% -1.354% 0%"); + expect(flair.borderRadius).toBe("50% 50% 0 0 / 100% 100% 0 0"); + // The flair's OWN layer blur only, from the export's + // `feGaussianBlur stdDeviation="17.9891"` / 0.8928 = 20.15 — Figma + // reports the layer-blur radius as 35.978 and halves it on the way out, + // exactly as it does for the backdrop blur. + // + // The surface's 40px backdrop blur is deliberately NOT folded in here. + // In Figma that pass runs after the flair is composited over an opaque + // backdrop, so it softens edges; applying it to the still-translucent + // flair instead spreads alpha and drains the glow, since a ~45px blur + // is comparable to the ~66px-tall crescent's own height. + expect(flair.filter).toBe("blur(20.15px)"); + // The group opacity, on top of the alpha already in the stops. + expect(flair.opacity).toBe(0.73); + expect(flair.pointerEvents).toBe("none"); + expect(styles["& > *"]).toEqual({ position: "relative", zIndex: 1 }); + }); + + it("draws the hairline as a ring that fades out at the bottom", () => { + const styles = cardGlassStyles(midnightTheme); + const ring = styles["&::after"] as Record; + + // The border is a vertical ramp now, so it cannot be a `border-color`. + expect(styles.border).toBe("none"); + expect(ring.background).toBe( + midnightTheme.palette.gradients.gradientCardGlassBorder, + ); + expect(midnightTheme.palette.gradients.gradientCardGlassBorder).toBe( + "linear-gradient(180deg, rgba(255, 255, 255, 0.3) 10%, rgba(241, 241, 241, 0.3) 75%, rgba(153, 153, 153, 0) 100%)", + ); + expect(ring.padding).toBe("1px"); + expect(ring.maskComposite).toBe("exclude"); + expect(ring.borderRadius).toBe("inherit"); + // SVG path outer corner radius 19.64 / 0.8928. + expect(styles.borderRadius).toBe("22px"); + }); + + it("renders without throwing and keeps `glass` off the DOM node", () => { + expect(() => + renderCard( + + + , + ), + ).not.toThrow(); + + expect(screen.getByTestId("glass-card")).not.toHaveAttribute("glass"); + }); + }); + + // Figma: `Welcome Card` (274405:44234) in the `Card with image` frame + // (274417:44476). Fill tokens `Gradient/Welcome-Card-BG-Dark` and + // `Gradient/Overlay-Black-Fade-In`. + describe("image treatment", () => { + const image = "/assets/img.png"; + + it("layers the photo and the scrim as separate pseudo-elements", () => { + const styles = cardImageStyles(midnightTheme, image); + const photo = styles["&::before"] as Record; + const scrim = styles["&::after"] as Record; + + // The base gradient is the card's own background; the photo sits above it + // at half strength, and the scrim above that. + expect(styles.background).toBe( + midnightTheme.palette.gradients.gradientWelcomeCardBgDark, + ); + expect(photo.backgroundImage).toBe(`url("${image}")`); + expect(photo.backgroundSize).toBe("cover"); + // CSS cannot set per-layer opacity, which is why these are two elements. + expect(photo.opacity).toBe(0.5); + // Two scrims, vertical painted over horizontal. + expect(scrim.background).toBe( + `${midnightTheme.palette.gradients.gradientOverlayBlackFadeIn}, ${cardImageSideFade}`, + ); + + // Neither layer may swallow clicks or paint over the content. + expect(photo.pointerEvents).toBe("none"); + expect(scrim.pointerEvents).toBe("none"); + expect(photo.zIndex).toBe(0); + expect(scrim.zIndex).toBe(0); + expect(styles["& > *"]).toEqual({ position: "relative", zIndex: 1 }); + }); + + it("carries the larger geometry the design uses for this surface", () => { + const styles = cardImageStyles(midnightTheme, image); + + expect(styles.borderRadius).toBe("20px"); + expect(styles.padding).toBe("24px"); + expect(styles.gap).toBe("16px"); + // Required so the two layers are clipped to the 20px radius. + expect(styles.overflow).toBe("hidden"); + expect(styles.backdropFilter).toBe("blur(60px)"); + + // It must differ from the default card, not just repeat it. + expect(cardRootStyles(midnightTheme).borderRadius).toBe("8px"); + expect(cardRootStyles(midnightTheme).padding).toBe("16px"); + }); + + // Figma `Rectangle 10` (274405:44237). It exports as SVG, so it never + // appears in the generated design context and has to be read off that + // export: stops at 25.15% and 50.81% of the card width. + it("fades the surface out horizontally by the midpoint", () => { + expect(cardImageSideFade).toContain("linear-gradient(90deg"); + expect(cardImageSideFade).toContain("#060b26 25.15%"); + expect(cardImageSideFade).toContain("rgba(6, 11, 38, 0) 50.81%"); + + // It ramps out of the same colour the base gradient starts from, so the + // left of the card reads as one flat surface. + expect( + midnightTheme.palette.gradients.gradientWelcomeCardBgDark, + ).toContain("#060b26"); + }); + + it("resolves the fills to the Figma gradient tokens in Midnight", () => { + const g = midnightTheme.palette.gradients; + + expect(g.gradientOverlayBlackFadeIn).toBe( + "linear-gradient(180deg, rgba(0, 0, 0, 0.65) 52.404%, rgba(102, 102, 102, 0) 100%)", + ); + expect(g.gradientWelcomeCardBgDark).toContain("#060b26 64.87%"); + }); + + it("keeps `image` off the DOM node and marks the card for text inheritance", () => { + expect(() => + renderCard( + + + , + ), + ).not.toThrow(); + + const card = screen.getByTestId("image-card"); + expect(card).not.toHaveAttribute("image"); + expect(card).toHaveAttribute("data-card-image", "true"); + }); + + it("does not mark cards without an image", () => { + renderCard(Content); + + expect(screen.getByTestId("plain-card")).not.toHaveAttribute( + "data-card-image", + ); + }); + + /* + * Tertiary actions on this surface read as text over the photo scrim, so + * they take the Interactive/Text In ramp rather than the variant's + * Interactive/Primary blue, which the scrim leaves sitting too dark. + */ + describe("tertiary action colour", () => { + const tertiaryRule = (theme: Theme) => + cardImageStyles(theme, image)[ + "& .MuiButton-root.MuiButton-tertariary" + ] as Record | string>; + + it.each([ + ["midnight", midnightTheme], + ["light", lightTheme], + ["dark", darkTheme], + ])("puts the whole %s ramp on Interactive/Text In", (_mode, theme) => { + const rule = tertiaryRule(theme); + const { vars } = theme.palette; + + expect(rule.color).toBe(vars.interactiveTextInDefault); + expect(rule["&:hover"]).toEqual({ + color: vars.interactiveTextInHover, + }); + expect(rule["&:active"]).toEqual({ + color: vars.interactiveTextInActive, + }); + }); + + it("moves every state off the variant's Interactive/Primary blue", () => { + const rule = tertiaryRule(midnightTheme); + const { vars } = midnightTheme.palette; + + // Leaving hover or active behind would make the button jump colour + // ramps mid-interaction. + expect(rule.color).not.toBe(vars.interactivePrimaryDefaultDefault); + expect(rule["&:hover"]).not.toEqual({ + color: vars.interactivePrimaryDefaultHover, + }); + expect(rule["&:active"]).not.toEqual({ + color: vars.interactivePrimaryDefaultActive, + }); + }); + + it("names the button root so the rule outweighs the variant's own", () => { + const selectors = Object.keys(cardImageStyles(midnightTheme, image)); + + /* + * The variant styles itself with `&.MuiButton-tertariary` — the styled + * class plus the variant class, two classes. A descendant selector + * naming only the variant class would tie, leaving the winner to + * emotion's injection order; `.MuiButton-root` takes this to three. + */ + expect(selectors).toContain("& .MuiButton-root.MuiButton-tertariary"); + expect(selectors).not.toContain("& .MuiButton-tertariary"); + }); + }); + }); }); diff --git a/packages/open-ui-kit/src/components/card/components/card-alert-header.tsx b/packages/open-ui-kit/src/components/card/components/card-alert-header.tsx new file mode 100644 index 00000000..05a54a68 --- /dev/null +++ b/packages/open-ui-kit/src/components/card/components/card-alert-header.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Typography } from "@mui/material"; +import type { CardAlertHeaderProps } from "../types"; +import { cardAlertAccentVar } from "../styles"; +import { StyledCardAlertHeader } from "./elements"; + +/** + * Meta row for an alert card: severity label on the left, timestamp on the + * right. Figma: `Alerts Card` (274421:47415). + * + * The label colour comes from the `--card-alert-accent` custom property that + * `cardAlertStyles` sets on the parent card, so the severity is declared once + * on `` rather than threaded through here as a second prop. + */ +const CardAlertHeader = ({ + children, + timestamp, + ...props +}: CardAlertHeaderProps) => ( + + + {children} + + {timestamp ? ( + ({ color: theme.palette.vars.baseTextWeak })} + > + {timestamp} + + ) : null} + +); + +export default CardAlertHeader; diff --git a/packages/open-ui-kit/src/components/card/components/card.tsx b/packages/open-ui-kit/src/components/card/components/card.tsx index 25587cc9..41637316 100644 --- a/packages/open-ui-kit/src/components/card/components/card.tsx +++ b/packages/open-ui-kit/src/components/card/components/card.tsx @@ -24,11 +24,24 @@ const mergeSx = (sx: SxProps | undefined): SxProps => [ ...(Array.isArray(sx) ? sx : sx ? [sx] : []), ]; -export const Card = ({ disabled = false, sx, ...props }: CardProps) => ( +export const Card = ({ + alert, + disabled = false, + image, + sx, + ...props +}: CardProps) => ( ); diff --git a/packages/open-ui-kit/src/components/card/components/elements.tsx b/packages/open-ui-kit/src/components/card/components/elements.tsx index dbfc4d55..cf87b6d7 100644 --- a/packages/open-ui-kit/src/components/card/components/elements.tsx +++ b/packages/open-ui-kit/src/components/card/components/elements.tsx @@ -16,45 +16,99 @@ import { CardContentProps as MuiCardContentProps, CardActions as MuiCardActions, CardActionsProps as MuiCardActionsProps, + Stack, + StackProps, Typography, TypographyProps, styled, } from "@mui/material"; +import type { CardAlertSeverity } from "../types"; import { cardActiveStyles, + cardAlertStyles, + cardConnectorStyles, cardDisabledStyles, + cardGlassStyles, + cardGlowStyles, + cardImageStyles, cardInteractiveStyles, cardRootStyles, } from "../styles"; export const StyledCard = styled(MuiCard, { - shouldForwardProp: (prop) => prop !== "disabled", -})<{ disabled?: boolean }>(({ theme }) => ({ + shouldForwardProp: (prop) => + prop !== "alert" && + prop !== "connector" && + prop !== "disabled" && + prop !== "glass" && + prop !== "glow" && + prop !== "image", +})<{ + alert?: CardAlertSeverity; + connector?: boolean; + disabled?: boolean; + glass?: boolean; + glow?: boolean; + image?: string; +}>(({ theme, alert, connector, glass, glow, image }) => ({ ...cardRootStyles(theme), - "&:hover": { - backgroundColor: theme.palette.vars.baseBackgroundWeak, - }, + // The decorative treatments are documented as mutually exclusive. They are + // still applied in a fixed order so a card that sets more than one gets a + // predictable result rather than whichever spread happened to land last. + ...(image ? cardImageStyles(theme, image) : {}), + ...(glass ? cardGlassStyles(theme) : {}), + ...(connector ? cardConnectorStyles(theme) : {}), + ...(alert ? cardAlertStyles(theme, alert) : {}), + ...(glow ? cardGlowStyles(theme) : {}), + // Hover deliberately leaves the background alone. The interactive card gets + // its hover feedback from the `controlBorderActive` border that + // `StyledCardActionArea` applies, and the decorative treatments paint + // translucent or gradient backgrounds that a hover colour would show through. '&[aria-disabled="true"]': cardDisabledStyles(theme), -})) as ComponentType; +})) as ComponentType< + MuiCardProps & { + alert?: CardAlertSeverity; + connector?: boolean; + disabled?: boolean; + glass?: boolean; + glow?: boolean; + image?: string; + } +>; + +// Meta row at the top of an alert card: severity label left, timestamp right. +// Figma: `Alerts Card` (274421:47415). +export const StyledCardAlertHeader = styled(Stack)(() => ({ + alignItems: "center", + alignSelf: "stretch", + flexDirection: "row", + justifyContent: "space-between", + width: "100%", +})) as ComponentType; export const StyledCardActionArea = styled(MuiCardActionArea)(({ theme }) => ({ borderRadius: "8px", - "&: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; @@ -68,6 +122,10 @@ export const StyledCardHeader = styled(MuiCardHeader)(({ theme }) => ({ ...theme.typography.captionMedium, color: theme.palette.vars.baseTextMedium, }, + '[data-card-image="true"] & .MuiCardHeader-title, [data-card-image="true"] & .MuiCardHeader-subheader': + { + color: "inherit", + }, '[aria-disabled="true"] & .MuiCardHeader-title, [aria-disabled="true"] & .MuiCardHeader-subheader': { color: theme.palette.vars.baseTextDisabled, @@ -87,6 +145,9 @@ export const StyledCardActions = styled(MuiCardActions)(() => ({ export const StyledCardDescription = styled(Typography)(({ theme }) => ({ color: theme.palette.vars.baseTextDefault, + '[data-card-image="true"] &': { + color: "inherit", + }, '[aria-disabled="true"] &': { color: theme.palette.vars.baseTextDisabled, }, @@ -94,6 +155,9 @@ export const StyledCardDescription = styled(Typography)(({ theme }) => ({ export const StyledCardSubheader = styled(Typography)(({ theme }) => ({ color: theme.palette.vars.baseTextMedium, + '[data-card-image="true"] &': { + color: "inherit", + }, '[aria-disabled="true"] &': { color: theme.palette.vars.baseTextDisabled, }, diff --git a/packages/open-ui-kit/src/components/card/index.ts b/packages/open-ui-kit/src/components/card/index.ts index dc7969a4..e32ecefe 100644 --- a/packages/open-ui-kit/src/components/card/index.ts +++ b/packages/open-ui-kit/src/components/card/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export { default as CardAlertHeader } from "./components/card-alert-header"; export { default as CardDescription } from "./components/card-description"; export { default as CardSubheader } from "./components/card-subheader"; export { @@ -16,6 +17,8 @@ export { export type { CardActionAreaProps, CardActionsProps, + CardAlertHeaderProps, + CardAlertSeverity, CardContentProps, CardDescriptionProps, CardHeaderProps, diff --git a/packages/open-ui-kit/src/components/card/stories/card.stories.tsx b/packages/open-ui-kit/src/components/card/stories/card.stories.tsx index f618ec3d..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,15 +17,20 @@ import { Card, CardActionArea, CardActions, + CardAlertHeader, 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"; @@ -44,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, }, @@ -73,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 = () => ( ( color: theme.palette.vars.baseTextMedium, })} > - + ({ + color: theme.palette.vars.successIconDefault, + fontSize: 16, + })} + /> 10k @@ -125,29 +162,53 @@ const StrategyCardContent = () => ( ); +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 = () => ( <> ({ 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 @@ -180,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: , @@ -260,7 +411,9 @@ export const Metrics: Story = { Headline not clickable - + + 0% + ({ alignItems: "flex-start", @@ -31,8 +44,8 @@ export const cardActiveStyles = (theme: Theme): CSSObject => ({ }); 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, @@ -41,6 +54,370 @@ export const cardDisabledStyles = (theme: Theme): CSSObject => ({ }, }); +/** + * Glow treatment: gradient border + blue glow. + * + * Figma: `Card/Basic Interactive` (274405:44327), border token + * `Gradient/Panel-Exec-Border`. + * + * The border is a 1px gradient ring rather than a `border`, because a gradient + * cannot be assigned to `border-color` and `border-image` ignores + * `border-radius`. The mask-composite pseudo-element is the same technique used + * by the Button `gradientOutlined` variant; it follows the 8px radius and keeps + * the card's own background intact. + */ +export const cardGlowStyles = (theme: Theme): CSSObject => ({ + position: "relative", + border: "none", + boxShadow: cardInsightGlow, + "&::before": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "1px", + background: theme.palette.gradients.gradientPanelExecBorder, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + // The ring is positioned, so without this it would paint above the card's + // non-positioned children. + zIndex: 0, + }, +}); + +/** CSS custom property carrying the alert accent down to `CardAlertHeader`. */ +export const cardAlertAccentVar = "--card-alert-accent"; + +/** Severity label colour. Figma: `Alerts Card` (274421:47415). */ +export const cardAlertAccent = (severity: CardAlertSeverity): string => + severity === "critical" ? alertCriticalText : alertWarningText; + +/** + * Alert treatment. + * + * Figma: `Alerts Card` (274421:47415) — critical (274421:47325) and warning + * (274421:47332). That whole group is scaled 0.869x on the canvas, so every + * length the design context reports has been divided through: the 20.863px + * radius is 24px, 17.386px of padding is 20px, and so on. + * + * The two severities share one surface and differ in exactly two ways: critical + * carries the rainbow gradient border, and the accent colour the header picks + * up. Warning has no border at all — the design context for 274421:47332 + * reports no border property, where 274421:47325 reports one. + * + * The border is a 1px mask-composite ring rather than a `border`, for the same + * reason as `cardGlowStyles`: a gradient cannot be assigned to + * `border-color`, and `border-image` ignores `border-radius`. + * + * The gradient itself needs no new token. The swatch in the frame is labelled + * `Gradient/Global-Border/Fade`, but its stops are #0a60ff -> #02c8ff -> + * #ff007f -> #ff9000 — the rainbow ramp already in the theme as + * `gradientGlobalBorderRainbow`, whose comment in `midnight-gradient-vars.ts` + * records this exact mislabelling. + */ +export const cardAlertStyles = ( + theme: Theme, + severity: CardAlertSeverity, +): CSSObject => ({ + // The same white the glass fill ramps out of, so no new colour is introduced. + background: alpha(midnightGradientStops.glassWhite, 0.05), + borderRadius: "24px", + boxShadow: cardAlertShadow, + color: theme.palette.vars.baseTextStrong, + gap: "4px", + // Deliberately no `overflow: hidden`, even though Figma marks the frame + // `overflow-clip`. A rounded overflow clip is antialiased at the corners, and + // multiplying that partial alpha by the ring's own partial alpha at the same + // pixels roughly halves it — the arcs rendered at about 40% of the strength + // of the straight edges, which are pixel-aligned and so lose nothing. The + // card holds only text, so there is nothing to clip. + padding: "20px", + position: "relative", + [cardAlertAccentVar]: cardAlertAccent(severity), + // Inter Bold 20/30. No theme variant matches — `h6` is Sharp Sans at 20/28 — + // so the alert scopes its own title style rather than bending a shared one. + "& .MuiCardHeader-title": { + color: "inherit", + fontFamily: "Inter, sans-serif", + fontSize: "20px", + fontWeight: 700, + lineHeight: "30px", + }, + ...(severity === "critical" + ? { + border: "none", + "&::before": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "1px", + background: theme.palette.gradients.gradientGlobalBorderRainbow, + // The card in the frame is mirrored — every text node in it carries + // Figma's `rotate-180` flip artifact, which is also why the meta row + // comes last in the layer order but renders first. That flip reverses + // the border ramp too: sampling the rendered top edge gives orange at + // 5% across, pink at 16%, cyan at 67% and blue at 88%, an exact + // reversal of the token's blue -> cyan -> pink -> orange. + // + // Mirroring the ring reproduces that without forking the shared + // token, which other surfaces use in its canonical direction. The + // radius is uniform on all four corners, so the flip changes nothing + // but the ramp. + transform: "scaleX(-1)", + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + // The ring is positioned, so without this it would paint above the + // card's non-positioned children. + zIndex: 0, + }, + } + : {}), +}); + +/** + * Graph-connector treatment. + * + * Figma: `Section 3` (274455:54313). The card there exports as a single SVG + * whose defs carry all three `Graph-Connector` gradients verbatim, so every + * value below is exact — geometry, alphas, blur and shadow alike. + * + * Two fills stack: the `Fill` linear ramp with the `Glow` radial painted over + * it, both already carrying the export's 0.16 `fill-opacity` in their tokens. + * CSS paints the first background layer on top, so the glow is listed first. + * + * The 1px stroke is a gradient, so it cannot be a `border-color`; it uses the + * same mask-composite ring as the other treatments, drawn on `::after` and + * with no `overflow: hidden` — see the alert treatment for why that clip + * thins the ring's corner arcs. + */ +export const cardConnectorStyles = (theme: Theme): CSSObject => ({ + background: `${theme.palette.gradients.gradientGraphConnectorGlow}, ${theme.palette.gradients.gradientGraphConnectorFill}`, + backdropFilter: `blur(${cardConnectorBlur})`, + border: "none", + // The export's path turns its corner over 6px in both axes. + borderRadius: "6px", + boxShadow: cardConnectorShadow, + color: theme.palette.vars.baseTextStrong, + padding: "20px", + position: "relative", + "&::after": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "1px", + background: theme.palette.gradients.gradientGraphConnectorStroke, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + zIndex: 0, + }, + "& > *": { + position: "relative", + zIndex: 1, + }, +}); + +/** + * Frosted-glass treatment. + * + * Figma: `Glass Card` (274490:55387). Unlike the earlier revision of this + * frame, every layer here is EXACT — the card surface and its flair export as + * SVGs whose gradient defs, blur radii and corner radius can be read directly + * (all at the mockup's 0.8928 scale, divided out below and in the tokens). + * + * Layer stack, bottom to top: + * 1. `::before` — the `Dashboard-Card/Fill/Cyan-Purple` flair: a cyan-to-periwinkle + * crescent across the lower card, keeping the frame's own shape and its + * own 20.15px layer blur. In Figma it sits BEHIND the surface (flair z=2, + * glass surface z=3); a pseudo-element cannot get beneath its own + * element's background, so it paints above the fill instead. Near the + * card's bottom the radial is only ~5% white, so the inverted order costs + * little — and keeping the flair on its own element is what preserves the + * shape and blur. + * 2. The card's own background — the `Card-Glass-BG` radial, anchored at the + * top-right corner, over `backdrop-filter: blur(40px)`. + * 3. `::after` — the `Card-Glass-BORDER` hairline, a vertical ramp that fades + * out by the bottom edge. A gradient cannot be a `border-color`, so it is + * the same mask-composite ring as the `glow` treatment; drawn as + * `::after` so it paints above the flair. + * + * No `overflow: hidden`, for the same corner-thinning reason as the alert + * treatment — and the flair is designed to bleed past the card edge anyway. + */ +export const cardGlassStyles = (theme: Theme): CSSObject => ({ + background: theme.palette.gradients.gradientCardGlassBg, + backdropFilter: `blur(${cardGlassBlur})`, + border: "none", + // SVG path outer corner radius 19.64 / 0.8928. + borderRadius: "22px", + boxShadow: cardGlassShadow, + color: theme.palette.vars.baseTextStrong, + position: "relative", + "&::before": { + content: '""', + position: "absolute", + // The flair is a crescent, not a band: its flat bottom hugs the card's + // bottom edge (bleeding just past it) while its top edge arcs mid-card + // and falls toward both ends. A half-ellipse dome reproduces that arc; + // the blur softens it into the rendered glow. + // + // Solved off the export's own path rather than estimated. Its cubic + // `C327.375 20.2369, 172.023 18.837, 39.3263 83.4893` bottoms out at + // t = 0.50983, y = 35.9785, and the flat bottom sits at y = 102.235; + // mapped through the layer's render bounds against the 188-tall card, + // that is 66.111% down for the peak and 1.354% past the bottom edge. + // Full width, no horizontal bleed — the path spans the card's 434 exactly + // (433.882 of it, 0.03% short on the right). + inset: "66.111% 0% -1.354% 0%", + borderRadius: "50% 50% 0 0 / 100% 100% 0 0", + background: theme.palette.gradients.gradientDashboardCardFillCyanPurple, + // The layer's OWN blur, and only that: the export carries + // `feGaussianBlur stdDeviation="17.9891"` — half the 35.978 layer-blur + // radius Figma reports, the same halving the backdrop blur goes through — + // and 17.9891 / 0.8928 = 20.15px. + // + // Do NOT fold the surface's 40px backdrop blur in on top. In Figma the + // flair sits behind the surface, so the order is `blur(20.15) -> composite + // over an opaque backdrop -> blur(40)`, and that second pass only softens + // edges. Composing them into sqrt(20.15^2 + 40^2) = 44.788 instead blurs + // the flair while it is still translucent, spreading its ALPHA rather than + // its colour — and against a crescent only ~66px tall that drains the glow. + filter: "blur(20.15px)", + opacity: 0.73, + pointerEvents: "none", + zIndex: 0, + }, + "&::after": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "1px", + background: theme.palette.gradients.gradientCardGlassBorder, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + zIndex: 0, + }, + // Both layers are positioned; keep the card's content above them. + "& > *": { + position: "relative", + zIndex: 1, + }, +}); + +/** + * Left-to-right fade across the photo — Figma `Rectangle 10` (274405:44237). + * + * This layer is missing from every `get_design_context` response for the card, + * because it exports as an SVG rather than as CSS. Read off that export, the + * ramp is horizontal (a 3deg tilt, dropped here) and its stops sit at 25.15% + * and 50.81% of the card width — the "till 50%" the design calls for. Figma + * declares the transparent stop as `#0e0d39`; at zero alpha it only affects + * interpolation, so the fade uses the surface colour it is ramping out of. + * + * Figma masks the photo to the right of the card and puts this ramp underneath + * it, running transparent -> opaque, so it backs the photo. Here the photo is a + * `cover` background that spans the whole card, so the ramp is oriented the + * other way and sits above the photo: it holds the flat surface colour across + * the left quarter, where the copy sits, and clears by the midpoint. Same + * rendered result, but it holds up for a background image of any size. + */ +export const cardImageSideFade = `linear-gradient(90deg, ${midnightGradientStops.welcomeCardStart} 25.15%, ${alpha(midnightGradientStops.welcomeCardStart, 0)} 50.81%)`; + +/** + * Background-image treatment. + * + * Figma: `Welcome Card` (274405:44234) inside the `Card with image` frame + * (274417:44476). Fill tokens `Gradient/Welcome-Card-BG-Dark` and + * `Gradient/Overlay-Black-Fade-In`. + * + * The design stacks the dark base gradient, the photo at 50% opacity, two + * scrims, then the content. The photo and the scrims need different opacities, + * and CSS cannot set per-layer opacity inside a single `background-image`, so + * they are painted as two pseudo-elements — `::before` for the photo, + * `::after` for both scrims. `overflow: hidden` clips them to the card radius. + * + * The scrims run in two directions: `Gradient/Overlay-Black-Fade-In` darkens + * the card from the top down, and `cardImageSideFade` holds the surface colour + * across the left, so the title and body copy always land on flat colour + * rather than on the picture. + * + * This surface also carries its own geometry (20px radius, 24px padding, 16px + * gap) rather than the 8px/16px/12px of `cardRootStyles`, because the design + * treats it as a larger promotional surface. + */ +export const cardImageStyles = (theme: Theme, image: string): CSSObject => ({ + backdropFilter: "blur(60px)", + background: theme.palette.gradients.gradientWelcomeCardBgDark, + borderRadius: "20px", + color: theme.palette.vars.baseTextStrong, + gap: "16px", + overflow: "hidden", + padding: "24px", + position: "relative", + "&::before": { + content: '""', + position: "absolute", + inset: 0, + backgroundImage: `url("${image}")`, + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + backgroundSize: "cover", + opacity: 0.5, + pointerEvents: "none", + zIndex: 0, + }, + "&::after": { + content: '""', + position: "absolute", + inset: 0, + // First layer paints on top: the vertical scrim over the horizontal one, + // matching the order the design stacks them in. + background: `${theme.palette.gradients.gradientOverlayBlackFadeIn}, ${cardImageSideFade}`, + pointerEvents: "none", + zIndex: 0, + }, + // The two layers are positioned, so without this they would paint above the + // card's non-positioned children. + "& > *": { + position: "relative", + zIndex: 1, + }, + /* + * Tertiary actions read as text on this surface, not as links on a page: + * `Interactive/Text In` rather than the variant's `Interactive/Primary` + * blue, which the scrim leaves sitting too dark against the photo. + * + * The variant's own rule is `&.MuiButton-tertariary` — the styled class plus + * the variant class, so two classes. Matching that from here with a + * descendant selector would only tie, leaving the winner up to emotion's + * injection order; naming `.MuiButton-root` as well takes it to three and + * settles it. + */ + "& .MuiButton-root.MuiButton-tertariary": { + color: theme.palette.vars.interactiveTextInDefault, + "&:hover": { + color: theme.palette.vars.interactiveTextInHover, + }, + "&:active": { + color: theme.palette.vars.interactiveTextInActive, + }, + }, +}); + export const cardSkeletonStyles = (theme: Theme): CSSObject => ({ backgroundColor: theme.palette.vars.baseBackgroundWeak, "&.MuiSkeleton-wave::after": { diff --git a/packages/open-ui-kit/src/components/card/types/index.ts b/packages/open-ui-kit/src/components/card/types/index.ts index 5151aa21..8f500529 100644 --- a/packages/open-ui-kit/src/components/card/types/index.ts +++ b/packages/open-ui-kit/src/components/card/types/index.ts @@ -11,12 +11,54 @@ import type { CardContentProps as MuiCardContentProps, CardHeaderProps as MuiCardHeaderProps, CardProps as MuiCardProps, + StackProps, TypographyProps, } from "@mui/material"; +/** + * Severity levels the alert card is designed for. + * + * Deliberately not the `Severity` enum from `@/common`: that has no `WARNING` + * member, and its other four levels have no design in `Alerts Card` + * (274421:47415). + */ +export type CardAlertSeverity = "warning" | "critical"; + export interface CardProps extends MuiCardProps { + /** + * Applies the alert treatment: a translucent surface at the larger alert + * radius and padding. `critical` additionally draws the rainbow gradient + * border; `warning` has no border. + * + * Pair with `CardAlertHeader`, which picks up the matching accent colour. + */ + alert?: CardAlertSeverity | undefined; /** Applies the disabled card treatment and marks the grouped content unavailable. */ disabled?: boolean; + /** + * Applies the graph-connector treatment: the `Graph-Connector` fill and glow + * gradients over a backdrop blur, edged with the matching 1px gradient + * stroke. A tighter 6px radius than the other treatments. + */ + connector?: boolean; + /** Applies the gradient border and blue glow treatment. */ + glow?: boolean; + /** + * Applies the frosted-glass treatment: the `Gradient/Card-Glass-BG` fill over + * a backdrop blur, with a white hairline and a soft drop shadow. + * + * The fill is translucent, so the card picks up whatever is behind it. + */ + glass?: boolean; + /** + * Background image URL. Applies the image treatment: the photo is layered + * over `Gradient/Welcome-Card-BG-Dark` and under a + * `Gradient/Overlay-Black-Fade-In` scrim, and the card switches to the + * larger radius and padding the design uses for these surfaces. + * + * Mutually exclusive with `glow`. + */ + image?: string | undefined; } /** Clickable wrapper for interactive cards. */ @@ -40,3 +82,10 @@ export interface CardSubheaderProps extends TypographyProps { /** Small supporting label, metadata, or date text. */ children: ReactNode; } + +export interface CardAlertHeaderProps extends StackProps { + /** Severity label, rendered in the parent card's accent colour. */ + children: ReactNode; + /** Right-aligned relative time, e.g. `4m ago`. */ + timestamp?: ReactNode; +} diff --git a/packages/open-ui-kit/src/components/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..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 @@ -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,162 @@ 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..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 @@ -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,117 @@ 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/gradients/theme-gradients.stories.tsx b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx new file mode 100644 index 00000000..5695c652 --- /dev/null +++ b/packages/open-ui-kit/src/components/gradients/theme-gradients.stories.tsx @@ -0,0 +1,194 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Meta, StoryObj } from "@storybook/react-vite"; +import { useTheme } from "@mui/material/styles"; +import { Box, Stack, Typography } from "@/components"; +import type { GradientVarsType } from "@/types/gradient-vars"; +import { DocsHeader } from "storybook/components/docs-header.stories"; + +type TokenKey = keyof GradientVarsType; + +// Strokes sit on a border, so they are previewed as a ring rather than a fill. +const STROKE_TOKENS: readonly TokenKey[] = [ + "gradientCardGlassBg", + "gradientCardGlassBgSubtle", + "gradientGlobalBorderFade", + "gradientGlobalBorderRainbow", + "gradientGlobalButtonPrimaryBorderGlow", + "gradientDashboardGraphNodeBorder", + "gradientGraphConnectorStroke", + "gradientIconButtonBlueGlow", + "gradientCardHighlightRadial", +]; + +const RADIAL_TOKENS: readonly TokenKey[] = [ + "gradientGlowOrange", + "gradientGlowGreen", + "gradientGlowRed", + "gradientGlowPinkShadow", + "gradientBackgroundGlowBlue", + "gradientPanelExecBorder", + "gradientPanelBorderBlueCyanDark", +]; + +function FillSwatch({ name, value }: { name: string; value: string }) { + const theme = useTheme(); + return ( + + + + {name} + + + ); +} + +function StrokeSwatch({ name, value }: { name: string; value: string }) { + const theme = useTheme(); + return ( + + + + {name} + + + ); +} + +function Group({ + title, + note, + tokens, + variant, +}: { + title: string; + note?: string | undefined; + tokens: [string, string][]; + variant: "fill" | "stroke"; +}) { + const theme = useTheme(); + const Swatch = variant === "stroke" ? StrokeSwatch : FillSwatch; + return ( + + + + {title} + + {note ? ( + + {note} + + ) : null} + + + {tokens.map(([name, value]) => ( + + ))} + + + ); +} + +const ThemeGradientsDemo = () => { + const theme = useTheme(); + const entries = Object.entries(theme.palette.gradients) as [string, string][]; + + const strokes = entries.filter(([k]) => + STROKE_TOKENS.includes(k as TokenKey), + ); + const radials = entries.filter(([k]) => + RADIAL_TOKENS.includes(k as TokenKey), + ); + const fills = entries.filter( + ([k]) => + !STROKE_TOKENS.includes(k as TokenKey) && + !RADIAL_TOKENS.includes(k as TokenKey), + ); + + return ( + + + + Theme gradients + + + Read from theme.palette.gradients. Switch the theme in the + toolbar — every swatch re-resolves with no code branching. Values are + designed for Midnight; Light, Dark and IoC currently fall back to a + provisional base set. + + + + + + + + ); +}; + +const meta: Meta = { + title: "Foundations/Theme Gradients", + component: ThemeGradientsDemo, + parameters: { + actions: { argTypesRegex: null }, + layout: "fullscreen", + docs: { + page: () => ( + + ), + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const All: Story = { + render: () => , +}; diff --git a/packages/open-ui-kit/src/components/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/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/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/icon/__tests__/custom-icons.test.ts b/packages/open-ui-kit/src/components/icon/__tests__/custom-icons.test.ts index fc06da1a..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 @@ -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,40 @@ function getSource(filePath: string) { return fs.readFileSync(filePath, "utf8"); } +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", () => { + it("never hardcodes SVG fill and stroke colors", () => { const badPaintAttributes = iconFiles.flatMap((filePath) => { const source = getSource(filePath); const matches = [ @@ -36,15 +72,7 @@ describe("custom icons", () => { ]; return matches - .filter((match) => { - const value = match[1].toLowerCase(); - return ( - value !== '"none"' && - value !== "'none'" && - value !== '"currentcolor"' && - value !== "'currentcolor'" - ); - }) + .filter((match) => !isAllowedPaint(match[1])) .map( (match) => `${path.relative(customIconsDir, filePath)}: ${match[0]}`, ); diff --git a/packages/open-ui-kit/src/components/index.ts b/packages/open-ui-kit/src/components/index.ts index ebca688f..9c017f29 100644 --- a/packages/open-ui-kit/src/components/index.ts +++ b/packages/open-ui-kit/src/components/index.ts @@ -64,12 +64,12 @@ export * from "./view-switcher"; export * from "./scroll-area"; export * from "./pagination"; export * from "./widget"; +export * from "./typography"; // MUI exports — only primitives with no local wrapper export { Box, Stack, - Typography, Grid, IconButton, Container, diff --git a/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx b/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx index 588d5ff0..621db608 100644 --- a/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx +++ b/packages/open-ui-kit/src/components/input-field/__tests__/input-field.test.tsx @@ -10,8 +10,9 @@ import "@testing-library/jest-dom"; import { ThemeMode, ThemeProvider } from "@/theme-provider/theme-provider"; import { darkTheme } from "@/theme/dark/dark-theme"; import { lightTheme } from "@/theme/light/light-theme"; +import { midnightTheme } from "@/theme/midnight/midnight-theme"; import { InputField } from ".."; -import { getInputFieldStyles } from "../styles"; +import { getInputFieldGlowStyles, getInputFieldStyles } from "../styles"; const renderInputField = ( props: React.ComponentProps, @@ -66,6 +67,52 @@ describe("InputField", () => { renderInputField({ label: "Label", type: "number", defaultValue: 1 }), ).not.toThrow(); }); + + }); + + describe("glow variant", () => { + it("renders without throwing", () => { + expect(() => + renderInputField({ label: "Label", glow: true }), + ).not.toThrow(); + expect(() => + renderInputField({ glow: true, placeholder: "Placeholder text" }), + ).not.toThrow(); + }); + + it("draws a borderless pill edged by the Input-Border-Blue ramp", () => { + expect(getInputFieldGlowStyles(midnightTheme)).toMatchObject({ + "&& .MuiInput-root": expect.objectContaining({ + border: "none", + borderRadius: "40px", + backgroundColor: "transparent", + "&::after": expect.objectContaining({ + background: + midnightTheme.palette.gradients.gradientInputBorderBlue, + maskComposite: "exclude", + }), + }), + }); + }); + + it("keeps the ring — rather than a solid border — on hover and focus", () => { + expect( + getInputFieldGlowStyles(midnightTheme)["&& .MuiInput-root"], + ).toMatchObject({ + "&:hover, &.Mui-focused": { border: "none" }, + }); + }); + + it("uses the exact stops documented for Midnight: white to #0a60ff", () => { + expect(midnightTheme.palette.gradients.gradientInputBorderBlue).toBe( + "linear-gradient(90deg, #ffffff 0%, #0a60ff 100%)", + ); + }); + + it("defines the token for every theme, not only Midnight", () => { + expect(lightTheme.palette.gradients.gradientInputBorderBlue).toBeTruthy(); + expect(darkTheme.palette.gradients.gradientInputBorderBlue).toBeTruthy(); + }); }); describe("token coverage", () => { diff --git a/packages/open-ui-kit/src/components/input-field/components/elements.tsx b/packages/open-ui-kit/src/components/input-field/components/elements.tsx index bfcd6681..7529e1d7 100644 --- a/packages/open-ui-kit/src/components/input-field/components/elements.tsx +++ b/packages/open-ui-kit/src/components/input-field/components/elements.tsx @@ -10,8 +10,11 @@ import { type TextFieldProps as MuiTextFieldProps, } from "@mui/material"; import type { ComponentType } from "react"; -import { getInputFieldStyles } from "../styles"; +import { getInputFieldGlowStyles, getInputFieldStyles } from "../styles"; -export const StyledInputField = styled(MuiTextField)(({ theme }) => - getInputFieldStyles(theme), -) as ComponentType; +export const StyledInputField = styled(MuiTextField, { + shouldForwardProp: (prop) => prop !== "glow", +})<{ glow?: boolean }>(({ theme, glow }) => ({ + ...getInputFieldStyles(theme), + ...(glow ? getInputFieldGlowStyles(theme) : {}), +})) as ComponentType; diff --git a/packages/open-ui-kit/src/components/input-field/components/input-field.tsx b/packages/open-ui-kit/src/components/input-field/components/input-field.tsx index ef461d59..df41e028 100644 --- a/packages/open-ui-kit/src/components/input-field/components/input-field.tsx +++ b/packages/open-ui-kit/src/components/input-field/components/input-field.tsx @@ -13,9 +13,10 @@ const toSxArray = (sx: SxProps | undefined) => Array.isArray(sx) ? sx : sx ? [sx] : []; export const InputField = React.forwardRef( - ({ slotProps, sx, variant = "standard", ...props }, ref) => ( + ({ glow, slotProps, sx, variant = "standard", ...props }, ref) => ( ( + + + + ), +}; + export const Focused: Story = { args: { defaultValue: "Entered text", diff --git a/packages/open-ui-kit/src/components/input-field/styles/index.ts b/packages/open-ui-kit/src/components/input-field/styles/index.ts index 7b028862..2197fbaf 100644 --- a/packages/open-ui-kit/src/components/input-field/styles/index.ts +++ b/packages/open-ui-kit/src/components/input-field/styles/index.ts @@ -160,6 +160,59 @@ export const getInputFieldStyles = (theme: Theme): CSSObject => ({ }, }); +/** + * Glow treatment: gradient border — Figma `Input Field` (274417:44475). + * + * A pill-shaped prompt field edged with `Gradient/Input-Border-Blue`, whose + * swatch reads "DARK original: FFFFFF -> 0A60FF". The token is new to the + * theme but introduces no new colour — both stops already existed. + * + * The ramp cannot be a `border-color`, so the border is drawn as a 1px + * mask-composite ring on the input root, matching how the other gradient + * borders in the kit are built. No `overflow: hidden`, which would thin the + * ring's arcs — and at this radius the arcs are most of the outline. + * + * Keyed on `&&` rather than `&` so it layers over `getInputFieldStyles` + * instead of replacing its `.MuiInput-root` block: the two objects are spread + * together, and a matching key would drop the base field's typography, height + * and underline resets wholesale. + */ +export const getInputFieldGlowStyles = (theme: Theme): CSSObject => ({ + "&& .MuiInput-root": { + position: "relative", + border: "none", + // Figma's 40px radius on a 40px-tall field — a full pill. + borderRadius: "40px", + padding: "8px 16px", + backgroundColor: "transparent", + + "&::after": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "1px", + background: theme.palette.gradients.gradientInputBorderBlue, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + // MUI's underline pseudo-elements are already neutralised above; this + // one is the ring, so it must not be caught by those resets. + borderBottom: "0 !important", + transform: "none !important", + zIndex: 0, + }, + + // The field keeps its ring on hover and focus rather than swapping to the + // control border tokens the standard field uses. + "&:hover, &.Mui-focused": { + border: "none", + }, + }, +}); + export const getStoryFocusedSx = (theme: Theme) => ({ "& .MuiInput-root": { diff --git a/packages/open-ui-kit/src/components/input-field/types/index.ts b/packages/open-ui-kit/src/components/input-field/types/index.ts index e05d21f3..816faa70 100644 --- a/packages/open-ui-kit/src/components/input-field/types/index.ts +++ b/packages/open-ui-kit/src/components/input-field/types/index.ts @@ -19,4 +19,9 @@ export type InputFieldProps = MuiTextFieldProps & { error?: MuiTextFieldProps["error"]; /** Custom MUI slot props, merged with the internal shrunk label behavior. */ slotProps?: MuiTextFieldProps["slotProps"]; + /** + * Applies the glow treatment: a pill-shaped field edged with the + * `Input-Border-Blue` ramp, for prompt-style inputs. + */ + glow?: boolean; }; diff --git a/packages/open-ui-kit/src/components/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/__tests__/navigation.test.tsx b/packages/open-ui-kit/src/components/navigation/__tests__/navigation.test.tsx index efa8b3e8..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, @@ -168,15 +169,44 @@ 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, + }); + // 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({ @@ -208,8 +238,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", @@ -218,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 c13af469..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 ? ( <> @@ -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,25 @@ 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 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.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.baseBackgroundHover, + backgroundColor: theme.palette.vars.brandBackgroundPrimaryMedium, + color: selected + ? theme.palette.vars.brandTextPrimary + : theme.palette.vars.brandTextSecondary, }, }); 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 ( { }); }); }); + + // Figma: `Toast message Glow` (274417:44480), its two `Text Card` instances. + describe("glow treatment", () => { + it("draws the border as a gradient ring, not a border property", () => { + const styles = toastGlowStyle(midnightTheme, true); + const ring = styles["&::before"] as Record; + + // The section documents one gradient and points it at both instances. + // It already exists in the theme, so the treatment adds none. + expect(ring.background).toBe( + midnightTheme.palette.gradients.gradientGlobalBorderFade, + ); + // A gradient cannot be a `border-color`, hence the ring. + expect(styles.border).toBe("none"); + expect(ring.padding).toBe("1px"); + expect(ring.maskComposite).toBe("exclude"); + expect(ring.borderRadius).toBe("inherit"); + // Would thin the ring's corner arcs. + expect(styles.overflow).toBeUndefined(); + expect(styles["& > *"]).toEqual({ position: "relative", zIndex: 1 }); + }); + + it("strengthens the glow when the toast has a header", () => { + expect(toastGlowStyle(midnightTheme, true).boxShadow).toBe( + toastGlowStrong, + ); + expect(toastGlowStyle(midnightTheme, false).boxShadow).toBe(toastGlow); + // Same offset and blur; only the alpha differs between the instances. + expect(toastGlow).toBe("0px -1px 34px rgba(10, 96, 255, 0.2)"); + expect(toastGlowStrong).toBe("0px -1px 34px rgba(10, 96, 255, 0.4)"); + }); + + it("pads to the 18px the frame uses, not the standard toast padding", () => { + expect(toastGlowStyle(midnightTheme, true).padding).toBe("18px"); + expect(toastRootStyle(midnightTheme, "default").padding).toBe( + "12px 16px", + ); + }); + + it("renders both header states without throwing", () => { + expect(() => + renderToast({ + id: "glow-header", + glow: true, + title: "New Successful Reasoning Strategy", + description: "A new successful reasoning strategy cluster emerged.", + }), + ).not.toThrow(); + expect( + screen.getByText("New Successful Reasoning Strategy"), + ).toBeInTheDocument(); + + // The helper defaults a title in, so clear it for the no-header case. + expect(() => + renderToast({ + id: "glow-no-header", + glow: true, + title: undefined, + description: "User queries naturally organize around travel.", + }), + ).not.toThrow(); + expect( + screen.getByText("User queries naturally organize around travel."), + ).toBeInTheDocument(); + }); + }); }); diff --git a/packages/open-ui-kit/src/components/toast/components/elements.tsx b/packages/open-ui-kit/src/components/toast/components/elements.tsx index 8c0cfdb8..e55feab9 100644 --- a/packages/open-ui-kit/src/components/toast/components/elements.tsx +++ b/packages/open-ui-kit/src/components/toast/components/elements.tsx @@ -8,6 +8,7 @@ import { Alert, styled, Theme, type AlertProps } from "@mui/material"; import type { ComponentType } from "react"; import { ToastType } from "../types"; import { + toastGlowStyle, toastIconSlotStyle, toastMessageSlotStyle, toastRootStyle, @@ -21,28 +22,40 @@ import { export const StyledToast = styled(Alert, { shouldForwardProp: (prop) => - prop !== "type" && prop !== "hasTitle" && prop !== "hasAction", -})<{ type?: ToastType; hasTitle?: boolean; hasAction?: boolean }>( - ({ theme, type, hasTitle, hasAction }) => ({ - ...toastRootStyle(theme as Theme, type, hasTitle, hasAction), - "& .MuiAlertTitle-root, & .MuiAlert-message": { - margin: 0, - }, - "& .MuiAlert-icon": { - ...toastIconSlotStyle(theme as Theme, type), - }, - "& .MuiAlert-action": { - display: "none", - }, - "& .MuiAlert-message": { - ...toastMessageSlotStyle, - }, - "& .MuiAlert-icon + .MuiAlert-message": { - margin: 0, - }, - }), -) as ComponentType< - AlertProps & { type?: ToastType; hasTitle?: boolean; hasAction?: boolean } + prop !== "type" && + prop !== "hasTitle" && + prop !== "hasAction" && + prop !== "glow", +})<{ + type?: ToastType; + hasTitle?: boolean; + hasAction?: boolean; + glow?: boolean; +}>(({ theme, type, hasTitle, hasAction, glow }) => ({ + ...toastRootStyle(theme as Theme, type, hasTitle, hasAction), + ...(glow ? toastGlowStyle(theme as Theme, hasTitle) : {}), + "& .MuiAlertTitle-root, & .MuiAlert-message": { + margin: 0, + }, + "& .MuiAlert-icon": { + ...toastIconSlotStyle(theme as Theme, type), + }, + "& .MuiAlert-action": { + display: "none", + }, + "& .MuiAlert-message": { + ...toastMessageSlotStyle, + }, + "& .MuiAlert-icon + .MuiAlert-message": { + margin: 0, + }, +})) as ComponentType< + AlertProps & { + type?: ToastType; + hasTitle?: boolean; + hasAction?: boolean; + glow?: boolean; + } >; export const IconToast = ({ type }: { type?: ToastType }) => { diff --git a/packages/open-ui-kit/src/components/toast/components/toast.tsx b/packages/open-ui-kit/src/components/toast/components/toast.tsx index 575eaa3e..cd87ca9f 100644 --- a/packages/open-ui-kit/src/components/toast/components/toast.tsx +++ b/packages/open-ui-kit/src/components/toast/components/toast.tsx @@ -31,6 +31,7 @@ export const Toast = ({ action, id, customActions, + glow, ...props }: ToastProps) => { const [show, setShow] = React.useState(true); @@ -58,6 +59,7 @@ export const Toast = ({ type={type} hasTitle={Boolean(title)} hasAction={Boolean(action)} + glow={glow} icon={} > diff --git a/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx b/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx index d8016a47..a9d4bbcd 100644 --- a/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx +++ b/packages/open-ui-kit/src/components/toast/stories/toast.stories.tsx @@ -35,6 +35,11 @@ const meta = { }, title: { control: "text" }, description: { control: "text" }, + glow: { + control: "boolean", + description: + "Applies the glow treatment: a `Global-Border/Fade` gradient border and a blue glow. Stronger when the toast also has a title.", + }, showCloseButton: { control: "boolean" }, useNativeClose: { control: "boolean" }, action: { control: false }, @@ -71,6 +76,41 @@ export const WithoutTitle: Story = { }, }; +/** + * Toast message Glow — Figma `Toast message Glow` (274417:44480), with header. + * + * A `Global-Border/Fade` gradient border and a blue glow cast from behind the + * toast. With a header the glow is the stronger of the two values the frame + * documents. + */ +export const GlowWithHeader: Story = { + name: "Glow / With header", + args: { + id: "glow-with-header", + glow: true, + title: "New Successful Reasoning Strategy", + description: + "A new successful reasoning strategy cluster emerged this week.", + action: undefined, + }, +}; + +/** + * Toast message Glow — the same treatment with no header, carrying the softer + * of the two glow values. + */ +export const GlowWithoutHeader: Story = { + name: "Glow / Without header", + args: { + id: "glow-without-header", + glow: true, + title: undefined, + description: + "User queries naturally organize around long-distance travel coordination and local route optimization, with inter-city planning emerging as the primary interaction theme.", + action: undefined, + }, +}; + export const Success: Story = { args: { id: "success", diff --git a/packages/open-ui-kit/src/components/toast/styles/index.ts b/packages/open-ui-kit/src/components/toast/styles/index.ts index 2e72f23c..7060676d 100644 --- a/packages/open-ui-kit/src/components/toast/styles/index.ts +++ b/packages/open-ui-kit/src/components/toast/styles/index.ts @@ -6,6 +6,7 @@ import type { CSSObject, Theme } from "@mui/material"; import type { ToastType } from "../types"; +import { toastGlow, toastGlowStrong } from "@/theme/style/color-palette"; const isStatusToast = (type?: ToastType) => type && type !== "default"; @@ -77,6 +78,54 @@ export const toastRootStyle = ( }; }; +/** + * Glow treatment — Figma `Toast message Glow` (274417:44480). + * + * The section documents one gradient, `Gradient/Global-Border/Fade`, and points + * it at both toast instances. It is already in the theme as + * `gradientGlobalBorderFade`, so this adds no new gradient. It runs dim on the + * left to bright blue on the right, matching the instances. + * + * Two things carry the treatment: a 1px gradient border and a blue glow cast + * upward from behind the toast. The glow is the stronger of the two documented + * values when the toast has a header and the softer one when it does not — + * which is exactly how the two instances in the frame differ. + * + * The border is a mask-composite ring rather than a `border`, since a gradient + * cannot be assigned to `border-color`. No `overflow: hidden`, which would + * thin the ring's corner arcs. + */ +export const toastGlowStyle = ( + theme: Theme, + hasTitle?: boolean, +): CSSObject => ({ + border: "none", + borderLeftWidth: 0, + // Figma pads these 18px rather than the 12/16 the standard toast uses. + padding: "18px", + position: "relative", + boxShadow: hasTitle ? toastGlowStrong : toastGlow, + "&::before": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + padding: "1px", + background: theme.palette.gradients.gradientGlobalBorderFade, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + pointerEvents: "none", + zIndex: 0, + }, + // The ring is positioned, so lift the toast's own slots above it. + "& > *": { + position: "relative", + zIndex: 1, + }, +}); + export const toastIconSlotStyle = ( theme: Theme, type?: ToastType, diff --git a/packages/open-ui-kit/src/components/toast/types/index.ts b/packages/open-ui-kit/src/components/toast/types/index.ts index 0b23ecfa..2889dcd0 100644 --- a/packages/open-ui-kit/src/components/toast/types/index.ts +++ b/packages/open-ui-kit/src/components/toast/types/index.ts @@ -27,6 +27,12 @@ export interface ToastProps extends Omit< type?: ToastType; /** Optional bold title line rendered above the message. */ title?: string; + /** + * Applies the glow treatment: a `Global-Border/Fade` gradient border and a + * blue glow cast from behind the toast. The glow is stronger when the toast + * also has a `title`. + */ + glow?: boolean; /** Optional body message shown in the toast content area. */ description?: string; /** Shows the close button when true. */ diff --git a/packages/open-ui-kit/src/components/typography/__tests__/typography.test.tsx b/packages/open-ui-kit/src/components/typography/__tests__/typography.test.tsx new file mode 100644 index 00000000..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 new file mode 100644 index 00000000..8919fd37 --- /dev/null +++ b/packages/open-ui-kit/src/components/typography/components/elements.tsx @@ -0,0 +1,53 @@ +/* + * 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`. + * + * 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", +})(({ theme, gradient }) => ({ + ...(gradient && { + background: theme.palette.gradients.gradientTextWhiteBlue, + 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", + }), +})) 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/custom-icons/brand-logos.tsx b/packages/open-ui-kit/src/custom-icons/brand-logos.tsx index d7bf277a..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,13 +4,19 @@ * 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/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 ( + + + + ); } diff --git a/packages/open-ui-kit/src/index.ts b/packages/open-ui-kit/src/index.ts index c8d10a4b..f7f13384 100644 --- a/packages/open-ui-kit/src/index.ts +++ b/packages/open-ui-kit/src/index.ts @@ -17,7 +17,11 @@ 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 { 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-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/dark/dark-theme.tsx b/packages/open-ui-kit/src/theme/dark/dark-theme.tsx index 3be02186..aa5c4369 100644 --- a/packages/open-ui-kit/src/theme/dark/dark-theme.tsx +++ b/packages/open-ui-kit/src/theme/dark/dark-theme.tsx @@ -31,6 +31,7 @@ import { Shadows, } from "@mui/material"; import { darkVars } from "./dark-vars"; +import { baseGradientVars } from "@/theme/style/gradient-vars-base"; import { buttonComponent, inputComponents, @@ -65,6 +66,7 @@ const palette: PaletteOptions = { orange: orangePalette, grey: greyPalette, vars: darkVars, + gradients: baseGradientVars, text: { primary: darkVars.baseTextStrong, secondary: darkVars.baseTextDefault, diff --git a/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx b/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx index 8247e559..738e505c 100644 --- a/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx +++ b/packages/open-ui-kit/src/theme/ioc/ioc-theme.tsx @@ -21,6 +21,7 @@ import { Shadows, } from "@mui/material"; import { iocVars } from "./ioc-vars"; +import { baseGradientVars } from "@/theme/style/gradient-vars-base"; import { iocTealPalette, iocBluePalette, @@ -70,6 +71,7 @@ const palette: PaletteOptions = { orange: orangePalette, grey: greyPalette, vars: iocVars, + gradients: baseGradientVars, text: { primary: iocTextPrimary, secondary: iocTextSecondary, @@ -142,3 +144,4 @@ const iocThemeOptions: ThemeOptions = { export const iocTheme: Theme = createTheme(baseTheme, iocThemeOptions); iocTheme.palette.vars = iocVars; +iocTheme.palette.gradients = baseGradientVars; diff --git a/packages/open-ui-kit/src/theme/light/light-theme.tsx b/packages/open-ui-kit/src/theme/light/light-theme.tsx index 76a0bded..04b4a433 100644 --- a/packages/open-ui-kit/src/theme/light/light-theme.tsx +++ b/packages/open-ui-kit/src/theme/light/light-theme.tsx @@ -32,6 +32,7 @@ import { import { commonMixins, breakpoints } from "@/theme/style/common"; import { typography } from "@/theme/style/typography"; import { lightVars } from "./light-vars"; +import { baseGradientVars } from "@/theme/style/gradient-vars-base"; import { buttonComponent, inputComponents, @@ -66,6 +67,7 @@ const palette: PaletteOptions = { orange: orangePalette, grey: greyPalette, vars: lightVars, + gradients: baseGradientVars, text: { primary: greyPalette[500], secondary: greyPalette[50], diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts new file mode 100644 index 00000000..5035d892 --- /dev/null +++ b/packages/open-ui-kit/src/theme/midnight/midnight-gradient-vars.ts @@ -0,0 +1,204 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { alpha } from "@mui/material/styles"; +import type { GradientVarsType } from "@/types/gradient-vars"; +import { + baseGradientVars, + gradientButtonPrimaryBorderGlow, + gradientButtonPrimaryFill, + gradientCardInsightBorder, + gradientTextWhiteBlue, +} from "@/theme/style/gradient-vars-base"; +import { gradientsRedPressed } from "@/theme/style/gradients"; +import { + blue300, + blue500, + midnightGradientStops as stops, + teal300, +} from "@/theme/style/color-palette"; + +/* + * Midnight gradient unique tokens + */ +export const midnightGradientVars: GradientVarsType = { + ...baseGradientVars, + + // --- Fills --------------------------------------------------------------- + + gradientGaugeArcAmber: `linear-gradient(90deg, ${stops.gaugeArcAmber} 0%, ${alpha(stops.gaugeArcAmber, 0.78)} 100%)`, + gradientGaugeArcTeal: `linear-gradient(90deg, ${stops.gaugeArcTealStart} 0%, ${stops.gaugeArcTealEnd} 100%)`, + gradientIconSubtractBlue: `linear-gradient(90deg, ${stops.iconSubtractBlue} 0%, ${blue500} 100%)`, + + // TODO(verify): 170.415deg read from a 2:1 swatch. + gradientDataVizCyanTeal: `linear-gradient(170.415deg, ${alpha(stops.dataVizCyan, 0.6)} 13.912%, ${alpha(stops.dataVizTeal, 0.6)} 82.071%)`, + gradientDataVizCyanBlue: `linear-gradient(90deg, ${teal300} 0%, ${blue300} 100%)`, + + gradientDataVizPinkMagenta: `linear-gradient(180deg, ${alpha(stops.dataVizPink, 0.7)} 0%, ${alpha(stops.dataVizMagenta, 0.7)} 100%)`, + // Figma labels this `Data-Viz-Pink-Magenta`, but the stops are orange. + gradientDataVizOrangeAmber: `linear-gradient(180deg, ${alpha(stops.dataVizOrange, 0.7)} 0%, ${alpha(stops.dataVizAmber, 0.7)} 100%)`, + // Figma labels this `Data-Viz-Pink-Magenta`; one stop, so it renders flat. + gradientDataVizPinkFlat: `linear-gradient(90deg, ${alpha(stops.dataVizPink, 0.2)} 0%, ${alpha(stops.dataVizPink, 0.2)} 50%)`, + + gradientDataVizPinkPurple: `linear-gradient(180deg, ${alpha(stops.dataVizPink, 0.2)} 11.117%, ${alpha(stops.dataVizPurple, 0.2)} 111.54%)`, + gradientDataVizOrangeGold: `linear-gradient(180deg, ${alpha(stops.dataVizOrange, 0.2)} 11.117%, ${alpha(stops.dataVizGold, 0.2)} 111.54%)`, + gradientDataVizBlueDark: `linear-gradient(180deg, ${alpha(stops.dataVizBlue, 0.2)} 0%, ${alpha(blue300, 0.2)} 100%)`, + + // TODO(verify): 10.276deg read from a 2:1 swatch. + gradientProgressBarTeal: `linear-gradient(10.276deg, ${alpha(stops.dataVizFadeGray, 0)} 32.561%, ${stops.dataVizMint} 69.418%)`, + + gradientGraphFlow: `linear-gradient(90deg, ${alpha(stops.graphFlowGray, 0.2)} 0%, ${alpha(stops.graphFlowTeal, 0.2)} 50%)`, + gradientGraphFlowPink: `linear-gradient(90deg, ${alpha(stops.graphFlowGray, 0.2)} 0%, ${alpha(stops.dataVizPink, 0.2)} 50%)`, + // Single stop in Figma, so this is a flat fill rather than a ramp. + gradientGraphFlowTeal: alpha(stops.graphFlowTeal, 0.2), + // Figma reuses `Graph-Flow` for this distinct maroon ramp. + gradientGraphFlowMaroon: `linear-gradient(90deg, ${alpha(stops.graphFlowMaroon, 0.2)} 0%, ${alpha(stops.graphFlowTeal, 0.2)} 50%)`, + + // Shared with every theme — see `gradient-vars-base.ts`. + gradientGlobalButtonPrimaryFill: gradientButtonPrimaryFill, + // Shared with every theme — see `gradient-vars-base.ts`. + gradientTextWhiteBlue, + gradientGlobalDividerFade: `linear-gradient(90deg, ${stops.panelExecBorderBlue} 0%, ${stops.dataVizCyan} 0%, ${stops.dataVizCyan} 21.692%, ${stops.dataVizOrange} 55.827%, ${stops.globalDividerPink} 100%)`, + + gradientOverlayBlackFadeIn: `linear-gradient(180deg, ${alpha(stops.overlayBlack, 0.65)} 52.404%, ${alpha(stops.overlayGray, 0)} 100%)`, + // TODO(verify): 146.411deg read from a 2:1 swatch. + gradientWelcomeCardBgDark: `linear-gradient(146.411deg, ${stops.welcomeCardStart} 64.87%, ${alpha(stops.welcomeCardEnd, 0.5)} 91.196%)`, + + // Identical stops and offsets to the existing `gradientsRedPressed` token, so + // it is reused to preserve the canonical 124.96deg angle. Figma exported this + // as 144.426deg because its angles are aspect-ratio dependent (2:1 swatch). + gradientAlertLineRed: gradientsRedPressed, + + // TODO(verify): 150.642deg read from a 2:1 swatch. Two stacked fills. + gradientDashboardGraphNodeFill: `linear-gradient(90deg, ${alpha(stops.overlayBlack, 0.2)} 0%, ${alpha(stops.overlayBlack, 0.2)} 100%), linear-gradient(150.642deg, ${alpha(stops.graphNodeFillBlue, 0.09)} 26.471%, ${alpha(stops.graphNodeFillDark, 0.15)} 88.419%)`, + /* + * Graph-connector card family — Figma `Section 3` (274455:54313). + * + * The TODO that used to sit here ("166.51deg read from a 2:1 swatch") is + * resolved: the card in that section exports as an SVG carrying all three + * gradient defs verbatim, so these are exact rather than sampled. + * + * The angle differs from the old swatch reading because Figma normalises a + * gradient transform to its layer box — the same caveat recorded on + * `gradientCardInsightBorder`. On the 215x207 card the fill vector runs + * (0,4) -> (116.6,256.4), which is 155.21deg in CSS; the 120x60 swatch + * squeezes that to the 166.51deg previously recorded. The card is the real + * usage, so it wins. + * + * Both fills carry `fill-opacity="0.16"` in the export — the "(16%)" on the + * swatch labels. That is baked into the stops here rather than left for the + * consumer: 0.22 -> 0.035, 0.10 -> 0.016, 0.40 -> 0.064, 0.05 -> 0.008. + */ + gradientGraphConnectorFill: `linear-gradient(155.21deg, ${alpha(stops.graphConnectorBlue, 0.035)} 0%, ${alpha(stops.graphConnectorBlue, 0.016)} 100%)`, + // Anchored bottom-centre, radii 85% of the card in both axes — the export + // rotates a (176.14, 182.947) scale by -90deg about (107.5, 211). + gradientGraphConnectorGlow: `radial-gradient(85% 85% at 50% 100%, ${alpha(stops.graphConnectorBlue, 0.064)} 0%, ${alpha(stops.graphConnectorBlue, 0.008)} 100%)`, + + gradientIconButtonBlue: `linear-gradient(180deg, ${stops.iconButtonBlueStart} 0%, ${stops.iconButtonBlueMid} 54.12%, ${stops.iconButtonBlueEnd} 120.67%)`, + + // --- Strokes ------------------------------------------------------------- + // Applied to borders — needs `border-image` or a padding-box/border-box + // double-background; a gradient string alone will not render as a border. + // + // The Figma MCP server flattens a gradient stroke to its first stop, so these + // were recovered by rendering each swatch and sampling the border band pixel + // by pixel, then un-compositing against the card background. Direction and + // stop offsets below are measured, not guessed. All nine are horizontal. + + /* + * Glass card family — Figma `Glass Card` (274490:55387). + * + * These are EXACT, not sampled: the updated frame exports the card surface + * and its flair as SVGs whose gradient defs can be read directly (nodes + * 274490:55140 and 274490:55139, at the mockup's 0.8928 scale). An earlier + * linear 5% -> 35% reading of the fill was a pixel-fit of this same radial: + * a corner-anchored radial sampled along one row degrades to exactly that + * ramp, and both agree with the published `Card-Glass-BG/Stop-0-White-5%` + * variable at the far corner. + * + * The fill is a radial anchored at the card's TOP-RIGHT corner (the SVG + * centres it at its own origin and the mockup rotates it 90deg clockwise), + * with both radii spanning the full card. Behind it sits the Glow-Teal + * flair, and the whole surface backdrop-blurs at `cardGlassBlur`. + */ + gradientCardGlassBg: `radial-gradient(100% 100% at 100% 0%, ${alpha(stops.glassWhite, 0.4)} 0%, ${alpha(stops.glassWhite, 0.05)} 100%)`, + // The "70%" the old swatch label carried, applied to the fill: the value to + // reach for on an already-light surface. + gradientCardGlassBgSubtle: `radial-gradient(100% 100% at 100% 0%, ${alpha(stops.glassWhite, 0.28)} 0%, ${alpha(stops.glassWhite, 0.035)} 100%)`, + // `Card-Glass-BORDER`: the hairline is a vertical ramp that holds white 30% + // over the upper card and fades out entirely by the bottom edge. From the + // SVG stroke def (offsets 10% / 75% / 100% after the 90deg rotation). + gradientCardGlassBorder: `linear-gradient(180deg, ${alpha(stops.glassWhite, 0.3)} 10%, ${alpha(stops.glassWhiteWeak, 0.3)} 75%, ${alpha(stops.glassGray, 0)} 100%)`, + // `Dashboard-Card/Fill/Cyan-Purple`: the flair behind the glass blur. The swatch says + // "Radial" but the layer's own def is linear (same label drift as + // `gradientCardHighlightRadial` below); cyan at the top of the streak, + // periwinkle at the bottom. Alphas are part of the token (37% / 73%). + // + // The negative first offset is the whole point of this token. In Figma the + // ramp is defined over the LAYER box (121.4132 tall, y -19.1782 -> 102.235 in + // the export of node 274666:38565), but the crescent actually painted inside + // that box only spans y 35.9785 -> 102.235 — its path's cubic + // `C327.375 20.2369, 172.023 18.837, 39.3263 83.4893` bottoms out at + // t = 0.50983, well short of the control points near y 19. + // + // So the visible flair never shows pure cyan: its top edge already sits + // (35.9785 + 19.1782) / 121.4132 = 45.429% of the way to periwinkle. + // Anchoring the ramp at -83.247% reproduces that slice over a box that IS + // the crescent — solve (0 - p) / (100 - p) = 0.45429 for p. + gradientDashboardCardFillCyanPurple: `linear-gradient(180deg, ${alpha(stops.glassGlowCyan, 0.37)} -83.247%, ${alpha(stops.glassGlowPeriwinkle, 0.73)} 100%)`, + // `Card-Glass-CTA-Glow`: mint -> blue -> pink -> gold sweep behind the glass + // CTA button, designed to be layer-blurred (~45) by the consumer. Geometry is + // the flattened export's own: centre and radii normalised to its viewBox. + gradientCardGlassCtaGlow: `radial-gradient(87% 72% at 6.5% 14%, ${stops.glassCtaMint} 44%, ${stops.glassCtaBlue} 50%, ${stops.dataVizPink} 58%, ${stops.glassCtaGold} 99%)`, + + // Measured: the blue stop renders #0a60ff, not the #0a66ff shown on the label. + // Ramps slate to blue, matching the toast instances that use it — the swatch + // preview runs the other way, but the applied instances are authoritative. + gradientGlobalBorderFade: `linear-gradient(90deg, ${alpha(stops.globalBorderSlateWeak, 0.7)} 10%, ${stops.globalBorderSlate} 31%, ${stops.buttonPrimaryGlowCyan} 51%, ${stops.panelExecBorderBlue} 79%)`, + // `Input-Border-Blue` — Figma `Input Field` (274417:44475), swatch labelled + // "DARK original: FFFFFF -> 0A60FF". Both stops already exist, so this adds + // no new colour. Sampling the rendered field's top border confirms a plain + // horizontal ramp: near-white at 13% across, (130,173,254) at 50%, and + // (39,114,255) at 88%, extrapolating to #0a60ff at the right edge. + gradientInputBorderBlue: `linear-gradient(90deg, ${stops.glassWhite} 0%, ${stops.panelExecBorderBlue} 100%)`, + // Figma reuses `Global-Border/Fade` for this distinct rainbow ramp. + gradientGlobalBorderRainbow: `linear-gradient(90deg, ${stops.panelExecBorderBlue} 0%, ${stops.dataVizCyan} 33%, ${stops.globalDividerPink} 83%, ${stops.dataVizOrange} 100%)`, + + // Shared with every theme — see `gradient-vars-base.ts`. + gradientGlobalButtonPrimaryBorderGlow: gradientButtonPrimaryBorderGlow, + gradientDashboardGraphNodeBorder: `linear-gradient(90deg, ${alpha(stops.globalBorderSlateWeak, 0.7)} 0%, ${stops.globalBorderSlate} 100%)`, + // Alpha ramps UP left to right (6% -> 16%), opposite to the label order. + // Originally measured by sampling; the card SVG in `Section 3` + // (274455:54313) exports this stroke def verbatim and confirms it exactly, + // including the pure-horizontal vector (0,107.5) -> (215,107.5). + gradientGraphConnectorStroke: `linear-gradient(90deg, ${alpha(stops.graphConnectorBlue, 0.06)} 0%, ${alpha(stops.graphConnectorBlue, 0.16)} 100%)`, + gradientIconButtonBlueGlow: `linear-gradient(90deg, ${stops.iconButtonGlowBlue} 0%, ${alpha(stops.iconButtonGlowBlue, 0)} 100%)`, + // Named `Card-Highlight-Radial` in Figma, but the swatch fill is linear. + // Measured: starts fully opaque, not at the 70% the label implies. + // Vertical, not horizontal: the swatch rectangle is drawn sideways, but the + // only place the ramp is applied — the Activity Timeline rail (274455:53823) + // — runs it top to bottom. Same swatch-vs-instance split as the toast border. + gradientCardHighlightRadial: `linear-gradient(180deg, ${stops.glassWhite} 0%, ${alpha(stops.glassGray, 0)} 100%)`, + + // --- Radial glows -------------------------------------------------------- + // Figma's intermediate stops are linear interpolations between the designed + // stops, so only the designed stops are kept. + + // The three status glows share one geometry: a circle centred on the top-left + // corner, matching the dot swatches. `farthest-side` because the timeline dot + // export (274455:53827) puts the end stop one box-width from that corner, not + // one diagonal — CSS would otherwise default to `farthest-corner` and stretch + // the ramp 41% too far, so the end colour never actually landed. + gradientGlowOrange: `radial-gradient(circle farthest-side at 0% 0%, ${alpha(stops.glowOrangeStart, 0.8)} 0%, ${alpha(stops.glowOrangeEnd, 0.8)} 100%)`, + gradientGlowGreen: `radial-gradient(circle farthest-side at 0% 0%, ${alpha(stops.glowGreenStart, 0.7)} 0%, ${alpha(stops.glowGreenEnd, 0.7)} 100%)`, + gradientGlowRed: `radial-gradient(circle farthest-side at 0% 0%, ${alpha(stops.glowRedStart, 0.4)} 0%, ${alpha(stops.glowRedEnd, 0.4)} 100%)`, + gradientGlowPinkShadow: `radial-gradient(ellipse at 29% -56%, ${alpha(stops.dataVizPink, 0.5)} 0%, ${alpha(stops.overlayBlack, 0.6)} 100%)`, + gradientBackgroundGlowBlue: `radial-gradient(ellipse 50% 100% at 50% 50%, ${alpha(stops.glowBlueStart, 0.3)} 0%, ${alpha(stops.glowBlueMid, 0.3)} 26.923%, ${alpha(stops.glowBlueDeep, 0.3)} 55.769%, ${alpha(stops.glowBlueEnd, 0)} 82.692%)`, + // Measured, not radial despite the swatch label — see `gradient-vars-base.ts`. + // Shared with every theme. + gradientPanelExecBorder: gradientCardInsightBorder, + gradientPanelBorderBlueCyanDark: `radial-gradient(ellipse at 50% 50%, ${stops.panelExecBorderBlue} 0%, ${stops.panelBorderCyanDark} 100%)`, +}; diff --git a/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx b/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx new file mode 100644 index 00000000..ed76ce6f --- /dev/null +++ b/packages/open-ui-kit/src/theme/midnight/midnight-theme.tsx @@ -0,0 +1,131 @@ +/* + * 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 { midnightGradientVars } from "./midnight-gradient-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, + gradients: midnightGradientVars, + 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..0bef9bdb --- /dev/null +++ b/packages/open-ui-kit/src/theme/midnight/midnight-vars.ts @@ -0,0 +1,118 @@ +/* + * 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, + surfaceDark900, +} 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["alpha40"], + interactivePrimaryWeakDefault: darkNavyPalette[200], + interactivePrimaryWeakHover: darkNavyPalette[100], + interactivePrimaryWeakActive: darkNavyPalette[100], + interactivePrimaryWeakDisabled: electricBluePalette["alpha10"], + + // 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], + interactiveInverseTextDisabled: surfaceDark900, + + // Excellent — Electric Blue + excellentBackgroundDefault: electricBluePalette[700], + 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["alpha10"], + excellentBorderDefault: electricBluePalette[500], + excellentBorderHover: electricBluePalette[300], + excellentBorderActive: electricBluePalette[700], + excellentBorderDisabled: electricBluePalette["alpha40"], + excellentBorderWeak: electricBluePalette[500], + excellentIconDefault: electricBluePalette[500], + excellentIconHover: electricBluePalette[300], + excellentIconActive: electricBluePalette[700], + excellentIconDisabled: electricBluePalette["alpha40"], + excellentIconInDefault: electricBluePalette[300], + excellentIconInHover: electricBluePalette[300], + excellentIconInActive: electricBluePalette[300], + excellentIconInDisabled: electricBluePalette["alpha10"], + + // Brand — Electric Blue + Dark Navy + brandIconPrimaryDefault: electricBluePalette[500], + brandIconPrimaryStrong: electricBluePalette[700], + brandIconPrimaryMedium: electricBluePalette[300], + brandIconPrimaryWeak: electricBluePalette[300], + // 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], + brandBackgroundSecondaryDefault: darkNavyPalette[100], + brandTextPrimary: electricBluePalette[500], + brandTextSecondary: greyPalette[0], +}; diff --git a/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts b/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts new file mode 100644 index 00000000..34833be5 --- /dev/null +++ b/packages/open-ui-kit/src/theme/style/__tests__/gradient-vars.test.ts @@ -0,0 +1,223 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Theme } from "@mui/material/styles"; + +import { lightTheme } from "@/theme/light/light-theme"; +import { darkTheme } from "@/theme/dark/dark-theme"; +import { iocTheme } from "@/theme/ioc/ioc-theme"; +import { midnightTheme } from "@/theme/midnight/midnight-theme"; +import { baseGradientVars } from "../gradient-vars-base"; +import { midnightGradientVars } from "@/theme/midnight/midnight-gradient-vars"; +import { gradientsRedPressed } from "../gradients"; + +const TOKEN_KEYS = Object.keys(baseGradientVars).sort(); + +const THEMES: ReadonlyArray<[string, Theme]> = [ + ["light", lightTheme], + ["dark", darkTheme], + ["ioc", iocTheme], + ["midnight", midnightTheme], +]; + +describe("gradient vars contract", () => { + it("exposes the same token set on every theme", () => { + for (const [name, theme] of THEMES) { + expect({ + name, + keys: Object.keys(theme.palette.gradients).sort(), + }).toEqual({ name, keys: TOKEN_KEYS }); + } + }); + + // `palette.vars` needed an explicit re-assert on the IoC theme because the + // second `createTheme` pass can drop custom palette keys. Guard `gradients` + // against the same failure on all four themes. + it("survives createTheme on every theme", () => { + for (const [name, theme] of THEMES) { + for (const key of TOKEN_KEYS) { + const value = + theme.palette.gradients[key as keyof typeof baseGradientVars]; + expect({ name, key, empty: !value }).toEqual({ + name, + key, + empty: false, + }); + } + } + }); + + it("resolves Midnight to the Midnight gradient set", () => { + expect(midnightTheme.palette.gradients).toEqual(midnightGradientVars); + }); + + it("falls back to the base set on the non-Midnight themes", () => { + expect(lightTheme.palette.gradients).toEqual(baseGradientVars); + expect(darkTheme.palette.gradients).toEqual(baseGradientVars); + expect(iocTheme.palette.gradients).toEqual(baseGradientVars); + }); + + it("switches value when the theme switches", () => { + expect(midnightTheme.palette.gradients.gradientDataVizCyanBlue).not.toBe( + lightTheme.palette.gradients.gradientDataVizCyanBlue, + ); + }); +}); + +describe("midnight gradient values", () => { + // Exact fills — angle is axis-aligned or verified, so these are pinned. + it("matches the Figma values for axis-aligned fills", () => { + const g = midnightGradientVars; + + expect(g.gradientDataVizCyanBlue).toBe( + "linear-gradient(90deg, #5de2e8 0%, #187adc 100%)", + ); + expect(g.gradientGlobalButtonPrimaryFill).toBe( + "linear-gradient(90deg, #0745b8 0%, #2e6ee5 100%)", + ); + expect(g.gradientTextWhiteBlue).toBe( + "linear-gradient(90deg, #ffffff 0%, #9dbcf7 51.28%, #3f7def 100%)", + ); + expect(g.gradientGaugeArcTeal).toBe( + "linear-gradient(90deg, #29fcc4 0%, #00af2f 100%)", + ); + expect(g.gradientDataVizPinkMagenta).toBe( + "linear-gradient(180deg, rgba(246, 52, 162, 0.7) 0%, rgba(144, 31, 95, 0.7) 100%)", + ); + expect(g.gradientOverlayBlackFadeIn).toBe( + "linear-gradient(180deg, rgba(0, 0, 0, 0.65) 52.404%, rgba(102, 102, 102, 0) 100%)", + ); + }); + + // Glow dots — radial gradient from the top-left corner. + it("matches the Figma values for the glow dots", () => { + expect(midnightGradientVars.gradientGlowGreen).toBe( + "radial-gradient(circle farthest-side at 0% 0%, rgba(17, 255, 200, 0.7) 0%, rgba(179, 255, 129, 0.7) 100%)", + ); + expect(midnightGradientVars.gradientGlowOrange).toBe( + "radial-gradient(circle farthest-side at 0% 0%, rgba(255, 157, 0, 0.8) 0%, rgba(255, 212, 142, 0.8) 100%)", + ); + expect(midnightGradientVars.gradientGlowRed).toBe( + "radial-gradient(circle farthest-side at 0% 0%, rgba(255, 3, 41, 0.4) 0%, rgba(255, 87, 70, 0.4) 100%)", + ); + }); + + // `Gradient/Alert-Line-Red` has identical stops and offsets to the existing + // `gradientsRedPressed` token, so it reuses it rather than re-deriving the + // angle from an aspect-ratio-distorted Figma export. + it("reuses the canonical red gradient for Alert-Line-Red", () => { + expect(midnightGradientVars.gradientAlertLineRed).toBe(gradientsRedPressed); + }); + + // Single-stop in Figma, so it must resolve to a flat colour, not a ramp. + it("keeps single-stop tokens flat", () => { + expect(midnightGradientVars.gradientGraphFlowTeal).toBe( + "rgba(1, 117, 128, 0.2)", + ); + }); + + // Stroke gradients cannot be read back from the Figma MCP server, so these + // values were measured by sampling the rendered swatch. Pinning them guards + // against a silent regression to the earlier evenly-distributed guesses. + describe("measured stroke geometry", () => { + const g = midnightGradientVars; + + it("uses a horizontal ramp for every stroke", () => { + const strokes = [ + g.gradientGlobalBorderFade, + g.gradientGlobalBorderRainbow, + g.gradientDashboardGraphNodeBorder, + g.gradientGraphConnectorStroke, + g.gradientIconButtonBlueGlow, + ]; + for (const value of strokes) { + expect(value.startsWith("linear-gradient(90deg,")).toBe(true); + } + }); + + // Also swatch-horizontal but applied vertically: the button at + // I274405:38087;25258:78851 runs blue along its bottom edge up to cyan, + // which pushes the evenly-spaced teal stop off the box entirely. + it("runs the button border glow up the button", () => { + expect(g.gradientGlobalButtonPrimaryBorderGlow).toBe( + "linear-gradient(7.5deg, #3b76ea 10%, #00bceb 90%, #63fff7 170%)", + ); + }); + + // The odd one out: its swatch is a horizontal rectangle, but the Activity + // Timeline rail that applies it runs the ramp top to bottom. + it("runs the card highlight ramp vertically", () => { + expect(g.gradientCardHighlightRadial).toBe( + "linear-gradient(180deg, #ffffff 0%, rgba(153, 153, 153, 0) 100%)", + ); + }); + + // Glass card family — exact values from the SVG exports in `Glass Card` + // (274490:55387); see the block comment in `midnight-gradient-vars.ts`. + it("anchors the glass fill radial at the top-right corner", () => { + expect(g.gradientCardGlassBg).toBe( + "radial-gradient(100% 100% at 100% 0%, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.05) 100%)", + ); + }); + + it("scales the subtle glass variant to 70% of the fill", () => { + expect(g.gradientCardGlassBgSubtle).toBe( + "radial-gradient(100% 100% at 100% 0%, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.035) 100%)", + ); + }); + + it("fades the glass border out toward the bottom", () => { + expect(g.gradientCardGlassBorder).toBe( + "linear-gradient(180deg, rgba(255, 255, 255, 0.3) 10%, rgba(241, 241, 241, 0.3) 75%, rgba(153, 153, 153, 0) 100%)", + ); + }); + + it("runs the glass flair cyan to periwinkle down the streak", () => { + expect(g.gradientDashboardCardFillCyanPurple).toBe( + "linear-gradient(180deg, rgba(0, 187, 255, 0.37) -83.247%, rgba(161, 166, 254, 0.73) 100%)", + ); + }); + + it("sweeps the CTA glow mint to gold with tight midpoints", () => { + expect(g.gradientCardGlassCtaGlow).toBe( + "radial-gradient(87% 72% at 6.5% 14%, #74ffc7 44%, #03b3ff 50%, #f634a2 58%, #ffe070 99%)", + ); + }); + + // Alpha ramps up left to right, opposite to the order shown on the label. + // Confirmed verbatim by the card SVG in `Section 3` (274455:54313). + it("ramps the graph connector stroke upward", () => { + expect(g.gradientGraphConnectorStroke).toBe( + "linear-gradient(90deg, rgba(199, 211, 234, 0.06) 0%, rgba(199, 211, 234, 0.16) 100%)", + ); + }); + + // Graph-connector fill and glow — exact, from the same SVG export. + it("uses the card-accurate angle for the connector fill", () => { + expect(g.gradientGraphConnectorFill).toBe( + "linear-gradient(155.21deg, rgba(199, 211, 234, 0.035) 0%, rgba(199, 211, 234, 0.016) 100%)", + ); + }); + + it("anchors the connector glow at the bottom of the card", () => { + expect(g.gradientGraphConnectorGlow).toBe( + "radial-gradient(85% 85% at 50% 100%, rgba(199, 211, 234, 0.064) 0%, rgba(199, 211, 234, 0.008) 100%)", + ); + }); + + it("places the rainbow pink stop at 83%", () => { + expect(g.gradientGlobalBorderRainbow).toContain("#ff007f 83%"); + }); + + // The label says #0a66ff; the rendered stroke measures #0a60ff. Direction + // comes from the toast instances, not the swatch, which runs the other way. + it("ramps the border fade slate to blue", () => { + expect(g.gradientGlobalBorderFade).toBe( + "linear-gradient(90deg, rgba(77, 99, 128, 0.7) 10%, #3d5066 31%, #00bceb 51%, #0a60ff 79%)", + ); + }); + }); +}); diff --git a/packages/open-ui-kit/src/theme/style/color-palette.ts b/packages/open-ui-kit/src/theme/style/color-palette.ts index d3d9c301..ee645ebc 100644 --- a/packages/open-ui-kit/src/theme/style/color-palette.ts +++ b/packages/open-ui-kit/src/theme/style/color-palette.ts @@ -317,6 +317,60 @@ export const gradientStopColors = { secondaryBlueHoverWeak: "#b3cbe7", } as const; +// Card insight glow — the blue drop shadow paired with the gradient card +// border. Figma: `Card/Basic Interactive` (274405:44327). +// +// Deliberately NOT added to any theme's `shadows` array: that array is +// positional and `dialog.test.tsx` asserts index-to-token identity, so +// inserting an entry would renumber every elevation. +export const cardInsightGlow = "0px 4px 17px rgba(10, 96, 255, 0.4)"; + +// Glass card drop shadow. Figma: `Glass Card` (274417:44469), effect variables +// `4/X: 0`, `4/Y: 12`, `4/Blur: 48`, `4/Spread: 0`, `4/Color: #00000040`. +// +// Kept out of the `shadows` array for the same reason as `cardInsightGlow`. +export const cardGlassShadow = "0px 12px 48px rgba(0, 0, 0, 0.25)"; + +// Backdrop blur behind the glass card fill. No longer estimated: the updated +// `Glass Card` frame (274490:55387) exports the card surface as an SVG whose +// foreignObject carries `backdrop-filter: blur(35.71px)` at the mockup's +// 0.8928 scale — 35.71 / 0.8928 = 40px. Matches the swatch label +// `Radial + Background Blur - 72`, since Figma's 71.4 blur radius halves on +// the way to CSS. +export const cardGlassBlur = "40px"; + +// Graph-connector card. Figma: `Section 3` (274455:54313). Both values are +// read off the card's SVG export rather than estimated: the drop shadow from +// its `feOffset dx="6"` / `feGaussianBlur stdDeviation="2"` (CSS blur is twice +// the deviation) and colour matrix `#0d1622` at 0.25, and the blur from the +// export's own `backdrop-filter`. +export const cardConnectorShadow = "6px 0px 4px rgba(13, 22, 34, 0.25)"; +export const cardConnectorBlur = "30px"; + +// Alert card severity label colours. Figma: `Alerts Card` (274421:47415) — +// critical (274421:47325) and warning (274421:47332). +// +// These are the design's literal values rather than the `negativeTextDefault` / +// `warningTextDefault` semantic tokens, which resolve to `#eebfcb` and +// `#ffe7c7` — far too pale to read as a severity accent on this surface. +// Figma binds the critical one to a library variable named `Red/55`, from a red +// scale this library does not carry; the warning one is the same value already +// present above as `gaugeArcAmber`, repeated here under its semantic name. +export const alertCriticalText = "#eb4651"; +export const alertWarningText = "#ffae4c"; + +// Alert card drop shadow, at the frame's 0.869 scale factor divided out. +// Kept out of the `shadows` array for the same reason as `cardInsightGlow`. +export const cardAlertShadow = "0px 4px 4px rgba(0, 0, 0, 0.1)"; + +// Toast glow. Figma: `Toast message Glow` (274417:44480), the two `Text Card` +// instances. Both use the same offset and blur; the glow is stronger on the +// variant that carries a header (0.4) than on the message-only one (0.2). +// The colour is `panelExecBorderBlue` (#0a60ff), the first stop of the +// `Global-Border/Fade` ramp that draws the toast's border. +export const toastGlow = "0px -1px 34px rgba(10, 96, 255, 0.2)"; +export const toastGlowStrong = "0px -1px 34px rgba(10, 96, 255, 0.4)"; + // Light Mode Box Shadows export const lightModeCardLifted = "0px 4px 4px rgba(200, 213, 245, 0.33)"; export const lightModeCardSubtle = "0px 2px 5px rgba(200, 213, 245, 0.4)"; @@ -626,3 +680,132 @@ 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 electricBlueAlpha40 = "#558bff66"; +export const electricBlueAlpha10 = "#558bff19"; + +export const electricBluePalette = { + 300: electricBlue300, + 500: electricBlue500, + 700: electricBlue700, + alpha40: electricBlueAlpha40, + alpha10: electricBlueAlpha10, +}; + +// Midnight gradient stops. +// +// Source: Figma "OXP" (wvTxiCkZBmP2jH24hzydHR), frame 274405:38026 +// "Components with Gradients". Keys are named after the Figma variable where +// one exists (e.g. `Gradient/Graph-Flow/Teal` -> `graphFlowTeal`); the rest are +// named after the component that consumes them. +// +// This mirrors the existing `gradientStopColors` block: gradient ramps need +// intermediate colors that do not belong to a semantic 50-900 hue scale. +// Stops that DO already exist are not duplicated here — reuse `teal300` +// (#5de2e8), `blue300` (#187adc), `blue500` (#0051af), `red800` (#b11939), +// `red900` (#a40f29) and `illustrationNegativeGradientStart` (#e09e89). +export const midnightGradientStops = { + // Graph flow (Figma variables: Gradient/Graph-Flow/*) + graphFlowGray: "#c6c6c6", + graphFlowTeal: "#017580", + graphFlowMaroon: "#892727", + + // Data viz + dataVizPink: "#f634a2", // Gradient/Dashboard-Background-Ring/Stroke/Stop-2-Pink + dataVizMagenta: "#901f5f", + dataVizPurple: "#8118dc", + dataVizCyan: "#02c8ff", + dataVizTeal: "#197690", // Gradient/Data-Viz-Cyan-Teal/Stop-1-Teal + dataVizOrange: "#ff9000", + dataVizAmber: "#995600", + dataVizGold: "#dcae18", + dataVizBlue: "#3b82f6", + dataVizMint: "#77e7cd", + dataVizFadeGray: "#d9d9d9", + + // Gauge arc (Figma variables: Gradient/Gauge-Arc-*) + gaugeArcCyan: "#29b0fc", + gaugeArcTealStart: "#29fcc4", + gaugeArcTealEnd: "#00af2f", + gaugeArcAmber: "#ffae4c", + + // Buttons and icons + buttonPrimaryFillStart: "#0745b8", + buttonPrimaryFillEnd: "#2e6ee5", + buttonPrimaryGlowBlue: "#3b76ea", // Gradient/Global-Button-Primary/Border-Glow/Stop-0-Blue + buttonPrimaryGlowCyan: "#00bceb", // ...Stop-1-Cyan + buttonPrimaryGlowTeal: "#63fff7", // ...Stop-2-Teal + iconSubtractBlue: "#5096ff", + iconButtonBlueStart: "#043abc", + iconButtonBlueMid: "#113ca1", + iconButtonBlueEnd: "#011d62", + iconButtonGlowBlue: "#3974ff", + + // Text (Figma variable: Gradient/Text-White-Blue) + textWhiteBlueStart: "#ffffff", + // Figma emits this stop at 51.28%. It sits exactly on the line between the + // two ends, so it changes nothing visually — carried so the token reads the + // same as the design's own CSS. + textWhiteBlueMid: "#9dbcf7", + textWhiteBlueEnd: "#3f7def", + + // Panels, borders and connectors + panelExecBorderBlue: "#0a60ff", // Gradient/Panel-Exec-Border/Stop-0-Blue + panelBorderCyanDark: "#16bdeb", // Gradient/Panel-Border-Blue-Cyan-Dark/Stop-1-Cyan + // NOTE: the `Global-Border/Fade` swatch labels its first stop #0a66ff, but the + // rendered stroke measures #0a60ff (`panelExecBorderBlue`). The label appears + // to be stale, so no #0a66ff stop is defined here. + globalBorderSlate: "#3d5066", + globalBorderSlateWeak: "#4d6380", + globalDividerPink: "#ff007f", + graphConnectorBlue: "#c7d3ea", // Gradient/Graph-Connector-Stroke/Stop-* + graphNodeFillBlue: "#2972ff", + graphNodeFillDark: "#01060d", + + // Surfaces and overlays + welcomeCardStart: "#060b26", + welcomeCardEnd: "#1a1f37", + overlayBlack: "#000000", + overlayGray: "#666666", + glassWhite: "#ffffff", + glassWhiteWeak: "#f1f1f1", + + // Glass card flair and CTA glow. Figma: `Glass Card` (274490:55387) — + // swatches `Gradient/Dashboard-Card/Fill/Cyan-Purple` and `Gradient/Card-Glass-CTA-Glow`. + // The CTA's pink stop is the existing `dataVizPink`, so it is not repeated. + glassGlowCyan: "#00bbff", // Dashboard-Card/Fill/Cyan-Purple/Stop-0 + glassGlowPeriwinkle: "#a1a6fe", // Dashboard-Card/Fill/Cyan-Purple/Stop-1 + glassCtaMint: "#74ffc7", // Card-Glass-CTA-Glow/Stop-0 + glassCtaBlue: "#03b3ff", // Card-Glass-CTA-Glow/Stop-1 + glassCtaGold: "#ffe070", // Card-Glass-CTA-Glow/Stop-3 + glassGray: "#999999", + + // Glows + glowOrangeStart: "#ff9d00", + glowOrangeEnd: "#ffd48e", + glowGreenStart: "#11ffc8", + glowGreenEnd: "#b3ff81", + glowRedStart: "#ff0329", + glowRedEnd: "#ff5746", + glowBlueStart: "#4a6ac8", + glowBlueMid: "#6abfff", + glowBlueDeep: "#3b69bc", + glowBlueEnd: "#1b4288", +} as const; diff --git a/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts new file mode 100644 index 00000000..632a8387 --- /dev/null +++ b/packages/open-ui-kit/src/theme/style/gradient-vars-base.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { GradientVarsType } from "@/types/gradient-vars"; +import { midnightGradientStops as stops } from "./color-palette"; +import { + gradientsBackgroundDark, + gradientsIllustrationsBlue, + gradientsIllustrationsGreen, + gradientsIllustrationsLightBlue, + gradientsIllustrationsOrange, + gradientsIllustrationsPink, + gradientsIllustrationsPurple, + gradientsIllustrationsRainbow, + gradientsRedPressed, + gradientsSecondaryDefault, + gradientsSecondaryHover, +} from "./gradients"; + +/* + * Button gradients — shared by every theme. + * + * Unlike the rest of this file these are real design values, not placeholders. + * Design has not diverged them per theme and the Button gradient variants must + * look identical everywhere, so they are defined once here and re-used by + * `midnight-gradient-vars.ts` instead of being overridden per theme. + * Source: Figma frames 274405:44106 and I274405:38087;25258:78851. + */ +export const gradientButtonPrimaryFill = `linear-gradient(90deg, ${stops.buttonPrimaryFillStart} 0%, ${stops.buttonPrimaryFillEnd} 100%)`; + +/* + * The ramp runs bottom-to-top, not left-to-right: the `Border-Glow` swatch is a + * horizontal rectangle, but the button that applies it + * (I274405:38087;25258:78851) carries the ramp up its 105 x 28 box, blue along + * the bottom edge to cyan along the top. Same swatch-vs-application split as + * `gradientCardHighlightRadial`. + * + * The stroke's paint cannot be read back through the MCP server, so the + * geometry was fitted to the rendered node: sampling its straight edges gives a + * ramp 7.5 degrees off vertical whose blue and cyan stops sit at 10% and 90% of + * the gradient line. The three stops stay evenly spaced, which puts teal at + * 170% — off the box, which is why the frame shows no teal at this size. Those + * numbers reproduce the render to a maximum channel error of 3/255; the + * previous `90deg, 0/50/100` averaged 46. + * + * The angle is exact for the frame's aspect ratio. Figma maps the handle + * through the layer's bounding box, so a much wider button tilts further there + * than a fixed CSS angle can follow — immaterial at this tilt. + */ +export const gradientButtonPrimaryBorderGlow = `linear-gradient(7.5deg, ${stops.buttonPrimaryGlowBlue} 10%, ${stops.buttonPrimaryGlowCyan} 90%, ${stops.buttonPrimaryGlowTeal} 170%)`; + +/* + * Card insight border — `Gradient/Panel-Exec-Border`, shared by every theme. + * + * The Figma swatch is LABELLED "Radial", but it is not. Sampling the border of + * the rendered `Card/Basic Interactive` (274405:44327) and of the swatch itself + * gives the same diagonal signature — the top edge ramps blue -> cyan -> slate, + * the right edge is uniformly slate, and the left edge holds blue before + * turning cyan near the bottom. That is a diagonal linear ramp. + * + * Angle and offsets are measured from the 318x214 card, which is the real + * usage. Figma normalises gradient transforms to the layer box, so the same + * token measures differently on the 120x60 documentation swatch — re-measure + * before reusing this on a very differently proportioned surface. + */ +export const gradientCardInsightBorder = `linear-gradient(135deg, ${stops.panelExecBorderBlue} 25%, ${stops.dataVizCyan} 34%, ${stops.globalBorderSlate} 60%, rgba(77, 99, 128, 0.7) 100%)`; + +/* Text gradient — shared by every theme, like the button gradients above. + * + * A real design value (not a placeholder). Design has not diverged it per theme, + * so it is defined once and reused everywhere via `background-clip: text`. + * Source: Figma "OXP" (wvTxiCkZBmP2jH24hzydHR), `Gradient/Text-White-Blue` + * (frame 274405:44228, "Welcome Amy!"), white -> blue, left to right. + */ +export const gradientTextWhiteBlue = `linear-gradient(90deg, ${stops.textWhiteBlueStart} 0%, ${stops.textWhiteBlueMid} 51.28%, ${stops.textWhiteBlueEnd} 100%)`; + +/* + * Base gradient tokens — the fallback every theme starts from. + * + * Apart from the three shared gradients above (the two button gradients and the + * text gradient), this deliberately contains NO new design values. + * + * The gradient set was designed for the Midnight theme only (Figma "OXP", + * frame 274405:38026). Light, Dark and IoC have no design-approved gradients + * yet, but `GradientVarsType` is a total contract, so they need a value for + * every key. Each remaining entry is therefore mapped to the closest gradient + * this library already ships in `gradientsPalette` — chosen so components + * render something on-brand rather than nothing. + * + * Those are PROVISIONAL, not design-approved. When design delivers Light / + * Dark / IoC gradients, add a `*-gradient-vars.ts` per theme that spreads this + * object and overrides, exactly as `midnight-gradient-vars.ts` does. + * + * Midnight overrides every provisional entry, so only the three shared + * gradients above (the two button gradients and the text gradient) reach the + * Midnight theme from this file. + */ +export const baseGradientVars: GradientVarsType = { + // --- Fills --------------------------------------------------------------- + gradientGaugeArcAmber: gradientsIllustrationsOrange, + gradientGaugeArcTeal: gradientsIllustrationsGreen, + gradientIconSubtractBlue: gradientsSecondaryDefault, + gradientDataVizCyanTeal: gradientsIllustrationsBlue, + gradientDataVizCyanBlue: gradientsIllustrationsLightBlue, + gradientDataVizPinkMagenta: gradientsIllustrationsPink, + gradientDataVizOrangeAmber: gradientsIllustrationsOrange, + gradientDataVizPinkFlat: gradientsIllustrationsPink, + gradientDataVizPinkPurple: gradientsIllustrationsPurple, + gradientDataVizOrangeGold: gradientsIllustrationsOrange, + gradientDataVizBlueDark: gradientsSecondaryDefault, + gradientProgressBarTeal: gradientsIllustrationsGreen, + gradientGraphFlow: gradientsIllustrationsBlue, + gradientGraphFlowPink: gradientsIllustrationsPink, + gradientGraphFlowTeal: gradientsIllustrationsGreen, + gradientGraphFlowMaroon: gradientsRedPressed, + gradientGlobalButtonPrimaryFill: gradientButtonPrimaryFill, + gradientTextWhiteBlue, + gradientGlobalDividerFade: gradientsIllustrationsRainbow, + gradientOverlayBlackFadeIn: gradientsBackgroundDark, + gradientWelcomeCardBgDark: gradientsBackgroundDark, + gradientAlertLineRed: gradientsRedPressed, + gradientDashboardGraphNodeFill: gradientsSecondaryDefault, + gradientGraphConnectorFill: gradientsSecondaryHover, + gradientGraphConnectorGlow: gradientsSecondaryHover, + gradientIconButtonBlue: gradientsSecondaryDefault, + + // --- Strokes ------------------------------------------------------------- + gradientCardGlassBg: gradientsSecondaryHover, + gradientCardGlassBgSubtle: gradientsSecondaryHover, + gradientCardGlassBorder: gradientsSecondaryHover, + gradientDashboardCardFillCyanPurple: gradientsSecondaryHover, + gradientCardGlassCtaGlow: gradientsIllustrationsRainbow, + gradientGlobalBorderFade: gradientsSecondaryDefault, + gradientInputBorderBlue: gradientsSecondaryDefault, + gradientGlobalBorderRainbow: gradientsIllustrationsRainbow, + gradientGlobalButtonPrimaryBorderGlow: gradientButtonPrimaryBorderGlow, + gradientDashboardGraphNodeBorder: gradientsSecondaryDefault, + gradientGraphConnectorStroke: gradientsSecondaryHover, + gradientIconButtonBlueGlow: gradientsIllustrationsLightBlue, + gradientCardHighlightRadial: gradientsSecondaryHover, + + // --- Radial glows -------------------------------------------------------- + gradientGlowOrange: gradientsIllustrationsOrange, + gradientGlowGreen: gradientsIllustrationsGreen, + gradientGlowRed: gradientsRedPressed, + gradientGlowPinkShadow: gradientsIllustrationsPink, + gradientBackgroundGlowBlue: gradientsIllustrationsBlue, + gradientPanelExecBorder: gradientCardInsightBorder, + gradientPanelBorderBlueCyanDark: gradientsSecondaryDefault, +}; diff --git a/packages/open-ui-kit/src/types/gradient-vars.ts b/packages/open-ui-kit/src/types/gradient-vars.ts new file mode 100644 index 00000000..b97a7453 --- /dev/null +++ b/packages/open-ui-kit/src/types/gradient-vars.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Theme-aware gradient tokens. + * + * Mirrors `VarsType`: a single flat contract that every theme must implement, + * mounted on the MUI palette as `palette.gradients`. Components read + * `theme.palette.gradients.gradientDataVizCyanBlue` with no theme branching — + * swapping the theme swaps the value, exactly like `palette.vars`. + * + * Token names come from the `gradient-token` labels in Figma "OXP" + * (wvTxiCkZBmP2jH24hzydHR), frame 274405:38026 "Components with Gradients", + * camelCased from the Figma path: `Gradient/Data-Viz-Cyan-Blue` -> + * `gradientDataVizCyanBlue`. + */ +export interface GradientVarsType { + // --- Fills --------------------------------------------------------------- + gradientGaugeArcAmber: string; + gradientGaugeArcTeal: string; + gradientIconSubtractBlue: string; + gradientDataVizCyanTeal: string; + gradientDataVizCyanBlue: string; + gradientDataVizPinkMagenta: string; + /** Renamed: Figma labels this `Data-Viz-Pink-Magenta` but the stops are orange. */ + gradientDataVizOrangeAmber: string; + /** Renamed: Figma labels this `Data-Viz-Pink-Magenta`; single stop, renders flat. */ + gradientDataVizPinkFlat: string; + gradientDataVizPinkPurple: string; + gradientDataVizOrangeGold: string; + gradientDataVizBlueDark: string; + gradientProgressBarTeal: string; + gradientGraphFlow: string; + gradientGraphFlowPink: string; + gradientGraphFlowTeal: string; + /** Renamed: Figma reuses `Graph-Flow` for this distinct maroon ramp. */ + gradientGraphFlowMaroon: string; + gradientGlobalButtonPrimaryFill: string; + /** Gradient text fill (`background-clip: text`). Figma: `Gradient/Text-White-Blue`. */ + gradientTextWhiteBlue: string; + gradientGlobalDividerFade: string; + gradientOverlayBlackFadeIn: string; + gradientWelcomeCardBgDark: string; + gradientAlertLineRed: string; + gradientDashboardGraphNodeFill: string; + gradientGraphConnectorFill: string; + /** `Graph-Connector-Glow`: radial lift from the bottom edge of the card. */ + gradientGraphConnectorGlow: string; + gradientIconButtonBlue: string; + + // --- Strokes ------------------------------------------------------------- + // Applied to borders. Colors and stop order are correct; angles and stop + // offsets are approximate — see midnight-gradient-vars.ts. + gradientCardGlassBg: string; + /** Renamed: same stops as `gradientCardGlassBg` at 70% layer opacity. */ + gradientCardGlassBgSubtle: string; + /** `Card-Glass-BORDER`: white hairline that fades out toward the bottom. */ + gradientCardGlassBorder: string; + /** `Dashboard-Card/Fill/Cyan-Purple`: cyan-to-periwinkle flair behind the glass blur. */ + gradientDashboardCardFillCyanPurple: string; + /** `Card-Glass-CTA-Glow`: mint-blue-pink-gold glow behind the glass CTA. */ + gradientCardGlassCtaGlow: string; + gradientGlobalBorderFade: string; + /** `Input-Border-Blue`: white-to-blue ramp around the prompt input field. */ + gradientInputBorderBlue: string; + /** Renamed: Figma reuses `Global-Border/Fade` for this distinct rainbow ramp. */ + gradientGlobalBorderRainbow: string; + gradientGlobalButtonPrimaryBorderGlow: string; + gradientDashboardGraphNodeBorder: string; + gradientGraphConnectorStroke: string; + gradientIconButtonBlueGlow: string; + /** Named `Card-Highlight-Radial` in Figma, but the swatch fill is linear. */ + gradientCardHighlightRadial: string; + + // --- Radial glows -------------------------------------------------------- + gradientGlowOrange: string; + gradientGlowGreen: string; + gradientGlowRed: string; + gradientGlowPinkShadow: string; + gradientBackgroundGlowBlue: string; + gradientPanelExecBorder: string; + gradientPanelBorderBlueCyanDark: string; +} diff --git a/packages/open-ui-kit/src/types/theme.ts b/packages/open-ui-kit/src/types/theme.ts index 5ef50f43..4d775c28 100644 --- a/packages/open-ui-kit/src/types/theme.ts +++ b/packages/open-ui-kit/src/types/theme.ts @@ -13,6 +13,7 @@ import "@mui/material/Tab"; import React from "react"; import type { Color } from "@mui/material/styles"; import { VarsType } from "./vars"; +import { GradientVarsType } from "./gradient-vars"; declare module "@mui/material/styles" { interface Palette { @@ -20,6 +21,7 @@ declare module "@mui/material/styles" { negative: Palette["primary"]; orange: Palette["primary"]; vars: VarsType; + gradients: GradientVarsType; } interface PaletteOptions { @@ -27,6 +29,7 @@ declare module "@mui/material/styles" { negative?: PaletteOptions["primary"]; orange?: PaletteOptions["primary"]; vars?: VarsType; + gradients?: GradientVarsType; } // eslint-disable-next-line @typescript-eslint/no-empty-interface -- MUI module augmentation hook (same as `ColorPartial` in createPalette) @@ -68,7 +71,11 @@ declare module "@mui/material/Button" { contained: false; primary: true; secondary: true; + /** Gradient background fill. Figma: `Gradient/Global-Button-Primary/Fill`. */ + gradient: true; outlined: true; + /** Gradient border ring. Figma: `Gradient/Global-Button-Primary/Border-Glow`. */ + gradientOutlined: true; tertariary: true; } @@ -106,4 +113,12 @@ declare module "@mui/material/Typography" { body2Semibold: true; headingSubSection: true; } + + interface TypographyOwnProps { + /** + * Fill the text with a gradient (`background-clip: text`) instead of a + * flat color. Composes with any `variant`. Figma: `Gradient/Text-White-Blue`. + */ + gradient?: boolean; + } } diff --git a/packages/open-ui-kit/src/typography/typography.mdx b/packages/open-ui-kit/src/typography/typography.mdx index c1adfcd1..fb37b885 100644 --- a/packages/open-ui-kit/src/typography/typography.mdx +++ b/packages/open-ui-kit/src/typography/typography.mdx @@ -73,7 +73,8 @@ import { TypographySection } from "./typography-row"; { token: "h6", label: "Title h6", - usage: "Used for section titles, accordion headers, and first-level titles within a container.", + usage: + "Used for section titles, accordion headers, and first-level titles within a container.", family: "Sharp Sans, sans-serif", fontWeight: 700, fontWeightLabel: "Sharp Sans Bold", @@ -84,7 +85,8 @@ import { TypographySection } from "./typography-row"; { token: "headingSubSection", label: "Title h7", - usage: "Used for sub-section titles, and second-level titles within a container.", + usage: + "Used for sub-section titles, and second-level titles within a container.", family: "Sharp Sans, sans-serif", fontWeight: 700, fontWeightLabel: "Sharp Sans Bold", @@ -101,7 +103,8 @@ import { TypographySection } from "./typography-row"; { token: "subtitle1", label: "Subtitle 1", - usage: "Used for first-level titles within a sub container or subsection.", + usage: + "Used for first-level titles within a sub container or subsection.", family: "Inter, sans-serif", fontWeight: 500, fontWeightLabel: "Inter Medium", @@ -113,7 +116,8 @@ import { TypographySection } from "./typography-row"; { token: "subtitle2", label: "Subtitle 2", - usage: "Used for first-level titles within a sub container or subsection.", + usage: + "Used for first-level titles within a sub container or subsection.", family: "Inter, sans-serif", fontWeight: 500, fontWeightLabel: "Inter Medium", @@ -131,7 +135,8 @@ import { TypographySection } from "./typography-row"; { token: "body1Semibold", label: "Body 1 SemiBold", - usage: "Used for first-level titles within a sub container or subsection, and to section paragraphs.", + usage: + "Used for first-level titles within a sub container or subsection, and to section paragraphs.", family: "Inter, sans-serif", fontWeight: 600, fontWeightLabel: "Inter Semibold", @@ -142,7 +147,8 @@ import { TypographySection } from "./typography-row"; { token: "body2Semibold", label: "Body 2 SemiBold", - usage: "Used for first-level titles within a sub container or subsection, and to section paragraphs.", + usage: + "Used for first-level titles within a sub container or subsection, and to section paragraphs.", family: "Inter, sans-serif", fontWeight: 600, fontWeightLabel: "Inter Semibold", @@ -177,7 +183,8 @@ import { TypographySection } from "./typography-row"; { token: "captionSemibold", label: "Caption Bold", - usage: "Used for third-level paragraph text, or small text within components.", + usage: + "Used for third-level paragraph text, or small text within components.", family: "Inter, sans-serif", fontWeight: 600, fontWeightLabel: "Inter Bold", @@ -189,7 +196,8 @@ import { TypographySection } from "./typography-row"; { token: "captionMedium", label: "Caption Medium", - usage: "Used for third-level paragraph text, or small text within components.", + usage: + "Used for third-level paragraph text, or small text within components.", family: "Inter, sans-serif", fontWeight: 500, fontWeightLabel: "Inter Medium", @@ -201,7 +209,8 @@ import { TypographySection } from "./typography-row"; { token: "caption", label: "Caption", - usage: "Used for third-level paragraph text, or small text within components.", + usage: + "Used for third-level paragraph text, or small text within components.", family: "Inter, sans-serif", fontWeight: 400, fontWeightLabel: "Inter", diff --git a/packages/open-ui-kit/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", 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 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"