diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 353dc454160..cd421f9febc 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -36,6 +36,9 @@ jobs: if: ${{ needs.changes.outputs.playwright == 'true' }} timeout-minutes: 18 # 2024-01-18 avg: 5.0m max: 7.0m runs-on: ubuntu-24.04 + container: + # Keep this tag in sync with EXPECTED_PLAYWRIGHT_VERSION below. + image: mcr.microsoft.com/playwright:v1.62.1-noble permissions: contents: read id-token: write # required for Doppler OIDC @@ -49,6 +52,10 @@ jobs: steps: - name: โฌ‡๏ธ Checkout repo uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: ๐Ÿ”ง Trust Git workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Write TOML run: | echo "[runtime]" > .marimo.toml @@ -61,6 +68,11 @@ jobs: run: | sed -i 's/auto_instantiate = false/auto_instantiate = true/' pyproject.toml + - name: ๐Ÿ› ๏ธ Install build tools + run: | + apt-get update + apt-get install --yes --no-install-recommends make + - name: ๐Ÿ” Fetch Doppler secrets # Skip on PRs from forks: id-token write is downgraded to read, so OIDC fails. # Downstream tokens (TURBO_TOKEN, CODECOV_TOKEN) are optional and tolerate empty. @@ -89,26 +101,17 @@ jobs: run: | echo "MARIMO_VERSION=$(uv run marimo --version)" >> $GITHUB_ENV - - name: ๐ŸŽญ Get installed Playwright version - id: playwright-version + - name: ๐ŸŽญ Verify Playwright image version working-directory: ./frontend + env: + EXPECTED_PLAYWRIGHT_VERSION: 1.62.1 run: | - PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test | grep @playwright | sed 's/.*@//') - echo "Playwright's Version: $PLAYWRIGHT_VERSION" - echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV - - - name: ๐Ÿ“ฆ Cache playwright binaries - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - id: playwright-cache - with: - path: | - ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-2-${{ env.PLAYWRIGHT_VERSION }} - - - name: ๐Ÿ“ฅ Install Playwright Browsers - working-directory: ./frontend - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: pnpm playwright install chromium --with-deps + ACTUAL_PLAYWRIGHT_VERSION=$(pnpm exec playwright --version) + ACTUAL_PLAYWRIGHT_VERSION=${ACTUAL_PLAYWRIGHT_VERSION#Version } + if [[ "$ACTUAL_PLAYWRIGHT_VERSION" != "$EXPECTED_PLAYWRIGHT_VERSION" ]]; then + echo "Playwright package $ACTUAL_PLAYWRIGHT_VERSION does not match image $EXPECTED_PLAYWRIGHT_VERSION" >&2 + exit 1 + fi - name: ๐ŸŽญ Run Playwright tests working-directory: ./frontend diff --git a/DESIGN.md b/DESIGN.md index 68a12b472e1..104ba82d3ed 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -36,21 +36,41 @@ colors: on-accent: "#0B68CB" on-accent-dark: "#B6ECF7" ring: "#94A3B8" - destructive: "#FF6666" - on-destructive: "#F8FAFC" - error: "#EA5D5D" - on-error: "#F8FAFC" - success: "#66FF7F" - on-success: "#F8FAFC" - action: "#FEF2A5" - action-hover: "#FFF8BB" - on-action: "#946800" + ring-dark: "#2870BD" + destructive: "#E5484D" + destructive-dark: "#72232D" + destructive-hover: "#DC3E42" + destructive-hover-dark: "#8C333A" + destructive-border: "#CE2C31" + destructive-border-dark: "#FF9592" + on-destructive: "#FFFCFC" + on-destructive-dark: "#FFD1D9" + error: "#E5484D" + error-dark: "#72232D" + on-error: "#FFFCFC" + on-error-dark: "#FFD1D9" + success: "#46A758" + success-dark: "#2D5736" + success-hover: "#3E9B4F" + success-hover-dark: "#366740" + success-border: "#2A7E3B" + success-border-dark: "#71D083" + on-success: "#FBFEFB" + on-success-dark: "#C2F0C2" + action: "#FFE629" + action-dark: "#524202" + action-hover: "#FFDC00" + action-hover-dark: "#665417" + action-border: "#9E6C00" + action-border-dark: "#F5E147" + on-action: "#473B1F" + on-action-dark: "#F6EEB4" link: "#0B68CB" link-dark: "#479BF5" link-visited: "#8E4EC6" link-visited-dark: "#BF9BDF" stale: "#EBE2CC" - stale-dark: "#3E3720" + stale-dark: "#525342" code-background: "#FFFFFF" code-background-dark: "#282C34" surface: "#FFFFFF" diff --git a/Makefile b/Makefile index a88b0ad60e8..fbdd48de2fb 100644 --- a/Makefile +++ b/Makefile @@ -98,6 +98,16 @@ fe-test: e2e: cd frontend; pnpm playwright install; pnpm playwright test +.PHONY: visual-test +# ๐Ÿ–ผ๏ธ Test visual baselines in the pinned Playwright container +visual-test: fe + ./scripts/run-visual-regression.sh + +.PHONY: visual-update +# ๐Ÿ–ผ๏ธ Update visual baselines in the pinned Playwright container +visual-update: fe + ./scripts/run-visual-regression.sh --update-snapshots + .PHONY: fe-lint # ๐Ÿงน Lint frontend fe-lint: diff --git a/frontend/e2e-tests/README.md b/frontend/e2e-tests/README.md index a9fdd17eece..7f87f3cdad6 100644 --- a/frontend/e2e-tests/README.md +++ b/frontend/e2e-tests/README.md @@ -25,26 +25,23 @@ The focused visual suite protects shared theme, typography, chrome, and layout b ### Run the visual suite -If frontend files changed, rebuild the frontend from the repository root: +Visual snapshots are captured in the same pinned Playwright container locally +and in CI. On macOS, the script uses the Docker CLI with OrbStack or another +Docker-compatible runtime. -```bash -make fe -``` - -Run the focused suite from `frontend/`: +From the repository root, build the frontend and run the focused suite: ```bash -pnpm playwright test e2e-tests/visual-regression.spec.ts --project=chromium +make visual-test ``` ### Update visual baselines -CAUTION: Generate committed baselines on Ubuntu 24.04. Native macOS images use a different platform suffix. - -Run this command from `frontend/`: +Generate committed baselines with the pinned container, not with native +Playwright. From the repository root, run: ```bash -pnpm playwright test e2e-tests/visual-regression.spec.ts --project=chromium --update-snapshots +make visual-update ``` Review every changed PNG. Then run the suite again without `--update-snapshots`. diff --git a/frontend/e2e-tests/playwright-container-proxy.cjs b/frontend/e2e-tests/playwright-container-proxy.cjs new file mode 100644 index 00000000000..8d1638386ce --- /dev/null +++ b/frontend/e2e-tests/playwright-container-proxy.cjs @@ -0,0 +1,25 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +const net = require("node:net"); + +const port = Number(process.argv[2]); +const upstreamPort = Number(process.argv[3]); + +if (!Number.isInteger(port) || !Number.isInteger(upstreamPort)) { + throw new Error( + `Expected integer ports, received ${process.argv[2]} and ${process.argv[3]}`, + ); +} + +net + .createServer((client) => { + const upstream = net.connect({ + host: "host.docker.internal", + port: upstreamPort, + }); + + client.pipe(upstream).pipe(client); + client.on("error", () => upstream.destroy()); + upstream.on("error", () => client.destroy()); + }) + .listen(port, "127.0.0.1"); diff --git a/frontend/e2e-tests/visual-regression.container.config.ts b/frontend/e2e-tests/visual-regression.container.config.ts new file mode 100644 index 00000000000..078a67b6c00 --- /dev/null +++ b/frontend/e2e-tests/visual-regression.container.config.ts @@ -0,0 +1,47 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import type { PlaywrightTestConfig } from "@playwright/test"; +import { devices } from "@playwright/test"; + +const config: PlaywrightTestConfig = { + testDir: ".", + testMatch: "visual-regression.spec.ts", + outputDir: "../test-results/visual-regression", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: false, + forbidOnly: true, + workers: 1, + reporter: "html", + use: { + actionTimeout: 5_000, + navigationTimeout: 10_000, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 720 }, + }, + }, + ], + webServer: [ + { + command: "node playwright-container-proxy.cjs 2718 2738", + url: "http://127.0.0.1:2718", + reuseExistingServer: true, + }, + { + command: "node playwright-container-proxy.cjs 2725 2739", + url: "http://127.0.0.1:2725", + reuseExistingServer: true, + }, + ], +}; + +export default config; diff --git a/frontend/e2e-tests/visual-regression.spec.ts b/frontend/e2e-tests/visual-regression.spec.ts index 5f50478a1f9..2f9780376b2 100644 --- a/frontend/e2e-tests/visual-regression.spec.ts +++ b/frontend/e2e-tests/visual-regression.spec.ts @@ -51,6 +51,17 @@ function getThemedUrl(app: ApplicationNames, theme: VisualTheme): string { return url.toString(); } +async function resolveColor(page: Page, value: string): Promise { + return page.evaluate((cssValue) => { + const probe = document.createElement("span"); + probe.style.color = cssValue; + document.body.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }, value); +} + async function prepareVisualCase( page: Page, visualCase: VisualCase, @@ -98,6 +109,79 @@ async function prepareVisualCase( const focusedInput = page.getByTestId("marimo-plugin-text-input"); await focusedInput.focus(); await expect(focusedInput).toBeFocused(); + + await expect(page.locator("select").first()).toBeVisible(); + + const accentColor = await page.locator("html").evaluate( + (element) => getComputedStyle(element).accentColor, + ); + const primaryColor = await resolveColor(page, "var(--primary)"); + expect(accentColor).toBe(primaryColor); + + const semanticAliases = + visualCase.theme === "light" + ? [ + ["--ring", "hsl(215deg 20.2% 65.1%)"], + ["--destructive", "var(--red-9)"], + ["--destructive-hover", "var(--red-10)"], + ["--destructive-border", "var(--red-11)"], + ["--destructive-foreground", "var(--red-1)"], + ["--error", "var(--red-9)"], + ["--error-foreground", "var(--red-1)"], + ["--success", "var(--grass-9)"], + ["--success-hover", "var(--grass-10)"], + ["--success-border", "var(--grass-11)"], + ["--success-foreground", "var(--grass-1)"], + ["--action", "var(--yellow-9)"], + ["--action-hover", "var(--yellow-10)"], + ["--action-border", "var(--yellow-11)"], + ["--action-foreground", "var(--yellow-12)"], + ] + : [ + ["--ring", "var(--blue-8)"], + ["--destructive", "var(--red-6)"], + ["--destructive-hover", "var(--red-7)"], + ["--destructive-border", "var(--red-11)"], + ["--destructive-foreground", "var(--red-12)"], + ["--error", "var(--red-6)"], + ["--error-foreground", "var(--red-12)"], + ["--success", "var(--grass-6)"], + ["--success-hover", "var(--grass-7)"], + ["--success-border", "var(--grass-11)"], + ["--success-foreground", "var(--grass-12)"], + ["--action", "var(--yellow-6)"], + ["--action-hover", "var(--yellow-7)"], + ["--action-border", "var(--yellow-11)"], + ["--action-foreground", "var(--yellow-12)"], + ]; + + for (const [semanticToken, sourceColor] of semanticAliases) { + const actualColor = await resolveColor(page, `var(${semanticToken})`); + const expectedColor = await resolveColor(page, sourceColor); + expect(actualColor, semanticToken).toBe(expectedColor); + } + + for (const [label, semanticToken] of [ + ["Success", "--success-border"], + ["Warning", "--action-border"], + ["Danger", "--destructive-border"], + ] as const) { + const button = page.getByRole("button", { name: label }); + const colors = await button.evaluate((element) => { + const style = getComputedStyle(element); + return { + background: style.backgroundColor, + border: style.borderColor, + }; + }); + const expectedBorder = await resolveColor( + page, + `color-mix(in srgb, var(${semanticToken}), transparent 0%)`, + ); + + expect(colors.border, label).toBe(expectedBorder); + expect(colors.border, label).not.toBe(colors.background); + } } for (const visualCase of VISUAL_CASES) { diff --git a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-1280x720-chromium-linux.png b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-1280x720-chromium-linux.png index a361968bf86..857e1e136d1 100644 Binary files a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-1280x720-chromium-linux.png and b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-1280x720-chromium-linux.png differ diff --git a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-390x720-chromium-linux.png b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-390x720-chromium-linux.png index 4774a9b92b5..c1765480589 100644 Binary files a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-390x720-chromium-linux.png and b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-dark-390x720-chromium-linux.png differ diff --git a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-1280x720-chromium-linux.png b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-1280x720-chromium-linux.png index 00bb1313dab..ab749d667e4 100644 Binary files a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-1280x720-chromium-linux.png and b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-1280x720-chromium-linux.png differ diff --git a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-390x720-chromium-linux.png b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-390x720-chromium-linux.png index d9a475c5d43..690cf0b41bf 100644 Binary files a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-390x720-chromium-linux.png and b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/edit-light-390x720-chromium-linux.png differ diff --git a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-1280x720-chromium-linux.png b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-1280x720-chromium-linux.png index 6f5511882c7..760c4459999 100644 Binary files a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-1280x720-chromium-linux.png and b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-1280x720-chromium-linux.png differ diff --git a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-390x720-chromium-linux.png b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-390x720-chromium-linux.png index 8fe26840e02..bd0ac6d5b4a 100644 Binary files a/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-390x720-chromium-linux.png and b/frontend/e2e-tests/visual-regression.spec.ts-snapshots/read-dark-390x720-chromium-linux.png differ diff --git a/frontend/src/components/ui/__tests__/button.test.ts b/frontend/src/components/ui/__tests__/button.test.ts new file mode 100644 index 00000000000..60178dfa7b6 --- /dev/null +++ b/frontend/src/components/ui/__tests__/button.test.ts @@ -0,0 +1,42 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { describe, expect, it } from "vitest"; +import { buttonVariants } from "../button"; + +describe("buttonVariants", () => { + it.each([ + { + variant: "destructive" as const, + expected: [ + "bg-destructive", + "text-destructive-foreground", + "border-destructive-border", + "hover:bg-destructive-hover", + ], + }, + { + variant: "success" as const, + expected: [ + "bg-success", + "text-success-foreground", + "border-success-border", + "hover:bg-success-hover", + ], + }, + { + variant: "warn" as const, + expected: [ + "bg-action", + "text-action-foreground", + "border-action-border", + "hover:bg-action-hover", + ], + }, + ])("uses semantic theme colors for $variant", ({ variant, expected }) => { + const classes = buttonVariants({ variant }); + + expect(classes).toEqual(expect.stringContaining(expected.join(" "))); + expect(classes).not.toMatch(/--(?:red|grass|yellow)-/); + expect(classes).not.toContain("dark:"); + }); +}); diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 51680a2cd66..60bd0e8218f 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -24,28 +24,22 @@ const buttonVariants = cva( ), destructive: cn( "border shadow-xs", - "bg-(--red-9) hover:bg-(--red-10) dark:bg-(--red-6) dark:hover:bg-(--red-7)", - "text-(--red-1) dark:text-(--red-12)", - "border-(--red-11)", + "bg-destructive text-destructive-foreground border-destructive-border hover:bg-destructive-hover", activeCommon, ), success: cn( "border shadow-xs", - "bg-(--grass-9) hover:bg-(--grass-10) dark:bg-(--grass-6) dark:hover:bg-(--grass-7)", - "text-(--grass-1) dark:text-(--grass-12)", - "border-(--grass-11)", + "bg-success text-success-foreground border-success-border hover:bg-success-hover", activeCommon, ), warn: cn( "border shadow-xs", - "bg-(--yellow-9) hover:bg-(--yellow-10) dark:bg-(--yellow-6) dark:hover:bg-(--yellow-7)", - "text-(--yellow-12)", - "border-(--yellow-11)", + "bg-action text-action-foreground border-action-border hover:bg-action-hover", activeCommon, ), action: cn( "bg-action text-action-foreground shadow-xs", - "hover:bg-action-hover border border-action", + "hover:bg-action-hover border border-action-border", activeCommon, ), outline: cn( diff --git a/frontend/src/css/globals.css b/frontend/src/css/globals.css index c11cbf44b30..caaee3c2387 100644 --- a/frontend/src/css/globals.css +++ b/frontend/src/css/globals.css @@ -83,19 +83,26 @@ --ring: hsl(215deg 20.2% 65.1%); /* Semantic colors. */ - --destructive: hsl(0deg 100% 70%); - --destructive-foreground: hsl(210deg 40% 98%); - --error: hsl(0deg 77% 64%); - --error-foreground: hsl(210deg 40% 98%); - --success: hsl(130deg 100% 70%); - --success-foreground: hsl(210deg 40% 98%); - --action: hsl(52deg 96.8% 82%); - --action-hover: hsl(54deg 100% 86.7%); - --action-foreground: hsl(42deg 100% 29%); + --destructive: var(--red-9, #e5484d); + --destructive-hover: var(--red-10, #dc3e42); + --destructive-border: var(--red-11, #ce2c31); + --destructive-foreground: var(--red-1, #fffcfc); + --error: var(--red-9, #e5484d); + --error-foreground: var(--red-1, #fffcfc); + --success: var(--grass-9, #46a758); + --success-hover: var(--grass-10, #3e9b4f); + --success-border: var(--grass-11, #2a7e3b); + --success-foreground: var(--grass-1, #fbfefb); + --action: var(--yellow-9, #ffe629); + --action-hover: var(--yellow-10, #ffdc00); + --action-border: var(--yellow-11, #9e6c00); + --action-foreground: var(--yellow-12, #473b1f); --link: light-dark(hsl(211deg 90% 42%), hsl(211deg 90% 62%)); --link-visited: light-dark(hsl(272deg 51% 54%), hsl(272deg 51% 74%)); --stale: hsl(42deg 56% 44% / 25%); + accent-color: var(--primary); + /* Base shadows. */ --base-shadow: light-dark(hsl(0deg 0% 85% / 40%), hsla(0deg 0% 36% / 60%)); --base-shadow-darker: light-dark( @@ -122,6 +129,26 @@ .marimo:is(.dark *) { color-scheme: dark; } + + .dark, + .marimo:is(.dark *) { + --ring: var(--blue-8, #2870bd); + --destructive: var(--red-6, #72232d); + --destructive-hover: var(--red-7, #8c333a); + --destructive-border: var(--red-11, #ff9592); + --destructive-foreground: var(--red-12, #ffd1d9); + --error: var(--red-6, #72232d); + --error-foreground: var(--red-12, #ffd1d9); + --success: var(--grass-6, #2d5736); + --success-hover: var(--grass-7, #366740); + --success-border: var(--grass-11, #71d083); + --success-foreground: var(--grass-12, #c2f0c2); + --action: var(--yellow-6, #524202); + --action-hover: var(--yellow-7, #665417); + --action-border: var(--yellow-11, #f5e147); + --action-foreground: var(--yellow-12, #f6eeb4); + --stale: hsl(54deg 100% 86.7% / 25%); + } } @layer base { diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index 6c93fcbb6fe..6cc5cd9583f 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -44,6 +44,10 @@ module.exports = { destructive: { DEFAULT: "color-mix(in srgb, var(--destructive), transparent calc((1 - ) * 100%))", + hover: + "color-mix(in srgb, var(--destructive-hover), transparent calc((1 - ) * 100%))", + border: + "color-mix(in srgb, var(--destructive-border), transparent calc((1 - ) * 100%))", foreground: "color-mix(in srgb, var(--destructive-foreground), transparent calc((1 - ) * 100%))", }, @@ -53,6 +57,16 @@ module.exports = { foreground: "color-mix(in srgb, var(--error-foreground), transparent calc((1 - ) * 100%))", }, + success: { + DEFAULT: + "color-mix(in srgb, var(--success), transparent calc((1 - ) * 100%))", + hover: + "color-mix(in srgb, var(--success-hover), transparent calc((1 - ) * 100%))", + border: + "color-mix(in srgb, var(--success-border), transparent calc((1 - ) * 100%))", + foreground: + "color-mix(in srgb, var(--success-foreground), transparent calc((1 - ) * 100%))", + }, stale: "var(--stale)", muted: { DEFAULT: @@ -71,6 +85,8 @@ module.exports = { "color-mix(in srgb, var(--action), transparent calc((1 - ) * 100%))", hover: "color-mix(in srgb, var(--action-hover), transparent calc((1 - ) * 100%))", + border: + "color-mix(in srgb, var(--action-border), transparent calc((1 - ) * 100%))", foreground: "color-mix(in srgb, var(--action-foreground), transparent calc((1 - ) * 100%))", }, diff --git a/scripts/generate-design-md.ts b/scripts/generate-design-md.ts index 7940c284186..ae0325ac6da 100644 --- a/scripts/generate-design-md.ts +++ b/scripts/generate-design-md.ts @@ -88,16 +88,21 @@ const colorSpecs = [ ["secondary-foreground", "on-secondary", "on-secondary-dark"], ["accent", "accent", "accent-dark"], ["accent-foreground", "on-accent", "on-accent-dark"], - ["ring", "ring"], - ["destructive", "destructive"], - ["destructive-foreground", "on-destructive"], - ["error", "error"], - ["error-foreground", "on-error"], - ["success", "success"], - ["success-foreground", "on-success"], - ["action", "action"], - ["action-hover", "action-hover"], - ["action-foreground", "on-action"], + ["ring", "ring", "ring-dark"], + ["destructive", "destructive", "destructive-dark"], + ["destructive-hover", "destructive-hover", "destructive-hover-dark"], + ["destructive-border", "destructive-border", "destructive-border-dark"], + ["destructive-foreground", "on-destructive", "on-destructive-dark"], + ["error", "error", "error-dark"], + ["error-foreground", "on-error", "on-error-dark"], + ["success", "success", "success-dark"], + ["success-hover", "success-hover", "success-hover-dark"], + ["success-border", "success-border", "success-border-dark"], + ["success-foreground", "on-success", "on-success-dark"], + ["action", "action", "action-dark"], + ["action-hover", "action-hover", "action-hover-dark"], + ["action-border", "action-border", "action-border-dark"], + ["action-foreground", "on-action", "on-action-dark"], ["link", "link", "link-dark"], ["link-visited", "link-visited", "link-visited-dark"], ["stale", "stale", "stale-dark"], @@ -106,8 +111,10 @@ const colorSpecs = [ const cssCache = new Map(); const cssVarCache = new Map>(); +const themedCssVarCache = new Map>(); const astCache = new Map(); const toRgb = converter("rgb"); +type ColorMode = "light" | "dark"; // The optional first arg lets callers run from any cwd while defaulting to repo root. const repoPath = (relativePath: string) => join(root, relativePath); @@ -159,6 +166,39 @@ const cssVars = (source: SourceKey): Record => return vars; }); +const isDarkSelector = (selector: string) => + selector.split(",").some((part) => part.includes(".dark")); + +// CSS variables cascade by theme. Keep the light and dark scopes separate +// instead of flattening both scopes into one last-declaration-wins map. +const themedCssVars = (mode: ColorMode): Record => + cached(themedCssVarCache, mode, () => { + const vars: Record = {}; + cssRoot("globalCss").walkRules((rule) => { + if (isDarkSelector(rule.selector)) { + return; + } + rule.walkDecls((decl) => { + if (decl.prop.startsWith("--")) { + vars[decl.prop.slice("--".length)] = cleanCss(decl.value); + } + }); + }); + if (mode === "dark") { + cssRoot("globalCss").walkRules((rule) => { + if (!isDarkSelector(rule.selector)) { + return; + } + rule.walkDecls((decl) => { + if (decl.prop.startsWith("--")) { + vars[decl.prop.slice("--".length)] = cleanCss(decl.value); + } + }); + }); + } + return vars; + }); + // Direct CSS reads are reserved for notebook layout facts owned by CSS. const cssDeclaration = ( source: SourceKey, @@ -222,7 +262,7 @@ const formatColor = (value: string, matte = "#FFFFFF") => { const resolveColorValue = ( value: string, vars: Record, - mode: "light" | "dark", + mode: ColorMode, seen = new Set(), matte = "#FFFFFF", ): string => { @@ -689,32 +729,33 @@ const typographyToken = ( // Mirror the frontend CSS variable color set, plus semantic aliases. const buildColors = () => { - const vars = cssVars("globalCss"); + const lightVars = themedCssVars("light"); + const darkVars = themedCssVars("dark"); const colors: Record = {}; const matte = { light: resolveColorValue( - required(vars.background, "background"), - vars, + required(lightVars.background, "background"), + lightVars, "light", ), dark: resolveColorValue( - required(vars.background, "background"), - vars, + required(darkVars.background, "background"), + darkVars, "dark", ), }; for (const [cssName, lightName, darkName] of colorSpecs) { colors[lightName] = resolveColorValue( - required(vars[cssName], cssName), - vars, + required(lightVars[cssName], cssName), + lightVars, "light", new Set(), matte.light, ); if (darkName) { colors[darkName] = resolveColorValue( - required(vars[cssName], cssName), - vars, + required(darkVars[cssName], cssName), + darkVars, "dark", new Set(), matte.dark, diff --git a/scripts/run-visual-regression.sh b/scripts/run-visual-regression.sh new file mode 100755 index 00000000000..f5e829bcd0d --- /dev/null +++ b/scripts/run-visual-regression.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly PLAYWRIGHT_VERSION="1.62.1" +readonly PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" +readonly EDIT_PORT=2738 +readonly RUN_PORT=2739 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly REPO_ROOT +readonly FRONTEND_ROOT="${REPO_ROOT}/frontend" +readonly FIXTURE="e2e-tests/py/visual_tokens.py" + +for command in curl docker uv; do + if ! command -v "${command}" >/dev/null 2>&1; then + echo "Required command not found: ${command}" >&2 + exit 1 + fi +done + +if [[ ! -x "${FRONTEND_ROOT}/node_modules/.bin/playwright" ]]; then + echo "Playwright is not installed. Run 'pnpm install' first." >&2 + exit 1 +fi + +ACTUAL_PLAYWRIGHT_VERSION="$( + "${FRONTEND_ROOT}/node_modules/.bin/playwright" --version +)" +ACTUAL_PLAYWRIGHT_VERSION="${ACTUAL_PLAYWRIGHT_VERSION#Version }" +if [[ "${ACTUAL_PLAYWRIGHT_VERSION}" != "${PLAYWRIGHT_VERSION}" ]]; then + echo \ + "Playwright package ${ACTUAL_PLAYWRIGHT_VERSION} does not match image ${PLAYWRIGHT_VERSION}." \ + >&2 + exit 1 +fi + +for port in "${EDIT_PORT}" "${RUN_PORT}"; do + if command -v lsof >/dev/null 2>&1 && + lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Port ${port} is already in use." >&2 + exit 1 + fi +done + +LOG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/marimo-visual-regression.XXXXXX")" +readonly CONFIG_ROOT="${LOG_DIR}/config" +EDIT_PID="" +RUN_PID="" + +cleanup() { + local pid + for pid in "${EDIT_PID}" "${RUN_PID}"; do + if [[ -n "${pid}" ]]; then + kill "${pid}" >/dev/null 2>&1 || true + wait "${pid}" >/dev/null 2>&1 || true + fi + done + rm -rf "${LOG_DIR}" +} +trap cleanup EXIT INT TERM + +wait_for_server() { + local name="$1" + local url="$2" + local log_file="$3" + + for _ in {1..120}; do + if curl --fail --silent --output /dev/null "${url}"; then + return 0 + fi + sleep 0.25 + done + + echo "${name} server did not become ready: ${url}" >&2 + sed -n '1,160p' "${log_file}" >&2 + return 1 +} + +( + cd "${FRONTEND_ROOT}" + exec env XDG_CONFIG_HOME="${CONFIG_ROOT}" \ + _MARIMO_CONFIG_OVERLOAD_RUNTIME_AUTO_INSTANTIATE=true \ + uv run marimo -q edit -p "${EDIT_PORT}" --headless --no-token +) >"${LOG_DIR}/edit.log" 2>&1 & +EDIT_PID="$!" + +( + cd "${FRONTEND_ROOT}" + exec env XDG_CONFIG_HOME="${CONFIG_ROOT}" \ + _MARIMO_CONFIG_OVERLOAD_RUNTIME_AUTO_INSTANTIATE=true \ + uv run marimo -q run "${FIXTURE}" -p "${RUN_PORT}" --headless --no-token +) >"${LOG_DIR}/run.log" 2>&1 & +RUN_PID="$!" + +wait_for_server \ + "Edit" \ + "http://127.0.0.1:${EDIT_PORT}/?file=${FIXTURE}" \ + "${LOG_DIR}/edit.log" +wait_for_server \ + "Read" \ + "http://127.0.0.1:${RUN_PORT}" \ + "${LOG_DIR}/run.log" + +docker run --rm \ + --add-host=host.docker.internal:host-gateway \ + --volume "${REPO_ROOT}:/work" \ + --workdir /work/frontend \ + "${PLAYWRIGHT_IMAGE}" \ + /work/frontend/node_modules/.bin/playwright test \ + --config=e2e-tests/visual-regression.container.config.ts \ + "$@"