diff --git a/.gitignore b/.gitignore index 5af63cadc..ed67f4040 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ dist coverage storybook-static/ +visual/test-results/ +visual/report/ diff --git a/.storybook/preview.js b/.storybook/preview.js index 93c1072ae..73d69e8ae 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -5,6 +5,8 @@ import { DarkTheme } from "@/theme/dark" import { GlobalStyles } from "@/global-styles" import Flex from "@/components/templates/flex" +const themes = { light: DefaultTheme, dark: DarkTheme } + const preview = { parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -20,7 +22,18 @@ const preview = { decorators: [ (Story, context) => { - const theme = context.globals.theme === "dark" ? DarkTheme : DefaultTheme + const theme = themes[context.globals.theme] || DefaultTheme + + // Docs pages render many stories inline, so a full-viewport frame would give each one a screen of whitespace. + if (context.viewMode === "docs") + return ( + + + + + + + ) return ( @@ -49,7 +62,10 @@ export const globalTypes = { toolbar: { title: "Theme", icon: "circlehollow", - items: ["light", "dark"], + items: [ + { value: "light", title: "Light" }, + { value: "dark", title: "Dark" }, + ], dynamicTitle: true, }, }, diff --git a/jest/config.js b/jest/config.js index 7ee73e260..b837bf140 100644 --- a/jest/config.js +++ b/jest/config.js @@ -28,7 +28,7 @@ module.exports = { }, testPathIgnorePatterns: ["/node_modules/"], transformIgnorePatterns: [ - "/node_modules/(?!(strip-indent|redent|@testing-library/jest-dom)/)", + "/node_modules/(?!(strip-indent|redent|@testing-library/jest-dom|@storybook|storybook)/)", ], reporters: ["default"], cacheDirectory: "/.jest-tmp", diff --git a/package.json b/package.json index b7c29ce7a..07e041254 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/netdata-ui", - "version": "5.5.16", + "version": "5.6.0", "description": "netdata UI kit", "main": "dist/index.js", "module": "dist/es6/index.js", @@ -30,7 +30,10 @@ "prepublishOnly": "yarn clean && yarn build", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "format": "prettier --write \"**/*.{js,mjs}\"" + "format": "prettier --write \"**/*.{js,mjs}\"", + "visual:build": "storybook build -o storybook-static --quiet", + "test:visual": "yarn visual:build && docker run --rm --ipc=host -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v1.62.1-noble npx playwright test -c visual/playwright.config.js", + "test:visual:update": "yarn visual:build && docker run --rm --ipc=host -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v1.62.1-noble npx playwright test -c visual/playwright.config.js --update-snapshots" }, "keywords": [ "netdata", @@ -43,6 +46,7 @@ "@dnd-kit/sortable": "^10.0.0", "@elastic/react-search-ui": "^1.24.2", "@elastic/search-ui-site-search-connector": "^1.24.2", + "@fontsource/ibm-plex-sans": "^5.3.0", "@prismicio/client": "^7.21.6", "@styled-system/css": "^5.1.5", "@tanstack/react-table": "^8.21.3", @@ -69,6 +73,7 @@ "@babel/plugin-transform-runtime": "^7.29.0", "@babel/preset-env": "^7.29.0", "@babel/preset-react": "^7.28.5", + "@playwright/test": "1.62.1", "@storybook/addon-docs": "10.2.10", "@storybook/addon-links": "10.2.10", "@storybook/addon-webpack5-compiler-babel": "^4.0.0", diff --git a/src/components/button/__snapshots__/button.test.js.snap b/src/components/button/__snapshots__/button.test.js.snap index be1f369cf..fdc19c01b 100644 --- a/src/components/button/__snapshots__/button.test.js.snap +++ b/src/components/button/__snapshots__/button.test.js.snap @@ -6,20 +6,23 @@ exports[`Borderless Button renders 1`] = ` justify-content: center; align-items: center; position: relative; + font-family: inherit; font-weight: 500; - font-size: 12px; + font-size: 13px; + line-height: 16px; + min-height: 28px; white-space: nowrap; word-break: keep-all; cursor: pointer; pointer-events: auto; - padding: 4px 12px; - transition: all 150ms; + padding: 0 12px; + transition: background-color 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out; background-color: rgba(255,255,255,0.0); - color: #00AB44; + color: #007830; border-width: 1px; border-style: solid; border-color: rgba(255,255,255,0.0); - border-radius: 2px; + border-radius: 4px; box-sizing: border-box; text-decoration: none; } @@ -36,20 +39,37 @@ exports[`Borderless Button renders 1`] = ` .c0.c0:hover { border-color: rgba(255,255,255,0.0); background-color: rgba(255,255,255,0.0); - color: #00CD51; + color: #006729; text-decoration: none; } .c0.c0:hover .button-icon { - fill: #00CD51; + fill: #006729; } .c0.c0:active, .c0.c0[aria-expanded="true"] { border-color: rgba(255,255,255,0.0); background-color: rgba(255,255,255,0.0); - color: #00CD51; - box-shadow: inset 0 4px 4px rgba(0,0,0,0.25); + color: #005622; +} + +.c0.c0[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} + +.c0.c0[aria-disabled="true"]:hover, +.c0.c0[aria-disabled="true"]:active { + border-color: rgba(255,255,255,0.0); + background-color: rgba(255,255,255,0.0); + color: #007830; + text-decoration: none; +} + +.c0.c0[aria-disabled="true"]:hover .button-icon, +.c0.c0[aria-disabled="true"]:active .button-icon { + fill: #007830; } .c0.c0:focus { @@ -57,14 +77,14 @@ exports[`Borderless Button renders 1`] = ` } .c0.c0:focus-visible { - outline: 2px solid #4A5757; + outline: 2px solid #007830; outline-offset: 2px; } .c0.c0 .button-icon { height: 14px; width: 14px; - fill: #00AB44; + fill: #007830; } .c0.c0 .ntd-spinner { @@ -73,12 +93,18 @@ exports[`Borderless Button renders 1`] = ` stroke-dasharray: 100; stroke-dashoffset: 100; animation: ntd-draw 1s linear infinite; - stroke: #00AB44; + stroke: #007830; width: 24px; } .c0.c0 .path { - stroke: #00AB44; + stroke: #007830; +} + +@media (prefers-reduced-motion:reduce) { + .c0.c0 { + transition: none; + } } @media (prefers-reduced-motion:reduce) { @@ -92,8 +118,8 @@ exports[`Borderless Button renders 1`] = ` class="c0" colors="[object Object]" flavour="borderless" - padding="1,3" - round="0.5" + padding="0,3" + round="1" texttransform="firstLetter" > @@ -108,20 +134,23 @@ exports[`Button states renders loading icon 1`] = ` justify-content: center; align-items: center; position: relative; + font-family: inherit; font-weight: 500; - font-size: 12px; + font-size: 13px; + line-height: 16px; + min-height: 28px; white-space: nowrap; word-break: keep-all; cursor: pointer; pointer-events: auto; - padding: 4px 12px; - transition: all 150ms; - background-color: #00AB44; + padding: 0 12px; + transition: background-color 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out; + background-color: #007830; color: #FFFFFF; border-width: 1px; border-style: solid; - border-color: #00AB44; - border-radius: 2px; + border-color: #007830; + border-radius: 4px; box-sizing: border-box; text-decoration: none; } @@ -136,8 +165,8 @@ exports[`Button states renders loading icon 1`] = ` } .c0.c0:hover { - border-color: #42B861; - background-color: #42B861; + border-color: #006729; + background-color: #006729; color: #FFFFFF; text-decoration: none; } @@ -148,10 +177,27 @@ exports[`Button states renders loading icon 1`] = ` .c0.c0:active, .c0.c0[aria-expanded="true"] { - border-color: #00CD51; - background-color: #00CD51; + border-color: #005622; + background-color: #005622; color: #FFFFFF; - box-shadow: inset 0 4px 4px rgba(0,0,0,0.25); +} + +.c0.c0[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} + +.c0.c0[aria-disabled="true"]:hover, +.c0.c0[aria-disabled="true"]:active { + border-color: #007830; + background-color: #007830; + color: #FFFFFF; + text-decoration: none; +} + +.c0.c0[aria-disabled="true"]:hover .button-icon, +.c0.c0[aria-disabled="true"]:active .button-icon { + fill: #FFFFFF; } .c0.c0:focus { @@ -159,7 +205,7 @@ exports[`Button states renders loading icon 1`] = ` } .c0.c0:focus-visible { - outline: 2px solid #4A5757; + outline: 2px solid #007830; outline-offset: 2px; } @@ -197,6 +243,12 @@ exports[`Button states renders loading icon 1`] = ` stroke: #FFFFFF; } +@media (prefers-reduced-motion:reduce) { + .c0.c0 { + transition: none; + } +} + @media (prefers-reduced-motion:reduce) { .c0.c0 .ntd-spinner { animation: none; @@ -214,8 +266,8 @@ exports[`Button states renders loading icon 1`] = ` } + + ) + fireEvent.click(screen.getByRole("button", { name: "show help" })) + return screen.getByTestId("documentation-layer") +} + +describe("Documentation", () => { + it("passes toggle and isOpen to children and opens the layer", () => { + renderOpen() + expect(screen.getByRole("button", { name: "hide help" })).toBeInTheDocument() + expect(screen.getByTestId("documentation-layer")).toBeInTheDocument() + }) + + it("keeps every documented test id in the general view", () => { + renderOpen() + ;[ + "documentation-help-close", + "dashboard-info", + "documentation-link", + "documentation-report-bug-link", + "documentation-community-support-link", + "demo-link", + ].forEach(id => expect(screen.getByTestId(id)).toBeInTheDocument()) + }) + + it("closes from the header and reports it", () => { + const onCloseClick = jest.fn() + renderOpen({ onCloseClick }) + fireEvent.click(screen.getByTestId("documentation-help-close")) + expect(onCloseClick).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId("documentation-layer")).not.toBeInTheDocument() + }) + + it("switches to the dashboard view and back", () => { + renderOpen() + fireEvent.click(screen.getByTestId("dashboard-info")) + expect(screen.getByTestId("dashboard")).toBeInTheDocument() + fireEvent.click(screen.getByTestId("dashboard-back")) + expect(screen.getByTestId("documentation-link")).toBeInTheDocument() + }) + + it("frames the panel as a floating surface with 8px radius and a hairline border", () => { + const layer = renderOpen() + expect(layer).toHaveStyleRule("border-radius", "8px") + expect(layer).toHaveStyleRule("border", `1px solid ${colors.border}`) + const shadow = colors.dropdownShadow + expect(layer).toHaveStyleRule("box-shadow", `0 16px 40px -8px ${shadow},0 2px 6px ${shadow}`) + expect(layer).toHaveStyleRule("padding", "16px") + }) + + it("divides the header with the shared border colour", () => { + renderOpen() + const header = screen.getByTestId("documentation-help-close").parentElement + expect(header).toHaveStyleRule("border-bottom", `1px solid ${colors.border}`) + }) + + it("spends primary green on the docs link only", () => { + renderOpen() + expect(screen.getByTestId("documentation-link")).toHaveStyleRule( + "background-color", + colors.primary, + { modifier: "&&" } + ) + ;[ + "dashboard-info", + "documentation-report-bug-link", + "documentation-community-support-link", + "demo-link", + ].forEach(id => + expect(screen.getByTestId(id)).not.toHaveStyleRule("background-color", colors.primary, { + modifier: "&&", + }) + ) + }) + + it("underlines the inline issue link on hover instead of shifting its hue", () => { + renderOpen() + const link = screen.getByRole("link", { name: "Open an issue" }) + expect(link).toHaveStyleRule("text-decoration", "underline", { modifier: "&&&:hover" }) + expect(link).not.toHaveStyleRule("color", colors.accent, { modifier: "&&&:hover" }) + expect(link).toHaveStyleRule("text-underline-offset", "2px", { modifier: "&&&:hover" }) + }) + + it("separates the agent dashboard section with the shared border colour", () => { + renderOpen() + const separator = screen.getByTestId("dashboard-info").parentElement.parentElement.nextSibling + expect(separator).toHaveStyleRule("background-color", colors.border) + }) +}) diff --git a/src/organisms/documentation/index.usage.mdx b/src/organisms/documentation/index.usage.mdx new file mode 100644 index 000000000..3ad6b4c46 --- /dev/null +++ b/src/organisms/documentation/index.usage.mdx @@ -0,0 +1,57 @@ +import { Meta, Canvas } from "@storybook/addon-docs/blocks" +import * as Stories from "./index.stories" + + + +# Documentation + +A help panel opened from a trigger you provide. It links to the docs, bug reporting, the community and the public demo space, and has a docs search field. + +## When to use + +- Use it for a global "Need help?" entry point in the app. +- Do not use it for contextual help on one field. Use a tooltip instead. + +## Usage + +- Pass a render-prop child. It receives `toggle` and `isOpen` and returns your trigger. +- The panel opens as a layer at the bottom left with a backdrop. + +```jsx + + {(toggle, isOpen) => ( + + + + ) + +describe("Topbar", () => { + it("centres the main child and the actions vertically", () => { + const { container } = renderTopbar() + expect(container.firstChild).toHaveStyleRule("align-items", "center") + expect(container.firstChild).toHaveStyleRule("justify-content", "space-between") + }) + + it("renders the main child before the actions", () => { + renderTopbar() + const title = screen.getByText("Title") + const first = screen.getByRole("button", { name: "One" }) + expect(title.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it("keeps the existing action gaps", () => { + renderTopbar() + const actions = screen.getByRole("button", { name: "One" }).parentElement + expect(actions).toHaveStyleRule("gap", "20px") + }) + + it("widens the action gap when extended", () => { + renderTopbar({ extended: true }) + const actions = screen.getByRole("button", { name: "One" }).parentElement + expect(actions).toHaveStyleRule("gap", "32px") + }) +}) diff --git a/src/organisms/topbar/index.usage.mdx b/src/organisms/topbar/index.usage.mdx new file mode 100644 index 000000000..a32cdc1b4 --- /dev/null +++ b/src/organisms/topbar/index.usage.mdx @@ -0,0 +1,39 @@ +import { Meta, Canvas } from "@storybook/addon-docs/blocks" +import * as Stories from "./index.stories" + + + +# Topbar + +A header row with a title on the left and a group of actions on the right. + +## When to use + +- Use it at the top of a page, panel or room for a heading and its actions. +- Do not use it for tabbed navigation. Use Navigation instead. + +## Props + +- `mainChild`: content on the left, usually a heading. +- `children`: actions on the right, usually buttons. +- `extended`: widens the gap between actions. + + + +## Spacing + +- Default: actions are spaced by 5 units. +- Extended: actions are spaced by 8 units. Use it when the actions are icon buttons that need more air. + + + +## Accessibility + +- Topbar adds no roles. Give icon-only buttons an accessible label. +- Put the heading in `mainChild` as a real heading component. + +## Do and don't + +- Do keep one primary action, placed last. +- Do use borderless neutral buttons for secondary icon actions. +- Don't put long content in `children`. The row does not wrap. diff --git a/src/stories.test.js b/src/stories.test.js new file mode 100644 index 000000000..fcc199205 --- /dev/null +++ b/src/stories.test.js @@ -0,0 +1,51 @@ +import fs from "fs" +import path from "path" +import { composeStories, setProjectAnnotations } from "@storybook/react" +import { useTheme } from "styled-components" +import { render, cleanup } from "testUtilities" +import preview, { globalTypes } from "../.storybook/preview" + +setProjectAnnotations({ ...preview, globalTypes }) + +// jsdom has no IntersectionObserver; an inert one lets lazy stories mount without observing anything. +beforeAll(() => { + if (!window.IntersectionObserver) + window.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +}) + +const collect = dir => + fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) return collect(full) + return /\.stories\.js$/.test(entry.name) ? [full] : [] + }) + +const files = collect(__dirname).map(file => path.relative(__dirname, file)) + +// Storybook is the review surface for the UI kit, so every story must render in both themes. +// The theme travels as a story-level global; a `globals` prop on the composed story is ignored. +describe.each(files)("%s", file => { + const module = require(`./${file}`) + const names = Object.keys(composeStories(module)) + + it.each(names.flatMap(name => [[name, "light"], [name, "dark"]]))("%s renders in %s", (name, theme) => { + const { [name]: Story } = composeStories({ + ...module, + [name]: { ...(typeof module[name] === "function" ? { render: module[name] } : module[name]), globals: { theme } }, + }) + const { container } = render() + expect(container.firstChild).not.toBeNull() + cleanup() + }) +}) + +it("really renders the dark pass with the dark theme", () => { + const Probe = () => {useTheme().colors.mainBackground} + const { Story } = composeStories({ default: { title: "Probe" }, Story: { render: () => , globals: { theme: "dark" } } }) + const { getByTestId } = render() + expect(getByTestId("bg").textContent).toBe(require("@/theme").DarkTheme.colors.mainBackground) +}) diff --git a/src/theme/dark/colors.js b/src/theme/dark/colors.js index 43f791ac0..bf783ba55 100644 --- a/src/theme/dark/colors.js +++ b/src/theme/dark/colors.js @@ -14,7 +14,7 @@ const appColors = { elementBackgroundHover: rawColors.neutral.tuna, mainBackground: rawColors.neutral.grey15, mainBackgroundDisabled: rawColors.neutral.outerSpace, - modalHeaderBackground: rawColors.neutral.grey10, + modalHeaderBackground: rawColors.neutral.grey30, modalTabsBackground: rawColors.neutral.grey20, modalBackground: rawColors.neutral.grey30, modalInfoBackground: rawColors.neutral.grey50, @@ -32,6 +32,8 @@ const appColors = { linkHover: rawColors.green.green120, // Buttons primaryHighlight: rawColors.green.green100, + primaryHover: rawColors.green.green110, + primaryPressed: rawColors.green.green90, secondaryColor: rawColors.green.green110, secondaryHighlight: rawColors.green.green40, neutralHighlight: rawColors.neutral.grey50, @@ -52,6 +54,11 @@ const appColors = { successSemi: rawColors.green.green20, successBackground: rawColors.green.green50, successText: rawColors.green.green100, + // Text on soft (tinted) status pills: one step past the tone so it reaches 4.5:1 on its own fill. + successSoftText: rawColors.green.green100, + warningSoftText: rawColors.yellow.yellow70, + errorSoftText: rawColors.red.red120, + staleSoftText: rawColors.neutral.grey150, warning: rawColors.yellow.yellow60, warningLite: rawColors.yellow.yellow190, @@ -85,9 +92,9 @@ const appColors = { controlFocused: rawColors.neutral.white, selected: rawColors.neutral.grey55, highlight: rawColors.neutral.grey55, - tooltip: rawColors.neutral.outerSpace, - tooltipText: rawColors.neutral.grey150, - tooltipBg: rawColors.neutral.grey10, + tooltip: rawColors.neutral.grey55, + tooltipText: rawColors.neutral.grey185, + tooltipBg: rawColors.neutral.grey55, bright: rawColors.neutral.white, text: rawColors.neutral.grey150, textLite: rawColors.neutral.grey120, @@ -98,7 +105,7 @@ const appColors = { sectionTitle: rawColors.neutral.grey155, sectionDescription: rawColors.neutral.grey150, menuItem: rawColors.neutral.grey140, - placeholder: rawColors.neutral.grey110, + placeholder: rawColors.neutral.grey125, key: rawColors.neutral.iron, panel: rawColors.neutral.limedSpruce, panelBg: rawColors.neutral.grey25, @@ -122,10 +129,13 @@ const appColors = { dateTimePickerChipBg: rawColors.violet.violet20, dateTimePickerChipAccent: rawColors.violet.violet120, // Input colors + // Resting edge for fields: 3:1 against the page so the field is findable without hover. + controlBorder: rawColors.neutral.grey120, + textInfo: rawColors.blue.blue150, inputBg: rawColors.neutral.grey55, - inputBorder: rawColors.neutral.grey65, - inputBorderHover: rawColors.neutral.grey85, - inputBorderFocus: rawColors.neutral.grey85, + inputBorder: rawColors.neutral.grey85, + inputBorderHover: rawColors.neutral.grey100, + inputBorderFocus: rawColors.neutral.grey100, // Badges nodeBadgeBackground: rawColors.neutral.grey90, nodeBadgeBorder: rawColors.neutral.bluebayoux, @@ -143,7 +153,7 @@ const appColors = { idleClear: rawColors.green.green20, // Table - tableRowBgHover: rawColors.neutral.grey25, + tableRowBgHover: rawColors.neutral.grey30, tableRowBg: rawColors.neutral.grey25, tableRowBg2: rawColors.neutral.grey25, tableRowBg2Hover: rawColors.neutral.grey25, @@ -156,7 +166,7 @@ const appColors = { iconColor: rawColors.neutral.grey160, // table-resizer - resizerLine: rawColors.green.vista, + resizerLine: rawColors.green.green100, anomalyText: rawColors.purple.mauve, anomalyTextLite: rawColors.purple.mauveDark, diff --git a/src/theme/default/colors.js b/src/theme/default/colors.js index 21c8b6ded..3afd36f6c 100644 --- a/src/theme/default/colors.js +++ b/src/theme/default/colors.js @@ -1,7 +1,7 @@ import rawColors from "../rawColors" const appColors = { - primary: rawColors.green.green100, + primary: rawColors.green.green70, accent: rawColors.green.chateau, main: rawColors.neutral.limedSpruce, border: rawColors.neutral.grey180, @@ -10,14 +10,14 @@ const appColors = { disabledBackground: rawColors.neutral.grey160, dropdown: rawColors.neutral.white, dropdownShadow: rawColors.shadows.dropdownLight, - elementBackground: rawColors.neutral.blackhaze, + elementBackground: rawColors.neutral.grey195, elementBackgroundHover: rawColors.neutral.white, mainBackground: rawColors.neutral.white, - mainBackgroundDisabled: rawColors.neutral.blackhaze, + mainBackgroundDisabled: rawColors.neutral.grey195, modalHeaderBackground: rawColors.neutral.grey185, modalTabsBackground: rawColors.neutral.grey190, modalBackground: rawColors.neutral.grey195, - modalInfoBackground: rawColors.neutral.blackhaze, + modalInfoBackground: rawColors.neutral.grey195, menuItemSelected: rawColors.neutral.grey180, menuItemHover: rawColors.neutral.grey185, tabActiveBackground: rawColors.transparent.full, @@ -27,11 +27,13 @@ const appColors = { scrollBarThumbHover: rawColors.neutral.grey150, scrollBarBorder: rawColors.neutral.grey180, // Links - link: rawColors.green.green100, - linkHover: rawColors.green.green110, + link: rawColors.green.green70, + linkHover: rawColors.green.green60, // Buttons primaryHighlight: rawColors.green.green110, - secondaryColor: rawColors.green.green100, + primaryHover: rawColors.green.green60, + primaryPressed: rawColors.green.green50, + secondaryColor: rawColors.green.green70, secondaryHighlight: rawColors.green.green190, neutralHighlight: rawColors.neutral.grey180, // Buttons - AI @@ -49,27 +51,32 @@ const appColors = { successLite: rawColors.green.green190, successSemi: rawColors.green.green190, successBackground: rawColors.green.green50, - successText: rawColors.green.green100, - - warning: rawColors.yellow.yellow70, + successText: rawColors.green.green70, + // Text on soft (tinted) status pills: one step past the tone so it reaches 4.5:1 on its own fill. + successSoftText: rawColors.green.green70, + warningSoftText: rawColors.yellow.yellow50, + errorSoftText: rawColors.red.red90, + staleSoftText: rawColors.neutral.grey100, + + warning: rawColors.yellow.yellow50, warningLite: rawColors.yellow.yellow190, warningSemi: rawColors.yellow.yellow190, warningBackground: rawColors.yellow.yellow160, warningBannerBg: rawColors.yellow.yellow160, - warningText: rawColors.yellow.seaBuckthorn, + warningText: rawColors.yellow.yellow40, error: rawColors.red.red100, errorLite: rawColors.red.red190, errorSemi: rawColors.red.red190, errorBackground: rawColors.red.red150, errorBannerBg: rawColors.red.red170, - errorText: rawColors.red.pomegranate, + errorText: rawColors.red.red100, generic: rawColors.neutral.grey165, live: rawColors.green.green100, - stale: rawColors.green.green1000, - staleSemi: rawColors.green.green900, + stale: rawColors.neutral.grey130, + staleSemi: rawColors.neutral.grey120, unseen: rawColors.yellow.yellow900, offline: rawColors.neutral.grey145, metricGap: rawColors.neutral.metricGapLight, @@ -84,9 +91,9 @@ const appColors = { selected: rawColors.neutral.grey180, highlight: rawColors.neutral.grey180, - tooltip: rawColors.neutral.grey180, - tooltipText: rawColors.neutral.grey100, - tooltipBg: rawColors.neutral.grey190, + tooltip: rawColors.neutral.limedSpruce, + tooltipText: rawColors.neutral.grey195, + tooltipBg: rawColors.neutral.limedSpruce, bright: rawColors.neutral.white, text: rawColors.neutral.grey100, textLite: rawColors.neutral.grey120, @@ -96,17 +103,18 @@ const appColors = { sectionHeaderBackground: rawColors.neutral.limedSpruce, sectionTitle: rawColors.neutral.grey100, sectionDescription: rawColors.neutral.grey120, - placeholder: rawColors.neutral.grey140, + placeholder: rawColors.neutral.grey110, key: rawColors.neutral.regentgrey, panel: rawColors.neutral.limedSpruce, panelBg: rawColors.neutral.grey195, - panelBgGrad: `linear-gradient(to right, ${rawColors.neutral.grey195} 0%, ${rawColors.neutral.white} 50%, ${rawColors.neutral.grey195} 100%)`, - mainChartBg: rawColors.neutral.blackhaze, + // Flat on purpose: gradients made panels read as a second surface system. + panelBgGrad: rawColors.neutral.grey195, + mainChartBg: rawColors.neutral.grey195, mainChartHeaderBg: rawColors.neutral.grey190, mainChartBorder: rawColors.neutral.grey185, mainChartTboxHover: rawColors.neutral.grey180, sideBar: rawColors.neutral.grey190, - sideBarMini: rawColors.neutral.grey185, + sideBarMini: rawColors.neutral.grey190, spaceSelected: rawColors.neutral.grey175, spaceIdle: rawColors.neutral.grey195, spaceHovered: rawColors.neutral.grey180, @@ -116,16 +124,19 @@ const appColors = { elevationLevelOne: rawColors.neutral.grey185, // Date picker - datePickerPausedBg: rawColors.neutral.grey170, - datePickerPlayBg: rawColors.green.green180, + datePickerPausedBg: rawColors.neutral.grey185, + datePickerPlayBg: rawColors.neutral.grey190, dateTimePickerChipBg: rawColors.violet.violet190, dateTimePickerChipAccent: rawColors.violet.violet130, // Input colors + // Resting edge for fields: 3:1 against the page so the field is findable without hover. + controlBorder: rawColors.neutral.grey130, + textInfo: rawColors.blue.blue100, inputBg: rawColors.neutral.white, - inputBorder: rawColors.neutral.grey180, - inputBorderHover: rawColors.neutral.grey165, - inputBorderFocus: rawColors.neutral.grey165, + inputBorder: rawColors.neutral.grey130, + inputBorderHover: rawColors.neutral.grey110, + inputBorderFocus: rawColors.neutral.grey110, // Badges nodeBadgeBackground: rawColors.neutral.grey155, @@ -146,7 +157,7 @@ const appColors = { // Table dropdownTable: rawColors.neutral.white, tableRowBg: rawColors.neutral.grey195, - tableRowBgHover: rawColors.neutral.grey180, + tableRowBgHover: rawColors.neutral.grey190, tableRowBg2: rawColors.neutral.grey195, tableRowBg2Hover: rawColors.neutral.grey180, columnHighlight: rawColors.neutral.grey190, @@ -158,7 +169,7 @@ const appColors = { progressBg: rawColors.neutral.chineseWhite, // table-resizer - resizerLine: rawColors.green.vista, + resizerLine: rawColors.green.green100, anomalyText: rawColors.purple.lilac, anomalyTextLite: rawColors.purple.lilacLite, diff --git a/src/theme/default/constants.js b/src/theme/default/constants.js index e31ab4cd9..478c19930 100644 --- a/src/theme/default/constants.js +++ b/src/theme/default/constants.js @@ -4,3 +4,10 @@ export const SIZE_SUB_UNIT = 4 export const SIZE_UNIT = 8 export const GUTTER_HEIGHT = 8 + +// One control ladder (px) shared by buttons, fields, selects and icon buttons, so rows always align. +export const CONTROL_SIZES = { + compact: { height: 24, fontSize: 12 }, + default: { height: 28, fontSize: 13 }, + large: { height: 32, fontSize: 14 }, +} diff --git a/src/theme/index.test.js b/src/theme/index.test.js index 29887b999..eb954103d 100644 --- a/src/theme/index.test.js +++ b/src/theme/index.test.js @@ -12,12 +12,137 @@ describe("theme colors", () => { expect(DarkTheme.colors.mapSearchHighlight).toBe(rawColors.blue.blue150) }) - it("owns a horizontal panel background gradient in both themes", () => { - expect(DefaultTheme.colors.panelBgGrad).toBe( - `linear-gradient(to right, ${rawColors.neutral.grey195} 0%, ${rawColors.neutral.white} 50%, ${rawColors.neutral.grey195} 100%)` - ) + it("keeps panelBgGrad flat in light so legacy panels follow the surface ladder", () => { + expect(DefaultTheme.colors.panelBgGrad).toBe(DefaultTheme.colors.panelBg) expect(DarkTheme.colors.panelBgGrad).toBe( `linear-gradient(to right, ${rawColors.neutral.grey25} 0%, ${rawColors.neutral.grey15} 50%, ${rawColors.neutral.grey25} 100%)` ) }) }) + +describe("visual refresh token values", () => { + it("uses an AA-contrast red for error text in light theme", () => { + expect(DefaultTheme.colors.errorText).toBe(rawColors.red.red100) + }) + + it("uses an AA-contrast amber for warning in light theme", () => { + expect(DefaultTheme.colors.warning).toBe(rawColors.yellow.yellow50) + }) + + it("gives dark input borders a visible edge", () => { + expect(DarkTheme.colors.inputBorder).toBe(rawColors.neutral.grey85) + expect(DarkTheme.colors.inputBorderHover).toBe(rawColors.neutral.grey100) + }) + + it("keeps dark focused input borders distinct from resting borders", () => { + expect(DarkTheme.colors.inputBorderFocus).not.toBe(DarkTheme.colors.inputBorder) + }) + + it("renders tooltips as an inverse surface", () => { + expect(DefaultTheme.colors.tooltip).toBe(rawColors.neutral.limedSpruce) + expect(DefaultTheme.colors.tooltipText).toBe(rawColors.neutral.grey195) + expect(DarkTheme.colors.tooltip).toBe(rawColors.neutral.grey55) + expect(DarkTheme.colors.tooltipText).toBe(rawColors.neutral.grey185) + }) + + it("keeps table row dividers visible under hover", () => { + expect(DefaultTheme.colors.tableRowBgHover).toBe(rawColors.neutral.grey190) + expect(DarkTheme.colors.tableRowBgHover).toBe(rawColors.neutral.grey30) + }) + + it("draws the column resizer in primary green", () => { + expect(DefaultTheme.colors.resizerLine).toBe(rawColors.green.green100) + expect(DarkTheme.colors.resizerLine).toBe(rawColors.green.green100) + }) + + it("lifts the dark modal header above the page", () => { + expect(DarkTheme.colors.modalHeaderBackground).toBe(rawColors.neutral.grey30) + }) + + it("exposes hover and pressed steps for primary actions", () => { + expect(DefaultTheme.colors.primaryHover).toBe(rawColors.green.green60) + expect(DefaultTheme.colors.primaryPressed).toBe(rawColors.green.green50) + expect(DarkTheme.colors.primaryHover).toBe(rawColors.green.green110) + expect(DarkTheme.colors.primaryPressed).toBe(rawColors.green.green90) + }) +}) + +describe("soft pill text keys", () => { + it.each([ + ["successSoftText", rawColors.green.green70, rawColors.green.green100], + ["warningSoftText", rawColors.yellow.yellow50, rawColors.yellow.yellow70], + ["errorSoftText", rawColors.red.red90, rawColors.red.red120], + ["staleSoftText", rawColors.neutral.grey100, rawColors.neutral.grey150], + ])("%s exists in both themes", (key, light, dark) => { + expect(DefaultTheme.colors[key]).toBe(light) + expect(DarkTheme.colors[key]).toBe(dark) + }) +}) + +describe("approved colour proposal", () => { + it("adds controlBorder and textInfo to both themes", () => { + expect(DefaultTheme.colors.controlBorder).toBe(rawColors.neutral.grey130) + expect(DarkTheme.colors.controlBorder).toBe(rawColors.neutral.grey120) + expect(DefaultTheme.colors.textInfo).toBe(rawColors.blue.blue100) + expect(DarkTheme.colors.textInfo).toBe(rawColors.blue.blue150) + }) + + it("darkens the light action green so white labels reach 4.5:1", () => { + expect(DefaultTheme.colors.primary).toBe(rawColors.green.green70) + expect(DefaultTheme.colors.primaryHover).toBe(rawColors.green.green60) + expect(DefaultTheme.colors.primaryPressed).toBe(rawColors.green.green50) + expect(DefaultTheme.colors.secondaryColor).toBe(rawColors.green.green70) + expect(DefaultTheme.colors.link).toBe(rawColors.green.green70) + expect(DefaultTheme.colors.linkHover).toBe(rawColors.green.green60) + expect(DefaultTheme.colors.successText).toBe(rawColors.green.green70) + }) + + it("keeps the status green unchanged", () => { + expect(DefaultTheme.colors.success).toBe(rawColors.green.green100) + }) + + it("raises warning text and placeholder contrast", () => { + expect(DefaultTheme.colors.warningText).toBe(rawColors.yellow.yellow40) + expect(DefaultTheme.colors.placeholder).toBe(rawColors.neutral.grey110) + expect(DarkTheme.colors.placeholder).toBe(rawColors.neutral.grey125) + }) +}) + +describe("tooltip surfaces", () => { + // Apps draw tooltip containers on tooltipBg with tooltipText; both must be the same inverse pair as `tooltip`. + it("paints tooltipBg like tooltip so tooltipText stays readable on it", () => { + expect(DefaultTheme.colors.tooltipBg).toBe(DefaultTheme.colors.tooltip) + }) +}) + +it("paints dark tooltipBg like the drops tooltip", () => { + expect(DarkTheme.colors.tooltipBg).toBe(DarkTheme.colors.tooltip) +}) + +describe("light surface ladder and field edges (audit L1–L4)", () => { + const c = DefaultTheme.colors + const n = rawColors.neutral + it("uses one lightest grey: blackhaze keys sit on grey195", () => { + ;["elementBackground", "mainChartBg", "mainBackgroundDisabled", "modalInfoBackground"].forEach(key => + expect([key, c[key]]).toEqual([key, n.grey195]) + ) + }) + it("puts the sidebar on grey190 with hover one step darker", () => { + expect(c.sideBarMini).toBe(n.grey190) + expect(c.menuItemHover).toBe(n.grey185) + }) + it("keeps the time picker neutral; green stays for status", () => { + expect(c.datePickerPlayBg).toBe(n.grey190) + expect(c.datePickerPausedBg).toBe(n.grey185) + }) + it("gives fields one softer edge that still reaches 3:1, and darkens it on hover", () => { + expect(c.controlBorder).toBe(n.grey130) + expect(c.inputBorder).toBe(n.grey130) + expect(c.inputBorderHover).toBe(n.grey110) + expect(c.inputBorderFocus).toBe(n.grey110) + }) + it("renders stale as a neutral grey", () => { + expect(c.stale).toBe(n.grey130) + expect(c.staleSemi).toBe(n.grey120) + }) +}) diff --git a/visual/playwright.config.js b/visual/playwright.config.js new file mode 100644 index 000000000..503785062 --- /dev/null +++ b/visual/playwright.config.js @@ -0,0 +1,29 @@ +const { defineConfig } = require("@playwright/test") + +const port = 6200 + +// Baselines are only valid when produced in the pinned Playwright image (see `yarn test:visual`): +// fonts and anti-aliasing differ between macOS and Linux. +module.exports = defineConfig({ + testDir: __dirname, + testMatch: /stories\.spec\.js$/, + outputDir: `${__dirname}/test-results`, + snapshotPathTemplate: `${__dirname}/__screenshots__/{arg}{ext}`, + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + reporter: [["list"], ["html", { outputFolder: `${__dirname}/report`, open: "never" }]], + expect: { + toHaveScreenshot: { maxDiffPixelRatio: 0.001, animations: "disabled", caret: "hide" }, + }, + use: { + baseURL: `http://localhost:${port}`, + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: 1, + }, + webServer: { + command: `node ${__dirname}/serve.js`, + port, + env: { PORT: String(port) }, + reuseExistingServer: false, + }, +}) diff --git a/visual/serve.js b/visual/serve.js new file mode 100644 index 000000000..99d9e0e22 --- /dev/null +++ b/visual/serve.js @@ -0,0 +1,33 @@ +// Dependency-free static server for the built Storybook, so the Playwright container needs nothing extra. +const http = require("http") +const fs = require("fs") +const path = require("path") + +const root = path.resolve(__dirname, "../storybook-static") +const port = Number(process.env.PORT || 6200) + +const types = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + +http + .createServer((req, res) => { + const url = decodeURIComponent(req.url.split("?")[0]) + const file = path.join(root, url.endsWith("/") ? `${url}index.html` : url) + if (!file.startsWith(root) || !fs.existsSync(file)) { + res.writeHead(404) + res.end() + return + } + res.writeHead(200, { "Content-Type": types[path.extname(file)] || "application/octet-stream" }) + fs.createReadStream(file).pipe(res) + }) + .listen(port, () => console.log(`storybook-static on ${port}`)) diff --git a/visual/stories.spec.js b/visual/stories.spec.js new file mode 100644 index 000000000..c98a767dd --- /dev/null +++ b/visual/stories.spec.js @@ -0,0 +1,24 @@ +const fs = require("fs") +const path = require("path") +const { test, expect } = require("@playwright/test") + +const index = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../storybook-static/index.json"), "utf8") +) + +// Stories that depend on live time, randomness or network opt out with the `no-visual` tag. +const stories = Object.values(index.entries).filter( + entry => entry.type === "story" && !entry.tags?.includes("no-visual") +) + +for (const story of stories) { + for (const theme of ["light", "dark"]) { + test(`${story.id} (${theme})`, async ({ page }) => { + await page.goto(`/iframe.html?id=${story.id}&viewMode=story&globals=theme:${theme}`) + await page.waitForSelector("#storybook-root > *") + await page.evaluate(() => document.fonts.ready) + await page.waitForLoadState("networkidle") + await expect(page).toHaveScreenshot(`${story.id}--${theme}.png`, { fullPage: true }) + }) + } +} diff --git a/webpack.config.js b/webpack.config.js index 2843baccf..0601f2f1c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -45,6 +45,8 @@ module.exports = { resolve: { alias: { "@": path.resolve(__dirname, "src"), + // The script-tag bundle cannot inject CSS or emit font files; bundler consumers load the fonts via the cjs/es6 builds. + "@fontsource/ibm-plex-sans": false, }, }, } diff --git a/yarn.lock b/yarn.lock index 97cf73cb5..e2a08bd16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,6 +1503,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== +"@fontsource/ibm-plex-sans@^5.3.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz#8099950b404e625f65e7bd833bcb84ffe6bcd40c" + integrity sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w== + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -1867,6 +1872,13 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== +"@playwright/test@1.62.1": + version "1.62.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.62.1.tgz#68e1aaf6e480e1923936dee6c66fae1921d3a65a" + integrity sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ== + dependencies: + playwright "1.62.1" + "@prismicio/client@^7.21.6": version "7.21.6" resolved "https://registry.yarnpkg.com/@prismicio/client/-/client-7.21.6.tgz#04fabe495e7c382c2efa16a972d5fa3b3d294bc8" @@ -4860,6 +4872,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^2.3.3, fsevents@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -7164,6 +7181,20 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +playwright-core@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785" + integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw== + +playwright@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700" + integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg== + dependencies: + playwright-core "1.62.1" + optionalDependencies: + fsevents "2.3.2" + polished@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548"