diff --git a/CHANGELOG.md b/CHANGELOG.md index d5826351..d72121c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ not per individual commit. - Pasting a launch address into the open tab opens the new workflow without a reload, fitted to the canvas, and keeps the open one if the code is refused. - Reloading the canvas page resumes the open workflow from a session kept for the tab; a restarted server asks for a fresh launch address. - A spent launch address in the tab falls back to the kept session and says so, and a first load that failed, of an opened or a pasted address, can be retried with a reload. +- The workflow canvas uses the Konductor docs theme, with light and dark modes, and system colours in forced-colors mode. ### Changed diff --git a/workflow-canvas/README.md b/workflow-canvas/README.md index c26f6a4d..aba1942f 100644 --- a/workflow-canvas/README.md +++ b/workflow-canvas/README.md @@ -210,8 +210,8 @@ does not start it, and the same tests call that server instead. `src/graph/` is the core of the canvas. `buildGraph(workflow, harness)` turns the workflow view into nodes (the intent, one per stage, the end) and edges -(one from each node to the next, from its `S` out port at the bottom right to -the `S` in port at the top right of the next), placed in one column. It imports +(one from each node to the next, from the tab under a node to the notch in the +top edge of the next), placed in one column. It imports only its own modules. A test (`test-support/graph-purity.test.ts`) fails if it imports anything else, or reads a clock, randomness, the environment or the network, and the stub core is held to the same rule by the same scan @@ -272,6 +272,55 @@ The rest wraps the core, so the rules can be tested without a browser: outcome short, and keeps the whole text in its `title`; an outcome too long for the node is also written out in full under the graph. +## Theme + +The page has the look of the Konductor docs theme: its colours, fonts and +corner radii, in a light and a dark mode. + +- `src/theme/tokens.ts` holds every colour, font and radius of the theme, as + two palettes and the Cloudscape token map built from them. Nothing else + names a colour of Cloudscape. The colours of the canvas itself are in + `src/canvas/canvas.css`, with their dark values under + `:root[data-theme='dark']`. A test checks that they match the palettes. +- `src/theme/install.ts` applies the token map with `applyTheme` from + `@cloudscape-design/components/theming` (colours, fonts and radii only, + with a value for each mode) and switches the mode with `applyMode` from + `@cloudscape-design/global-styles`. The header band and the top bar are dark + in both modes, so their text colours are set for the band in `tokens.ts`. + The root element gets `data-theme` (`light` or `dark`) and `color-scheme`; a + small script in `index.html` sets both before the first paint, so a dark page + does not flash white. +- `src/theme/fonts.ts` loads Outfit and JetBrains Mono from the pinned + `@fontsource/outfit` and `@fontsource/jetbrains-mono` packages (SIL Open + Font License 1.1). The Latin files are bundled, so the page makes no request + to a font site. +- The top bar (`src/chrome/TopBar.tsx`) has the K logo, the page name and the + **Theme: light** / **Theme: dark** button. The first visit follows the + system setting (`prefers-color-scheme`). Pressing the button saves the mode + in local storage (`src/theme/mode.ts`), and the saved mode wins from then + on. A page that was never switched keeps following the system, but only as it + was at load: a later change of the system setting, or of the choice in + another tab, shows after a reload. +- The page header is a dark band with the breadcrumb `Workflows > ` + (the page has no list of workflows yet, so no crumb is a link), the title + and the Designer and Execution tabs. The whole tab strip, with its rule and + the selected-tab indicator, is part of the band. +- Nodes are cards with the corner radius of the theme. The `S` run-order + ports are pieces of a jigsaw, 26 by 16 pixels, with a dark rim and a letter + `S`: the out port is a square tab that hangs from the bottom edge, the in + port a notch of the same outline in the top edge (white inside, with a ring + in the colour of the tab), both in the middle of the card, so a tab fits the + notch of the card under it (`src/canvas/jigsaw.ts` defines the shape once). + A link runs from the tip of the tab to the top of the notch. The keyboard + focus ring follows the outline of the card and of the tab. + +Text meets WCAG 2 AA (4.5:1) in both modes, and the edges of controls, nodes, +ports and the selected-tab indicator reach 3:1. `src/theme/tokens.test.ts` +and `test-support/canvas-contrast.test.ts` check each of these colour pairs +for the palettes, the band and the canvas in both modes, and the end-to-end +tests run axe (which checks text contrast) in both modes and read the colour +of the tab strip. + ## End-to-end tests The tests in `e2e/tests/` drive the production build in headless Chromium. @@ -298,7 +347,7 @@ npx playwright install chromium Then `npm run test:e2e`. The HTML report is written to `playwright-report/`; open it with `npx playwright show-report`. Accessibility is checked with -axe-core on each tab and on the error state. +axe-core on each tab, on the error state and in both theme modes. Shared page objects and helpers live in `e2e/support/`. New tests should extend them rather than repeat selectors: import `test` and `expect` from @@ -308,19 +357,21 @@ the app. ## Screenshots `npm run screenshots` saves a full-page PNG of each tab to -`e2e/screenshots/`, with the Designer tab drawn from the stub, in the light -theme at 1280x800 with animations frozen. Pull request descriptions embed these +`e2e/screenshots/`, with the Designer tab drawn from the stub, at 1280x800 with +animations frozen. The tabs are in the light theme (`designer-tab.png`, +`execution-tab.png`), and `designer-tab-dark.png` is the Designer tab after +the theme button is pressed. Pull request descriptions embed these files, so regenerate and commit them when the UI changes. Only that script writes the PNGs. It uses its own config, `playwright.screenshots.config.ts`, so `npm run test:e2e`, a bare `npx playwright test` and editor integrations never touch committed files. -The capture loads the Open Sans font from the `@fontsource-variable/open-sans` -dev dependency (Cloudscape names Open Sans first but does not ship it) and -turns off text hinting and LCD smoothing, so the PNGs come out byte-identical -on different machines and in CI. CI regenerates them, uploads that set as the -`screenshots` artifact, and fails when it differs from the committed files. +The capture waits until the bundled Outfit and JetBrains Mono faces are loaded +and turns off text hinting and LCD smoothing, so the PNGs come out +byte-identical on different machines and in CI. CI regenerates them, uploads +that set as the `screenshots` artifact, and fails when it differs from the +committed files. The comparison is byte-exact, so CI fails if a committed screenshot differs by even one byte. After a toolchain bump (Playwright, Chromium, fonts or Node), run diff --git a/workflow-canvas/e2e/capture/screenshots.spec.ts b/workflow-canvas/e2e/capture/screenshots.spec.ts index 29115b39..54d1e840 100644 --- a/workflow-canvas/e2e/capture/screenshots.spec.ts +++ b/workflow-canvas/e2e/capture/screenshots.spec.ts @@ -13,3 +13,11 @@ for (const name of TAB_NAMES) { await saveFullPageScreenshot(canvas.page, `${name.toLowerCase()}-tab`); }); } + +// The Designer tab in the dark mode, switched with the button like a person would. +test('capture the Designer tab in the dark mode', async ({ canvas }) => { + await canvas.switchTheme(); + await canvas.expectTabLoaded('Designer'); + + await saveFullPageScreenshot(canvas.page, 'designer-tab-dark'); +}); diff --git a/workflow-canvas/e2e/screenshots/designer-tab-dark.png b/workflow-canvas/e2e/screenshots/designer-tab-dark.png new file mode 100644 index 00000000..cb4c690d Binary files /dev/null and b/workflow-canvas/e2e/screenshots/designer-tab-dark.png differ diff --git a/workflow-canvas/e2e/screenshots/designer-tab.png b/workflow-canvas/e2e/screenshots/designer-tab.png index 4de06aeb..05bdcd50 100644 Binary files a/workflow-canvas/e2e/screenshots/designer-tab.png and b/workflow-canvas/e2e/screenshots/designer-tab.png differ diff --git a/workflow-canvas/e2e/screenshots/execution-tab.png b/workflow-canvas/e2e/screenshots/execution-tab.png index ce7f412c..941e658b 100644 Binary files a/workflow-canvas/e2e/screenshots/execution-tab.png and b/workflow-canvas/e2e/screenshots/execution-tab.png differ diff --git a/workflow-canvas/e2e/support/screenshots.ts b/workflow-canvas/e2e/support/screenshots.ts index b20ef8c0..bcc0e2c2 100644 --- a/workflow-canvas/e2e/support/screenshots.ts +++ b/workflow-canvas/e2e/support/screenshots.ts @@ -1,6 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -import { readFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Page } from '@playwright/test'; @@ -9,40 +7,35 @@ const SCREENSHOT_DIR = fileURLToPath( new URL('../screenshots', import.meta.url), ); -// Cloudscape lists Open Sans first in its font stack but does not ship it, so -// the page falls back to whichever font the machine has. Loading the same file -// on every machine keeps the text identical. -const FONT_FAMILY = 'Open Sans'; -const FONT_FILE = createRequire(import.meta.url).resolve( - '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2', -); +// The faces that the page bundles. The browser loads a face only when text +// uses it, so the capture asks for each one, then waits until all can paint. +const BUNDLED_FACES = [ + '400 1em Outfit', + '500 1em Outfit', + '600 1em Outfit', + '700 1em Outfit', + "400 1em 'JetBrains Mono'", + "700 1em 'JetBrains Mono'", +]; -/** Loads the bundled font into the page and waits until it can paint. */ -async function pinFont(page: Page): Promise { - const fontData = (await readFile(FONT_FILE)).toString('base64'); - await page.addStyleTag({ - content: `@font-face { - font-family: '${FONT_FAMILY}'; - font-weight: 300 800; - src: url(data:font/woff2;base64,${fontData}) format('woff2'); - }`, - }); +// Waits until every bundled font can paint. +async function waitForFonts(page: Page): Promise { await page.evaluate( - (family) => document.fonts.load(`1em '${family}'`), - FONT_FAMILY, + (faces) => Promise.all(faces.map((face) => document.fonts.load(face))), + BUNDLED_FACES, ); await page.evaluate(() => document.fonts.ready); } /** * Saves a full-page PNG as `e2e/screenshots/.png`, rendered with the - * pinned font, with animations and the caret frozen so repeated runs match. + * bundled fonts, with animations and the caret frozen so repeated runs match. */ export async function saveFullPageScreenshot( page: Page, name: string, ): Promise { - await pinFont(page); + await waitForFonts(page); await page.screenshot({ path: path.join(SCREENSHOT_DIR, `${name}.png`), fullPage: true, diff --git a/workflow-canvas/e2e/support/workflow-canvas-page.ts b/workflow-canvas/e2e/support/workflow-canvas-page.ts index 7dbdeeb6..2bff325a 100644 --- a/workflow-canvas/e2e/support/workflow-canvas-page.ts +++ b/workflow-canvas/e2e/support/workflow-canvas-page.ts @@ -6,6 +6,9 @@ export const TAB_NAMES = ['Designer', 'Execution'] as const; /** The label of one tab. */ export type TabName = (typeof TAB_NAMES)[number]; +/** The two modes of the page. */ +export type ThemeName = 'light' | 'dark'; + /** Text the Execution tab shows until it has real content. */ export const PLACEHOLDER_TEXT = 'Nothing here yet.'; @@ -36,12 +39,16 @@ export class WorkflowCanvasPage { readonly title: Locator; readonly graph: Locator; readonly edges: Locator; + readonly themeButton: Locator; + readonly breadcrumbs: Locator; constructor(readonly page: Page) { this.main = page.getByRole('main'); this.title = this.main.getByRole('heading', { level: 1 }); this.graph = page.getByRole('region', { name: 'Workflow graph' }); this.edges = this.graph.locator('.react-flow__edge'); + this.themeButton = page.getByRole('button', { name: /^Theme:/ }); + this.breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }); } /** @@ -53,6 +60,20 @@ export class WorkflowCanvasPage { await this.title.waitFor(); } + /** Presses the theme button and waits until the page is in the other mode. */ + async switchTheme(): Promise { + const next: ThemeName = + (await this.themeName()) === 'dark' ? 'light' : 'dark'; + await this.themeButton.click(); + await expect(this.themeButton).toHaveText(`Theme: ${next}`); + } + + /** The mode the page is in, as the theme button says it. */ + async themeName(): Promise { + const text = (await this.themeButton.textContent()) ?? ''; + return text.endsWith('dark') ? 'dark' : 'light'; + } + tab(name: TabName): Locator { return this.page.getByRole('tab', { name }); } @@ -65,6 +86,16 @@ export class WorkflowCanvasPage { return this.graph.getByTestId(id); } + /** The tab under a node, where its run order leaves. */ + outPort(id: NodeId): Locator { + return this.node(id).locator('.wf-port-tab'); + } + + /** The notch in the top edge of a node, where its run order arrives. */ + inPort(id: NodeId): Locator { + return this.node(id).locator('.wf-port-notch'); + } + /** The node that a screen reader names `name`. */ nodeNamed(name: string): Locator { return this.graph.getByRole('group', { name, exact: true }); diff --git a/workflow-canvas/e2e/tests/canvas.spec.ts b/workflow-canvas/e2e/tests/canvas.spec.ts index c0b01ce2..caefaa56 100644 --- a/workflow-canvas/e2e/tests/canvas.spec.ts +++ b/workflow-canvas/e2e/tests/canvas.spec.ts @@ -4,6 +4,7 @@ import { STUB_LAUNCH_CODE } from '../../playwright.shared'; import { expect, test } from '../support/fixtures'; import { sharedSession } from '../support/session'; import { + NODE_IDS, NODE_NAMES, WorkflowCanvasPage, } from '../support/workflow-canvas-page'; @@ -59,6 +60,65 @@ test('stacks the nodes in the order of the stages', async ({ canvas }) => { expect(new Set(tops).size).toBe(tops.length); }); +test('draws a tab under every node but the end and a notch in every node but the intent', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + + for (const id of NODE_IDS) { + await expect(canvas.outPort(id)).toHaveCount(id === 'node-end' ? 0 : 1); + await expect(canvas.inPort(id)).toHaveCount(id === 'node-intent' ? 0 : 1); + } +}); + +test('puts each tab in the middle of its card, over the notch of the card under it', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + + for (let i = 0; i < NODE_IDS.length - 1; i += 1) { + const [tab, notch, card] = [ + await canvas.outPort(NODE_IDS[i]!).boundingBox(), + await canvas.inPort(NODE_IDS[i + 1]!).boundingBox(), + await canvas.node(NODE_IDS[i]!).boundingBox(), + ]; + const centre = (box: typeof tab) => (box?.x ?? 0) + (box?.width ?? 0) / 2; + + expect(Math.abs(centre(tab) - centre(notch))).toBeLessThan(1); + expect(Math.abs(centre(tab) - centre(card))).toBeLessThan(1); + // The tab hangs below the card, and the notch is the same size. + expect((tab?.y ?? 0) + (tab?.height ?? 0)).toBeGreaterThan( + (card?.y ?? 0) + (card?.height ?? 0), + ); + expect(Math.abs((tab?.width ?? 0) - (notch?.width ?? 0))).toBeLessThan(1); + expect(Math.abs((tab?.height ?? 0) - (notch?.height ?? 0))).toBeLessThan(1); + } +}); + +test('starts each link at the tip of a tab and ends it at the top of a notch', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + const links = canvas.edges.locator('.react-flow__edge-path'); + // The boxes of a port and of a link include the dark rim, the width of the + // line and the arrowhead, which add up to a few pixels. + const REACH = 4; + + for (let i = 0; i < NODE_IDS.length - 1; i += 1) { + const [tab, notch, link] = [ + await canvas.outPort(NODE_IDS[i]!).boundingBox(), + await canvas.inPort(NODE_IDS[i + 1]!).boundingBox(), + await links.nth(i).boundingBox(), + ]; + const centre = (box: typeof tab) => (box?.x ?? 0) + (box?.width ?? 0) / 2; + const bottom = (box: typeof tab) => (box?.y ?? 0) + (box?.height ?? 0); + + expect(Math.abs(centre(link) - centre(tab))).toBeLessThan(1); + expect(Math.abs((link?.y ?? 0) - bottom(tab))).toBeLessThan(REACH); + expect(Math.abs(bottom(link) - (notch?.y ?? 0))).toBeLessThan(REACH + 4); + } +}); + /** A view of a workflow with `count` stages, as `GET /api/workflow` returns it. */ function workflowOf(count: number) { const stages = Array.from({ length: count }, (_, i) => ({ @@ -207,11 +267,32 @@ test('reaches each node with the Tab key and names it', async ({ canvas }) => { await expect(node).toBeFocused(); await expect(node).toHaveAccessibleName(name); await expect(node).toHaveAccessibleDescription(/^Read-only workflow graph/); - await expect(node).toHaveCSS('outline-style', 'solid'); - await expect(node).toHaveCSS('outline-width', '3px'); + await expect(node.locator('.wf-focus-ring')).toBeVisible(); } }); +test('draws the focus ring around the tab, not across it', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + await canvas.tab('Designer').focus(); + + await canvas.tabToNextNode(); + const focused = canvas.page.locator('.react-flow__node:focus'); + const [ring, tab] = [ + await focused.locator('.wf-focus-ring-port').boundingBox(), + await focused.locator('.wf-port-tab').boundingBox(), + ]; + + await expect(focused).toHaveCSS('outline-style', 'none'); + // The ring reaches past the tip of the tab, so no part of it is a line + // across the tab. + expect((ring?.y ?? 0) + (ring?.height ?? 0)).toBeGreaterThan( + (tab?.y ?? 0) + (tab?.height ?? 0), + ); + expect(ring?.width ?? 0).toBeGreaterThan(tab?.width ?? 0); +}); + test('names the edges by the nodes they join, not by ids', async ({ canvas, }) => { diff --git a/workflow-canvas/e2e/tests/theme.spec.ts b/workflow-canvas/e2e/tests/theme.spec.ts new file mode 100644 index 00000000..e3e5631b --- /dev/null +++ b/workflow-canvas/e2e/tests/theme.spec.ts @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 +import { STUB_LAUNCH_CODE } from '../../playwright.shared'; +import { expectNoAccessibilityViolations } from '../support/accessibility'; +import { expect, test } from '../support/fixtures'; +import type { WorkflowCanvasPage } from '../support/workflow-canvas-page'; + +const DARK_CANVAS = 'rgb(11, 10, 18)'; +const LIGHT_CANVAS = 'rgb(245, 243, 238)'; + +const BAND_RGB = 'rgb(20, 16, 38)'; +const BAND_DIVIDER_RGB = 'rgb(42, 36, 80)'; +const BAND_LINK_RGB = 'rgb(196, 178, 255)'; + +test('shows the top bar, the breadcrumb and the title of the page', async ({ + canvas, +}) => { + await expect( + canvas.page.getByRole('link', { name: 'Workflow canvas' }), + ).toBeVisible(); + await expect(canvas.themeButton).toBeVisible(); + await expect(canvas.breadcrumbs).toContainText('Workflows'); + await expect(canvas.breadcrumbs).toContainText('hello'); + await expect(canvas.title).toHaveText('Workflow canvas'); +}); + +test('self-hosts the fonts of the theme', async ({ canvas }) => { + const requested: string[] = []; + canvas.page.on('request', (request) => requested.push(request.url())); + await canvas.page.reload(); + await canvas.title.waitFor(); + + const fonts = await canvas.page.evaluate(async () => { + // A face downloads only when text uses it, so ask for each one. + const [outfit, mono] = await Promise.all([ + document.fonts.load('400 1em Outfit'), + document.fonts.load("400 1em 'JetBrains Mono'"), + ]); + return { + outfit: outfit.length > 0, + mono: mono.length > 0, + body: getComputedStyle(document.body).fontFamily, + }; + }); + + expect(fonts.outfit).toBe(true); + expect(fonts.mono).toBe(true); + expect(fonts.body).toContain('Outfit'); + const origin = new URL(canvas.page.url()).origin; + expect( + requested.filter( + (url) => !url.startsWith(origin) && !url.startsWith('data:'), + ), + ).toEqual([]); +}); + +test.describe('light first load', () => { + test.use({ colorScheme: 'light' }); + + test('starts light and does not save a choice nobody made', async ({ + canvas, + }) => { + await expect(canvas.themeButton).toHaveText('Theme: light'); + await expect(canvas.graph).toHaveCSS('background-color', LIGHT_CANVAS); + expect(await canvas.page.evaluate(() => localStorage.length)).toBe(0); + }); +}); + +test.describe('dark system setting', () => { + test.use({ colorScheme: 'dark' }); + + test('starts dark when the system asks for it', async ({ canvas }) => { + await expect(canvas.themeButton).toHaveText('Theme: dark'); + await expect(canvas.page.locator('html')).toHaveAttribute( + 'data-theme', + 'dark', + ); + }); +}); + +test('switches to the dark mode and back', async ({ canvas }) => { + await canvas.switchTheme(); + + await expect(canvas.page.locator('html')).toHaveAttribute( + 'data-theme', + 'dark', + ); + await expect(canvas.graph).toHaveCSS('background-color', DARK_CANVAS); + + await canvas.switchTheme(); + + await expect(canvas.page.locator('html')).toHaveAttribute( + 'data-theme', + 'light', + ); + await expect(canvas.graph).toHaveCSS('background-color', LIGHT_CANVAS); +}); + +test('remembers the choice after a reload', async ({ canvas }) => { + await canvas.switchTheme(); + + await canvas.open(STUB_LAUNCH_CODE); + + await expect(canvas.themeButton).toHaveText('Theme: dark'); +}); + +test('a saved choice wins over the system setting', async ({ canvas }) => { + await canvas.switchTheme(); + await canvas.page.emulateMedia({ colorScheme: 'light' }); + + await canvas.open(STUB_LAUNCH_CODE); + + await expect(canvas.themeButton).toHaveText('Theme: dark'); +}); + +test('has no axe violations in the light mode, text contrast included', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + + await expectNoAccessibilityViolations(canvas.page); +}); + +test('has no axe violations in the dark mode, text contrast included', async ({ + canvas, +}) => { + await canvas.switchTheme(); + await canvas.expectWorkflowDrawn(); + + await expectNoAccessibilityViolations(canvas.page); +}); + +test('keeps the keyboard ring of a node visible in the dark mode', async ({ + canvas, +}) => { + await canvas.switchTheme(); + await canvas.expectWorkflowDrawn(); + await canvas.tab('Designer').focus(); + + await canvas.tabToNextNode(); + + await expect( + canvas.page.locator('.react-flow__node:focus .wf-focus-ring'), + ).toHaveCSS('stroke', 'rgb(167, 139, 250)'); +}); + +test('switches with the keyboard', async ({ canvas }) => { + await canvas.themeButton.focus(); + + await canvas.page.keyboard.press('Enter'); + + await expect(canvas.themeButton).toHaveText('Theme: dark'); +}); + +// The strip of the tabs, the selected-tab indicator and the rule under it. +async function expectTabStripOnBand(canvas: WorkflowCanvasPage) { + const strip = canvas.page + .getByRole('tablist') + .locator('xpath=ancestor::*[2]'); + await expect(strip).toHaveCSS('background-color', BAND_RGB); + await expect(strip).toHaveCSS('border-bottom-color', BAND_DIVIDER_RGB); + // The indicator is drawn after the element that wraps the tab button. + const indicator = await canvas + .tab('Designer') + .evaluate( + (tab) => getComputedStyle(tab.parentElement!, '::after').backgroundColor, + ); + expect(indicator).toBe(BAND_LINK_RGB); +} + +test('puts the tab strip on the dark band in the light mode', async ({ + canvas, +}) => { + await expectTabStripOnBand(canvas); +}); + +test('puts the tab strip on the dark band in the dark mode', async ({ + canvas, +}) => { + await canvas.switchTheme(); + + await expectTabStripOnBand(canvas); +}); + +test('sets the saved mode before the app script runs', async ({ canvas }) => { + await canvas.switchTheme(); + await canvas.page.route('**/*', (route) => + route.request().resourceType() === 'script' + ? route.abort() + : route.fallback(), + ); + + await canvas.page.reload(); + + const root = canvas.page.locator('html'); + await expect(root).toHaveAttribute('data-theme', 'dark'); + await expect(root).toHaveCSS('color-scheme', 'dark'); + await expect(canvas.themeButton).toHaveCount(0); +}); + +test.describe('forced colors', () => { + test.use({ colorScheme: 'dark', forcedColors: 'active' }); + + test('draws the card in the system colours, so no text sits on a dark patch', async ({ + canvas, + }) => { + const colours = await canvas.page.evaluate(() => { + const probe = document.createElement('div'); + probe.style.cssText = 'background: Canvas; color: CanvasText'; + document.body.append(probe); + const system = getComputedStyle(probe); + const body = document.querySelector('.wf-node-body'); + const title = document.querySelector('.wf-node-title'); + const result = { + canvas: system.backgroundColor, + canvasText: system.color, + fill: body ? getComputedStyle(body).fill : '', + stroke: body ? getComputedStyle(body).stroke : '', + text: title ? getComputedStyle(title).color : '', + }; + probe.remove(); + return result; + }); + + expect(colours.fill).toBe(colours.canvas); + expect(colours.stroke).toBe(colours.canvasText); + expect(colours.text).toBe(colours.canvasText); + }); +}); diff --git a/workflow-canvas/index.html b/workflow-canvas/index.html index e0bf487b..35a7a317 100644 --- a/workflow-canvas/index.html +++ b/workflow-canvas/index.html @@ -6,9 +6,29 @@ Workflow canvas +
diff --git a/workflow-canvas/package-lock.json b/workflow-canvas/package-lock.json index 139d142f..1fb21f24 100644 --- a/workflow-canvas/package-lock.json +++ b/workflow-canvas/package-lock.json @@ -18,7 +18,8 @@ "devDependencies": { "@apidevtools/swagger-parser": "13.1.0", "@axe-core/playwright": "4.14.0", - "@fontsource-variable/open-sans": "5.3.0", + "@fontsource/jetbrains-mono": "5.3.0", + "@fontsource/outfit": "5.3.0", "@playwright/test": "1.64.1", "@testing-library/dom": "10.4.2", "@testing-library/react": "16.3.3", @@ -620,10 +621,20 @@ } } }, - "node_modules/@fontsource-variable/open-sans": { + "node_modules/@fontsource/jetbrains-mono": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/open-sans/-/open-sans-5.3.0.tgz", - "integrity": "sha512-VOfhZfLIOBVa6K/dTEu7scqQXYdGa4jBX8quFpQ0S6574B+W2EOBJf9PkqyVtMEI7WWIWNEbpvPc3vY1mN2mmQ==", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/outfit": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/outfit/-/outfit-5.3.0.tgz", + "integrity": "sha512-0AVHzTTVJSxWDOxPKSfMeHhZeBFDU3cmgrcOb76ZEyhGo0+xgyG3GsLF3h9+X8w0IX7G4jzjJgFQEBiWdX6VgA==", "dev": true, "license": "OFL-1.1", "funding": { diff --git a/workflow-canvas/package.json b/workflow-canvas/package.json index a053db86..dbf91c8b 100644 --- a/workflow-canvas/package.json +++ b/workflow-canvas/package.json @@ -31,7 +31,8 @@ "devDependencies": { "@apidevtools/swagger-parser": "13.1.0", "@axe-core/playwright": "4.14.0", - "@fontsource-variable/open-sans": "5.3.0", + "@fontsource/jetbrains-mono": "5.3.0", + "@fontsource/outfit": "5.3.0", "@playwright/test": "1.64.1", "@testing-library/dom": "10.4.2", "@testing-library/react": "16.3.3", diff --git a/workflow-canvas/src/App.test.tsx b/workflow-canvas/src/App.test.tsx index 9e6d88f5..2b2f389d 100644 --- a/workflow-canvas/src/App.test.tsx +++ b/workflow-canvas/src/App.test.tsx @@ -1,12 +1,28 @@ // SPDX-License-Identifier: Apache-2.0 import { fireEvent, render, screen, within } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { MODE_STORAGE_KEY } from './theme/mode'; import { App } from './App'; import { HELLO_DOCUMENT, failed, loaded } from './test-support/fixtures'; import type { LoadResult } from './workflow/load-workflow'; const settled = (result: LoadResult) => Promise.resolve(result); +// The top bar measures itself, and jsdom has no layout, so it shows its +// buttons in the overflow menu. The measuring copy is hidden from the +// accessibility tree, but it is the same button with the same handler. +const themeButton = (mode: 'light' | 'dark') => + screen.getAllByRole('button', { + name: new RegExp(`^Theme: ${mode}`), + hidden: true, + })[0]!; + +afterEach(() => { + window.localStorage.clear(); + delete document.documentElement.dataset.theme; + document.documentElement.style.colorScheme = ''; +}); + describe('App', () => { it('renders the page title inside a main landmark', () => { render(); @@ -83,4 +99,58 @@ describe('App', () => { expect(screen.getByTestId('node-end')).toBeTruthy(); expect(screen.queryByText('Loading the workflow')).toBeNull(); }); + + it('shows the top bar with the page name and a theme button', () => { + render(); + + expect(themeButton('light').textContent).toBe('Theme: light'); + expect(themeButton('light').getAttribute('aria-label')).toBe( + 'Theme: light. Switch to dark', + ); + }); + + it('shows the breadcrumb Workflows > the open workflow, with no dead link', async () => { + render(); + + const trail = await screen.findByRole('navigation', { + name: 'Breadcrumbs', + }); + expect(within(trail).getByText('Workflows')).toBeTruthy(); + expect( + (await within(trail).findByText('hello')).closest('[aria-current]'), + ).toBeTruthy(); + expect(within(trail).queryAllByRole('link')).toEqual([]); + }); + + it('switches to the dark mode, remembers it and switches back', () => { + render(); + + fireEvent.click(themeButton('light')); + + expect(document.documentElement.style.colorScheme).toBe('dark'); + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(window.localStorage.getItem(MODE_STORAGE_KEY)).toBe('dark'); + expect(themeButton('dark').textContent).toBe('Theme: dark'); + + fireEvent.click(themeButton('dark')); + + expect(document.documentElement.dataset.theme).toBe('light'); + expect(themeButton('light').textContent).toBe('Theme: light'); + expect(window.localStorage.getItem(MODE_STORAGE_KEY)).toBe('light'); + }); + + it('starts in the mode that was saved', () => { + window.localStorage.setItem(MODE_STORAGE_KEY, 'dark'); + + render(); + + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(themeButton('dark').textContent).toBe('Theme: dark'); + }); + + it('does not save a mode that nobody chose', () => { + render(); + + expect(window.localStorage.getItem(MODE_STORAGE_KEY)).toBeNull(); + }); }); diff --git a/workflow-canvas/src/App.tsx b/workflow-canvas/src/App.tsx index 556e761b..3f294dec 100644 --- a/workflow-canvas/src/App.tsx +++ b/workflow-canvas/src/App.tsx @@ -5,16 +5,22 @@ import ContentLayout from '@cloudscape-design/components/content-layout'; import Header from '@cloudscape-design/components/header'; import Tabs from '@cloudscape-design/components/tabs'; import type { ReactNode } from 'react'; +import { Breadcrumbs } from './chrome/Breadcrumbs'; +import './chrome/chrome.css'; +import { TOP_BAR_ID, TopBar } from './chrome/TopBar'; import { Designer } from './designer/Designer'; import { useSettled } from './designer/use-settled'; -import type { LoadResult } from './workflow/load-workflow'; +import { TAB_STRIP_STYLE } from './theme/tokens'; +import { useThemeMode } from './theme/use-theme-mode'; +import { openWorkflowName, type LoadResult } from './workflow/load-workflow'; const placeholder = Nothing here yet.; /** - * Root component: the page title and the Designer and Execution tabs. The - * Designer draws the workflow that `load` settles with; the Execution tab - * shows a placeholder until its content is built. + * Root component: the top bar, then the page header (breadcrumbs and title) + * with the Designer and Execution tabs. The Designer draws the workflow that + * `load` settles with; the Execution tab shows a placeholder until its + * content is built. */ export function App({ load, @@ -26,26 +32,43 @@ export function App({ }) { // Read here, not in the tab, so switching tabs does not load again. const result = useSettled(load); + const [mode, toggleMode] = useThemeMode(); return ( - Workflow canvas}> - {notice} - , - }, - { id: 'execution', label: 'Execution', content: placeholder }, - ]} - /> - - } - /> + <> + + + } + header={
Workflow canvas
} + > + {notice} +
+ , + }, + { id: 'execution', label: 'Execution', content: placeholder }, + ]} + /> +
+ + } + /> + ); } diff --git a/workflow-canvas/src/canvas/canvas.css b/workflow-canvas/src/canvas/canvas.css index 9f1fa66c..98cfae8c 100644 --- a/workflow-canvas/src/canvas/canvas.css +++ b/workflow-canvas/src/canvas/canvas.css @@ -1,38 +1,115 @@ /* SPDX-License-Identifier: Apache-2.0 */ -/* Colours of the light theme for the parts that the canvas draws itself. */ +/* + * The colours of the parts that the canvas draws itself, for the light and the + * dark mode. The dark values apply when the page root has data-theme="dark". + * A test (canvas-contrast.test.ts) reads this file, checks that the text, the + * edges and the ports reach WCAG AA in both modes, and that the colours and + * radii match the theme module. + */ .wf-canvas { --wf-canvas-bg: #f5f3ee; --wf-canvas-dots: #c9c1b3; --wf-edge: #5f6b7a; - --wf-focus: #0b57d0; + --wf-focus: #6236f0; --wf-node-bg: #ffffff; --wf-node-border: #7a7490; --wf-text: #16131f; --wf-text-muted: #56516a; - --wf-port-bg: #414d5c; - --wf-port-text: #ffffff; - --wf-port-ring: #16131f; + /* The ports are the same in both modes. The fill and the letters carry the + contrast; the rim only outlines them. */ + --wf-port-fill: #5f6b7a; + --wf-port-rim: #16191f; + --wf-port-letter-out: #ffffff; + --wf-port-notch-bg: #ffffff; + --wf-port-letter-in: #414d5c; + --wf-controls-bg: #ffffff; + --wf-controls-bg-hover: #efebff; + --wf-controls-border: #cfc7ba; border-radius: 12px; background: var(--wf-canvas-bg); + /* The graph library reads these for its zoom buttons and its credit line. */ + --xy-controls-button-background-color: var(--wf-controls-bg); + --xy-controls-button-background-color-hover: var(--wf-controls-bg-hover); + --xy-controls-button-color: var(--wf-text); + --xy-controls-button-color-hover: var(--wf-text); + --xy-controls-button-border-color: var(--wf-controls-border); + --xy-attribution-background-color: var(--wf-controls-bg); } -/* The ring that shows which node the keyboard is on. */ +:root[data-theme='dark'] .wf-canvas { + --wf-canvas-bg: #0b0a12; + --wf-canvas-dots: #3a3168; + --wf-edge: #a39bc0; + --wf-focus: #a78bfa; + --wf-node-bg: #171331; + --wf-node-border: #8f86ad; + --wf-text: #f5f3ee; + --wf-text-muted: #9c93b8; + --wf-controls-bg: #171331; + --wf-controls-bg-hover: #1f1a44; + --wf-controls-border: #3a3168; +} + +.wf-canvas .react-flow__attribution a { + color: var(--wf-text-muted); +} + +/* + * The ring that shows which node the keyboard is on. It is drawn in the SVG + * of the node (see nodes.tsx), around the outline, so it follows the tab + * and does not cut across it. The gap is the canvas colour. + */ .wf-canvas .react-flow__node:focus-visible { - border-radius: 12px; - outline: 3px solid var(--wf-focus); - outline-offset: 3px; + outline: none; +} + +.wf-focus-ring, +.wf-focus-ring-port, +.wf-focus-gap { + fill: none; + stroke-linejoin: round; + visibility: hidden; +} + +.wf-focus-ring, +.wf-focus-ring-port { + stroke: var(--wf-focus); +} + +.wf-focus-gap { + stroke: var(--wf-canvas-bg); +} + +.react-flow__node:focus-visible .wf-focus-ring, +.react-flow__node:focus-visible .wf-focus-ring-port, +.react-flow__node:focus-visible .wf-focus-gap { + visibility: visible; +} + +/* In forced-colors mode the system outline replaces the SVG ring. */ +@media (forced-colors: active) { + .wf-canvas .react-flow__node:focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + .react-flow__node:focus-visible .wf-focus-ring, + .react-flow__node:focus-visible .wf-focus-ring-port, + .react-flow__node:focus-visible .wf-focus-gap { + visibility: hidden; + } } .wf-node { + position: relative; + /* The outline is drawn behind the content, inside this box. */ + isolation: isolate; box-sizing: border-box; width: 100%; height: 100%; - padding: 10px 12px; - border: 2px solid var(--wf-node-border); - border-radius: 12px; - background: var(--wf-node-bg); + padding: 12px 14px; color: var(--wf-text); font-size: 12px; line-height: 1.35; @@ -69,38 +146,101 @@ justify-content: center; } -/* A run order port: a small square with an S, at the right of the node. */ +/* + * A run order port is a piece of a jigsaw: the S out tab hangs from the + * bottom edge of a card and the S in notch sits in the top edge, both in the + * middle, so a tab lines up with the notch of the card under it. The card, the tab and + * the notch are one SVG (see nodes.tsx and jigsaw.ts); this file only gives + * them colours. + */ +.wf-node-shape { + position: absolute; + top: 0; + left: 0; + z-index: -1; + overflow: visible; + pointer-events: none; +} + +.wf-node-body { + fill: var(--wf-node-bg); + stroke: var(--wf-node-border); + stroke-linejoin: round; + stroke-width: 2px; +} + +/* A dark line around the tab and the notch, so they read on any background. */ +.wf-port-rim { + fill: var(--wf-port-rim); + stroke: var(--wf-port-rim); + stroke-linejoin: round; +} + +.wf-port-tab-body { + fill: var(--wf-port-fill); +} + +/* The notch is white inside, with a ring in the colour of the tab. */ +.wf-port-notch-inside { + fill: var(--wf-port-notch-bg); +} + +.wf-port-notch-ring { + fill: none; + stroke: var(--wf-port-fill); + stroke-linejoin: round; +} + +.wf-port-letter { + /* The pinned page font, not a system family, so the letter looks the same + on every machine. */ + font-family: inherit; + font-size: 9px; + font-weight: 700; + text-anchor: middle; + dominant-baseline: central; +} + +.wf-port-letter-tab { + fill: var(--wf-port-letter-out); +} + +.wf-port-letter-notch { + fill: var(--wf-port-letter-in); +} + +/* The title of a node starts below its notch. */ +.wf-node-notched { + padding-top: 18px; +} + +.wf-node-end.wf-node-notched { + padding-top: 20px; + padding-bottom: 4px; +} + +/* The handle is only the box that a link measures from. */ .react-flow .wf-port { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; min-width: 0; min-height: 0; - border: 2px solid var(--wf-port-ring); - border-radius: 4px; - background: var(--wf-port-bg); - color: var(--wf-port-text); - /* Inherit the page font. Naming a system family here (Arial) would resolve - to a different face per host and make the screenshots differ. */ - font-family: inherit; - font-size: 10px; - font-weight: 700; - line-height: 1; + padding: 0; + border: none; + border-radius: 0; + background: none; pointer-events: none; } -.react-flow .wf-port-in { - top: 0; - right: 16px; - left: auto; - transform: translate(50%, -50%); -} +/* + * In forced-colors mode the card takes the system colours, so its text does + * not sit on a patch of the dark fill. After the rules above, to win over them. + */ +@media (forced-colors: active) { + .wf-node-body { + fill: Canvas; + stroke: CanvasText; + } -.react-flow .wf-port-out { - right: 16px; - bottom: 0; - left: auto; - transform: translate(50%, 50%); + .wf-node { + color: CanvasText; + } } diff --git a/workflow-canvas/src/canvas/jigsaw.ts b/workflow-canvas/src/canvas/jigsaw.ts new file mode 100644 index 00000000..6132c358 --- /dev/null +++ b/workflow-canvas/src/canvas/jigsaw.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { CSSProperties } from 'react'; +import { NODE_WIDTH } from '../graph'; +import { RADIUS } from '../theme/tokens'; + +// The run order ports are pieces of a jigsaw. The out port is a square tab +// that hangs from the bottom edge of a node; the in port is a notch of the +// same outline in the top edge, in line with it, so a tab fits the notch of +// the node under it. This module is the one place that defines the shape: +// the node outline, the fills and the link handles all come from it. +// +// A port is drawn in a box of PORT_WIDTH by PORT_HEIGHT. The outline is a +// stem that crosses the border and a wider body below it, both pointing +// down the page, for the tab and for the notch. + +/** The width of the line around a node, in pixels. */ +export const BORDER_WIDTH = 2; +/** The width of the box of a port. */ +export const PORT_WIDTH = 26; +/** The height of the box of a port: its stem and its body. */ +export const PORT_HEIGHT = 16; +/** The x of the centre of every port, in the frame of the node. */ +export const PORT_X = NODE_WIDTH / 2; +/** The corner radius of a card. */ +export const CARD_RADIUS = parseFloat(RADIUS.control); + +/** The width of the dark rim around a tab and a notch. */ +export const RIM_WIDTH = 3.6; +/** The width of the coloured ring around the white inside of a notch. */ +export const RING_WIDTH = 2.4; + +/** The outline of a port in its box: the stem, then the body. */ +const OUTLINE = 'M9.5 0V4H7V16H19V4H16.5V0'; +/** The closed outline, for filling. */ +export const PORT_SHAPE = `${OUTLINE}Z`; +/** The open outline of a notch, which has no line along its mouth. */ +export const NOTCH_RING = OUTLINE; +/** The y of the letter in a port, in its box: in the middle of the body. */ +export const LETTER_Y = 10; + +/** The y of the top of the box of a notch, in the frame of the node. */ +export const NOTCH_TOP = BORDER_WIDTH; +/** The y of the top of the box of a tab, in the frame of a node `height` tall. */ +export function tabTop(height: number): number { + return height - BORDER_WIDTH; +} + +/** The transform that puts the box of a port at `top`, centred on PORT_X. */ +export function portTransform(top: number): string { + return `translate(${PORT_X - PORT_WIDTH / 2} ${top})`; +} + +// A number as it is written in a path: two decimals at most. +const n = (value: number) => Number(value.toFixed(2)); + +export interface OutlineOptions { + width: number; + height: number; + /** The corner radius. */ + radius: number; +} + +/** The clear space between a focused node and its focus ring. */ +export const FOCUS_GAP = 2; +/** The thickness of the focus ring. */ +export const FOCUS_WIDTH = 3; + +/** + * The stroke width that makes the outline of a node grow by `spread` pixels + * on each side of its border, as the focus ring and its gap do. + */ +export function spreadStroke(spread: number): number { + return BORDER_WIDTH + 2 * spread; +} + +/** + * The stroke width that makes the outline of a tab grow by `spread` pixels + * beyond its dark rim. + */ +export function rimSpreadStroke(spread: number): number { + return RIM_WIDTH + 2 * spread; +} + +/** The y of the border line along the top edge, and of the one along the bottom. */ +function borderLines(height: number) { + return { top: BORDER_WIDTH / 2, bottom: height - BORDER_WIDTH / 2 }; +} + +/** + * The outline of a node as one closed rounded rectangle, drawn clockwise + * from the top left. The line runs through the middle of the border. + */ +export function nodeOutline(options: OutlineOptions): string { + const { width, height, radius: r } = options; + const { top, bottom } = borderLines(height); + const left = BORDER_WIDTH / 2; + const right = width - left; + const side = (x: number, y: number) => `A${r} ${r} 0 0 1 ${n(x)} ${n(y)}`; + + return [ + `M${n(left + r)} ${n(top)}`, + `L${n(right - r)} ${n(top)}`, + side(right, top + r), + `L${n(right)} ${n(bottom - r)}`, + side(right - r, bottom), + `L${n(left + r)} ${n(bottom)}`, + side(left, bottom - r), + `L${n(left)} ${n(top + r)}`, + side(left + r, top), + 'Z', + ].join(''); +} + +// The handle is the box that the link measures from: a link leaves the +// bottom of the out handle, which is the tip of the tab, and arrives at the +// top of the in handle, which is the top of the notch. +const HANDLE_BOX: CSSProperties = { + left: PORT_X, + right: 'auto', + bottom: 'auto', + width: PORT_WIDTH, + height: PORT_HEIGHT, + transform: 'translateX(-50%)', +}; + +/** Puts the out handle on the tab, so the link starts at its tip. */ +export const OUT_HANDLE_STYLE: CSSProperties = { + ...HANDLE_BOX, + top: `calc(100% - ${BORDER_WIDTH}px)`, +}; + +/** Puts the in handle on the notch, so the arrowhead stops at its top. */ +export const IN_HANDLE_STYLE: CSSProperties = { + ...HANDLE_BOX, + top: NOTCH_TOP, +}; diff --git a/workflow-canvas/src/canvas/nodes.tsx b/workflow-canvas/src/canvas/nodes.tsx index 5e21410a..f606a6f5 100644 --- a/workflow-canvas/src/canvas/nodes.tsx +++ b/workflow-canvas/src/canvas/nodes.tsx @@ -1,18 +1,40 @@ // SPDX-License-Identifier: Apache-2.0 import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { + NODE_HEIGHT, + NODE_WIDTH, SEQUENCE_IN, SEQUENCE_OUT, type IntentGraphNode, type StageGraphNode, } from '../graph'; +import { + CARD_RADIUS, + FOCUS_GAP, + FOCUS_WIDTH, + IN_HANDLE_STYLE, + LETTER_Y, + NOTCH_RING, + NOTCH_TOP, + OUT_HANDLE_STYLE, + PORT_SHAPE, + PORT_WIDTH, + RIM_WIDTH, + RING_WIDTH, + nodeOutline, + portTransform, + rimSpreadStroke, + spreadStroke, + tabTop, +} from './jigsaw'; type FlowNode }> = Node< N['data'], N['type'] >; -// The port where run order arrives, at the top right of a node. +// The port where run order arrives. The handle is only the box that the link +// measures from; the notch itself is drawn by `NodeShape`. function SequenceIn() { return ( - - + className="wf-port" + style={IN_HANDLE_STYLE} + /> ); } -// The port where run order leaves, at the bottom right of a node. +// The port where run order leaves, with its tab drawn by `NodeShape`. function SequenceOut() { return ( + ); +} + +// The letter of a port, in the body of its tab or notch. +function PortLetter({ kind }: { kind: 'tab' | 'notch' }) { + return ( + + S + + ); +} + +// The notch where run order arrives: a dark rim, a white inside and a ring in +// the colour of the tab that fits it, open at the border. +function Notch() { + return ( + + + + + + + ); +} + +// The tab where run order leaves: the same outline as the notch, filled. +function Tab({ top }: { top: number }) { + return ( + + + + + + ); +} + +/** + * The card behind the content of a node: one rounded outline, with a notch + * just inside the top edge when run order comes in and a tab hanging from the + * bottom edge when it goes out. A tab fits the notch of the node below it. + * The focus ring is drawn around the card and the tab, so it follows the tab. + */ +function NodeShape({ + height, + radius, + notch, + tab, +}: { + height: number; + radius: number; + notch: boolean; + tab: boolean; +}) { + const outline = nodeOutline({ width: NODE_WIDTH, height, radius }); + const tabAt = tabTop(height); + return ( + - + + {tab && ( + + )} + + {tab && ( + + )} + + {notch && } + {tab && } + ); } @@ -46,6 +167,12 @@ function SequenceOut() { export function IntentNode({ data }: NodeProps>) { return (
+
intent
{data.outcome} @@ -58,7 +185,11 @@ export function IntentNode({ data }: NodeProps>) { /** One stage: its name, then its agent and harness, with run order in and out. */ export function StageNode({ data }: NodeProps>) { return ( -
+
+
{data.name} @@ -74,7 +205,13 @@ export function StageNode({ data }: NodeProps>) { /** The finish of the workflow, with a run order in. */ export function EndNode() { return ( -
+
+
end
diff --git a/workflow-canvas/src/chrome/Breadcrumbs.tsx b/workflow-canvas/src/chrome/Breadcrumbs.tsx new file mode 100644 index 00000000..71da872f --- /dev/null +++ b/workflow-canvas/src/chrome/Breadcrumbs.tsx @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +import Box from '@cloudscape-design/components/box'; +import Icon from '@cloudscape-design/components/icon'; + +/** + * The trail above the title: Workflows, then the workflow that is open. The + * page has no list of workflows yet, so no crumb is a link; the last one is + * the current page. + */ +export function Breadcrumbs({ workflowName }: { workflowName?: string }) { + return ( + + ); +} diff --git a/workflow-canvas/src/chrome/TopBar.tsx b/workflow-canvas/src/chrome/TopBar.tsx new file mode 100644 index 00000000..1442ad30 --- /dev/null +++ b/workflow-canvas/src/chrome/TopBar.tsx @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +import TopNavigation from '@cloudscape-design/components/top-navigation'; +import { otherMode } from '../theme/mode'; +import { LOGO_DATA_URI, type ThemeMode } from '../theme/tokens'; + +/** The id of the top bar, which the page layout reads to sit below it. */ +export const TOP_BAR_ID = 'wf-top-bar'; + +/** + * The bar across the top: the logo and the page name, and the button that + * switches between the light and the dark mode. The accessible name of the + * button starts with its visible text. + */ +export function TopBar({ + mode, + onToggle, +}: { + mode: ThemeMode; + onToggle: () => void; +}) { + return ( +
+ event.preventDefault(), + title: 'Workflow canvas', + logo: { src: LOGO_DATA_URI, alt: 'Konductor' }, + }} + utilities={[ + { + type: 'button', + text: `Theme: ${mode}`, + ariaLabel: `Theme: ${mode}. Switch to ${otherMode(mode)}`, + onClick: onToggle, + }, + ]} + /> +
+ ); +} diff --git a/workflow-canvas/src/chrome/chrome.css b/workflow-canvas/src/chrome/chrome.css new file mode 100644 index 00000000..f990720d --- /dev/null +++ b/workflow-canvas/src/chrome/chrome.css @@ -0,0 +1,27 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +/* + * The strip of the Designer and Execution tabs is part of the dark header + * band: it is painted with the band colour across the full width of the + * page, so the tab labels, the selected-tab indicator and the rule under + * them all sit on the band. The colours come from the theme module through + * `--wf-band-background` and `--wf-band-divider`. + */ +.wf-tab-strip :has(> * > [role='tablist']) { + background: var(--wf-band-background); + box-shadow: 0 0 0 100vmax var(--wf-band-background); + /* The library sets this colour with a more specific rule. */ + border-block-end-color: var(--wf-band-divider) !important; + clip-path: inset(0 -100vmax); +} + +/* The trail above the title. */ +.wf-crumbs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} diff --git a/workflow-canvas/src/graph/layout.ts b/workflow-canvas/src/graph/layout.ts index 73793a83..5d487f80 100644 --- a/workflow-canvas/src/graph/layout.ts +++ b/workflow-canvas/src/graph/layout.ts @@ -3,7 +3,7 @@ /** Every node is as wide as this, so the order edges run straight down. */ export const NODE_WIDTH = 280; /** The height of each kind of node, as the node components draw them. */ -export const NODE_HEIGHT = { intent: 96, stage: 76, end: 44 } as const; +export const NODE_HEIGHT = { intent: 96, stage: 84, end: 48 } as const; /** The empty space between one node and the next. */ export const NODE_GAP = 56; diff --git a/workflow-canvas/src/main.tsx b/workflow-canvas/src/main.tsx index 31d69212..19ab66c3 100644 --- a/workflow-canvas/src/main.tsx +++ b/workflow-canvas/src/main.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import '@cloudscape-design/global-styles/index.css'; +import '@cloudscape-design/global-styles/index-base.css'; +import './theme/fonts'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; @@ -7,12 +8,15 @@ import { createHttpApi } from './api/http-api'; import { LaunchNotice } from './launch/LaunchNotice'; import { takeLaunchCode, watchLaunchCode } from './launch/launch-code'; import { sessionStorageMemory } from './launch/session-memory'; +import { installTheme } from './theme/install'; import { useWorkflowSource } from './workflow/use-workflow-source'; import { createWorkflowSource } from './workflow/workflow-source'; const root = document.getElementById('root'); if (!root) throw new Error('Missing #root element'); +installTheme(document.body); + const source = createWorkflowSource( createHttpApi(), takeLaunchCode(window), diff --git a/workflow-canvas/src/theme/contrast.ts b/workflow-canvas/src/theme/contrast.ts new file mode 100644 index 00000000..18f56228 --- /dev/null +++ b/workflow-canvas/src/theme/contrast.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +function channel(hex: string, start: number): number { + const value = parseInt(hex.slice(start, start + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +function luminance(hex: string): number { + const digits = hex.replace('#', ''); + return ( + 0.2126 * channel(digits, 0) + + 0.7152 * channel(digits, 2) + + 0.0722 * channel(digits, 4) + ); +} + +/** The WCAG 2 contrast ratio of two `#rrggbb` colours, from 1 to 21. */ +export function contrastRatio(a: string, b: string): number { + const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return ((light ?? 0) + 0.05) / ((dark ?? 0) + 0.05); +} + +/** The least ratio for body text (WCAG 2 AA). */ +export const AA_TEXT = 4.5; +/** The least ratio for the edge of a control or an icon (WCAG 2 AA). */ +export const AA_GRAPHIC = 3; + +/** The `#rrggbb` colour of a translucent `rgba(r, g, b, a)` laid over `backing`. */ +export function blend(rgba: string, backing: string): string { + const match = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(rgba); + if (!match) throw new Error(`Not an rgba colour: ${rgba}`); + const alpha = Number(match[4]); + const digits = backing.replace('#', ''); + return ( + '#' + + [1, 2, 3] + .map((group, index) => { + const below = parseInt(digits.slice(index * 2, index * 2 + 2), 16); + const mixed = Number(match[group]) * alpha + below * (1 - alpha); + return Math.round(mixed).toString(16).padStart(2, '0'); + }) + .join('') + ); +} diff --git a/workflow-canvas/src/theme/fonts.ts b/workflow-canvas/src/theme/fonts.ts new file mode 100644 index 00000000..40cfaff3 --- /dev/null +++ b/workflow-canvas/src/theme/fonts.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// Outfit for text and JetBrains Mono for code, both licensed under the SIL Open +// Font License 1.1. The Latin files are bundled with the page, so no font is +// requested from another site. +import '@fontsource/outfit/latin-400.css'; +import '@fontsource/outfit/latin-500.css'; +import '@fontsource/outfit/latin-600.css'; +import '@fontsource/outfit/latin-700.css'; +import '@fontsource/jetbrains-mono/latin-400.css'; +import '@fontsource/jetbrains-mono/latin-700.css'; diff --git a/workflow-canvas/src/theme/install.ts b/workflow-canvas/src/theme/install.ts new file mode 100644 index 00000000..9e0004a0 --- /dev/null +++ b/workflow-canvas/src/theme/install.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +import { applyMode, Mode } from '@cloudscape-design/global-styles'; +import { applyTheme } from '@cloudscape-design/components/theming'; +import { + APP_THEME, + BAND, + FONT_TEXT, + TAB_STRIP_SELECTOR, + TAB_STRIP_THEME, + type ThemeMode, +} from './tokens'; + +// The band colours that `chrome.css` reads, as custom properties. +const BAND_VARIABLES = { + '--wf-band-background': BAND.background, + '--wf-band-divider': BAND.divider, +} as const; + +/** + * Applies the konductor docs theme to the page. Call it once, before the + * first render; the theme has a value for each mode. Cloudscape sets fonts on + * its components only, so the page text outside them gets the font here. + */ +export function installTheme(body: HTMLElement): void { + body.style.fontFamily = FONT_TEXT; + for (const [name, value] of Object.entries(BAND_VARIABLES)) { + body.style.setProperty(name, value); + } + applyTheme({ theme: APP_THEME }); + applyTheme({ theme: TAB_STRIP_THEME, selector: TAB_STRIP_SELECTOR }); +} + +/** + * Switches the page, its components and its native controls to `mode`. The + * root's `data-theme` is what the canvas styles key on. + */ +export function showMode(mode: ThemeMode, root: HTMLElement): void { + applyMode(mode === 'dark' ? Mode.Dark : Mode.Light); + root.style.colorScheme = mode; + root.dataset.theme = mode; +} diff --git a/workflow-canvas/src/theme/mode.test.ts b/workflow-canvas/src/theme/mode.test.ts new file mode 100644 index 00000000..2672b18d --- /dev/null +++ b/workflow-canvas/src/theme/mode.test.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { + initialMode, + MODE_STORAGE_KEY, + otherMode, + rememberMode, + type ModeEnvironment, +} from './mode'; + +function memoryStorage(initial: Record = {}) { + const items = new Map(Object.entries(initial)); + return { + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => void items.set(key, value), + }; +} + +const systemDark = (dark: boolean) => (query: string) => ({ + matches: dark && query === '(prefers-color-scheme: dark)', +}); + +describe('initialMode', () => { + it('uses the saved choice before the system setting', () => { + const env: ModeEnvironment = { + localStorage: memoryStorage({ [MODE_STORAGE_KEY]: 'light' }), + matchMedia: systemDark(true), + }; + + expect(initialMode(env)).toBe('light'); + }); + + it('follows the system setting when nothing was saved', () => { + const storage = memoryStorage(); + + expect( + initialMode({ localStorage: storage, matchMedia: systemDark(true) }), + ).toBe('dark'); + expect( + initialMode({ localStorage: storage, matchMedia: systemDark(false) }), + ).toBe('light'); + }); + + it('ignores a saved value that is not a mode', () => { + const env: ModeEnvironment = { + localStorage: memoryStorage({ [MODE_STORAGE_KEY]: 'sepia' }), + matchMedia: systemDark(true), + }; + + expect(initialMode(env)).toBe('dark'); + }); + + it('is light when the browser tells nothing', () => { + expect(initialMode({})).toBe('light'); + }); + + it('survives storage that throws', () => { + const blocked = { + getItem: () => { + throw new Error('blocked'); + }, + setItem: () => { + throw new Error('blocked'); + }, + }; + + expect( + initialMode({ localStorage: blocked, matchMedia: systemDark(true) }), + ).toBe('dark'); + expect(() => rememberMode({ localStorage: blocked }, 'dark')).not.toThrow(); + }); +}); + +describe('rememberMode', () => { + it('keeps the mode for the next visit', () => { + const env: ModeEnvironment = { localStorage: memoryStorage() }; + + rememberMode(env, 'dark'); + + expect(initialMode(env)).toBe('dark'); + }); +}); + +describe('otherMode', () => { + it('switches between the two modes', () => { + expect(otherMode('light')).toBe('dark'); + expect(otherMode('dark')).toBe('light'); + }); +}); diff --git a/workflow-canvas/src/theme/mode.ts b/workflow-canvas/src/theme/mode.ts new file mode 100644 index 00000000..987cee84 --- /dev/null +++ b/workflow-canvas/src/theme/mode.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { ThemeMode } from './tokens'; + +/** The key under which the chosen mode is kept in local storage. */ +export const MODE_STORAGE_KEY = 'konductor-workflow-canvas-theme'; + +/** The parts of `window` that decide the mode. */ +export interface ModeEnvironment { + localStorage?: Pick; + matchMedia?: (query: string) => { matches: boolean }; +} + +function isMode(value: unknown): value is ThemeMode { + return value === 'light' || value === 'dark'; +} + +// Reading `localStorage` can throw when the browser blocks storage, so the +// read of the property is inside the `try` too. +function storedMode(env: ModeEnvironment): ThemeMode | undefined { + try { + const value = env.localStorage?.getItem(MODE_STORAGE_KEY); + return isMode(value) ? value : undefined; + } catch { + return undefined; + } +} + +function systemMode(env: ModeEnvironment): ThemeMode { + const dark = env.matchMedia?.('(prefers-color-scheme: dark)').matches; + return dark ? 'dark' : 'light'; +} + +/** + * The mode to show on first paint: the one the person chose before, else the + * one the system asks for, else light. + */ +export function initialMode(env: ModeEnvironment): ThemeMode { + return storedMode(env) ?? systemMode(env); +} + +/** The mode that a toggle switches to. */ +export function otherMode(mode: ThemeMode): ThemeMode { + return mode === 'dark' ? 'light' : 'dark'; +} + +/** Keeps a chosen mode for the next visit. A blocked storage is ignored. */ +export function rememberMode(env: ModeEnvironment, mode: ThemeMode): void { + try { + env.localStorage?.setItem(MODE_STORAGE_KEY, mode); + } catch { + // The choice then lasts until the page closes. + } +} diff --git a/workflow-canvas/src/theme/tokens.test.ts b/workflow-canvas/src/theme/tokens.test.ts new file mode 100644 index 00000000..5d350308 --- /dev/null +++ b/workflow-canvas/src/theme/tokens.test.ts @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { AA_GRAPHIC, AA_TEXT, blend, contrastRatio } from './contrast'; +import { + APP_THEME, + BAND, + colorsOf, + TAB_STRIP_STYLE, + TAB_STRIP_THEME, + WHITE, + type ThemeMode, +} from './tokens'; + +const MODES: ThemeMode[] = ['light', 'dark']; + +describe.each(MODES)('the %s palette', (mode) => { + const c = colorsOf(mode); + + it.each([ + ['text on the page', c.text, c.bg], + ['text on a surface', c.text, c.surface], + ['muted text on a surface', c.textMuted, c.surface], + ['muted text on the page', c.textMuted, c.bg], + ['muted text on a raised area', c.textMuted, c.surfaceAlt], + ['links on a surface', c.link, c.surface], + ['hovered links on a surface', c.linkHover, c.surface], + ['white text on a filled button', WHITE, c.fill], + ['white text on a hovered filled button', WHITE, c.fillHover], + ['success text on a surface', c.success, c.surface], + ['error text on a surface', c.error, c.surface], + ['warning text on a surface', c.warning, c.surface], + ['success text on its wash', c.success, c.successBg], + ['error text on its wash', c.error, c.errorBg], + ['warning text on its wash', c.warning, c.warningBg], + ])('has AA contrast for %s', (_name, foreground, background) => { + expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual( + AA_TEXT, + ); + }); + + describe.each(['tint', 'tintStrong'] as const)( + 'text on the %s wash', + (wash) => { + it.each([ + ['text', c.text], + ['muted text', c.textMuted], + ['links', c.link], + ])( + 'has AA contrast for %s on every area it can lie on', + (_name, text) => { + for (const area of [c.bg, c.surface, c.surfaceAlt]) { + expect( + contrastRatio(text, blend(c[wash], area)), + ).toBeGreaterThanOrEqual(AA_TEXT); + } + }, + ); + }, + ); + + it('draws control edges that reach 3:1 on the surface and the field', () => { + expect(contrastRatio(c.control, c.surface)).toBeGreaterThanOrEqual( + AA_GRAPHIC, + ); + expect(contrastRatio(c.control, c.field)).toBeGreaterThanOrEqual( + AA_GRAPHIC, + ); + }); +}); + +describe('the header band', () => { + it.each([BAND.text, BAND.textMuted, BAND.link])( + 'has AA contrast for %s', + (colour) => { + expect(contrastRatio(colour, BAND.background)).toBeGreaterThanOrEqual( + AA_TEXT, + ); + }, + ); + + it('draws the selected-tab indicator at 3:1 or more on the band', () => { + expect(contrastRatio(BAND.link, BAND.background)).toBeGreaterThanOrEqual( + AA_GRAPHIC, + ); + }); +}); + +describe('TAB_STRIP_THEME', () => { + it('colours the selected-tab indicator from the band', () => { + expect(TAB_STRIP_THEME.tokens.colorBorderItemSelected).toBe(BAND.link); + }); + + it('colours the line between tabs from the band', () => { + expect(TAB_STRIP_STYLE.tabSeparator.color).toBe(BAND.divider); + }); +}); + +describe('APP_THEME', () => { + it('gives the base font to the page', () => { + expect(APP_THEME.tokens.fontFamilyBase).toMatch(/^Outfit,/); + expect(APP_THEME.tokens.fontFamilyMonospace).toMatch(/^'JetBrains Mono',/); + }); + + it('gives every colour a value for both modes', () => { + const colourTokens = Object.entries(APP_THEME.tokens).filter( + ([name, value]) => name.startsWith('color') && typeof value === 'object', + ); + + expect(colourTokens.length).toBeGreaterThan(40); + for (const [, value] of colourTokens) { + expect(value).toHaveProperty('light'); + expect(value).toHaveProperty('dark'); + } + }); +}); diff --git a/workflow-canvas/src/theme/tokens.ts b/workflow-canvas/src/theme/tokens.ts new file mode 100644 index 00000000..93f4d6b6 --- /dev/null +++ b/workflow-canvas/src/theme/tokens.ts @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Theme } from '@cloudscape-design/components/theming'; + +/** The two looks of the page. */ +export type ThemeMode = 'light' | 'dark'; + +/** The colours of one mode, named by what they are used for. */ +export interface Colors { + /** The page. */ + bg: string; + /** The background of text boxes and other form controls. */ + field: string; + /** Cards, panels and containers. */ + surface: string; + /** Raised or secondary areas inside a surface. */ + surfaceAlt: string; + /** Hairlines that divide areas. */ + border: string; + /** The edge of a popup or a panel. */ + borderStrong: string; + /** The edge of a control. It must reach 3:1 on the surface. */ + control: string; + text: string; + textMuted: string; + /** Filled buttons and the selected segment. White text is set on it. */ + fill: string; + fillHover: string; + fillActive: string; + /** Links and outlined buttons: text and edge in one colour. */ + link: string; + linkHover: string; + success: string; + error: string; + warning: string; + /** The wash on a hovered or selected row, translucent. */ + tint: string; + tintStrong: string; + successBg: string; + errorBg: string; + warningBg: string; + infoBg: string; +} + +/** White: light-mode surfaces, text on filled buttons, hovered links on the band. */ +export const WHITE = '#ffffff'; + +/** Light mode: the paper tones of the konductor docs theme. */ +export const LIGHT: Colors = { + bg: '#f5f3ee', + field: WHITE, + surface: WHITE, + surfaceAlt: '#faf8f4', + border: '#e0dad0', + borderStrong: '#cfc7ba', + control: '#7a7490', + text: '#16131f', + textMuted: '#56516a', + fill: '#6236f0', + fillHover: '#5128db', + fillActive: '#4f26c9', + link: '#6236f0', + linkHover: '#4f26c9', + success: '#0b7a52', + error: '#b3261e', + warning: '#8a5a00', + tint: 'rgba(98, 54, 240, 0.08)', + tintStrong: 'rgba(98, 54, 240, 0.14)', + successBg: '#e7f5ee', + errorBg: '#fdecec', + warningBg: '#fff4db', + infoBg: '#efebff', +}; + +/** Dark mode: the night tones of the konductor docs theme. */ +export const DARK: Colors = { + bg: '#0b0a12', + field: '#100e1a', + surface: '#171331', + surfaceAlt: '#1c1740', + border: '#2a2450', + borderStrong: '#3a3168', + control: '#8f86ad', + text: '#f5f3ee', + textMuted: '#9c93b8', + fill: '#6b4bef', + fillHover: '#5b3bdb', + fillActive: '#4f2fc8', + link: '#a78bfa', + linkHover: '#c4b2ff', + success: '#3ddc97', + error: '#ff8b8b', + warning: '#f5b454', + tint: 'rgba(124, 92, 255, 0.14)', + tintStrong: 'rgba(124, 92, 255, 0.2)', + successBg: '#10281f', + errorBg: '#2b1722', + warningBg: '#2b2314', + infoBg: '#1f1a44', +}; + +/** The colours that only the canvas uses, for one mode. */ +export interface CanvasColors { + dots: string; + edge: string; + portFill: string; + portRim: string; + portLetterOut: string; + portLetterIn: string; + portNotchBg: string; +} + +const PORTS = { + portFill: '#5f6b7a', + portRim: '#16191f', + portLetterOut: WHITE, + portLetterIn: '#414d5c', + portNotchBg: WHITE, +} as const; + +/** + * The canvas colours that are not in the palette. `canvas.css` repeats them, + * and a test fails when it holds a colour that is not named here or in the + * palette. The ports look the same in both modes. + */ +export const CANVAS: Record = { + light: { dots: '#c9c1b3', edge: '#5f6b7a', ...PORTS }, + dark: { dots: '#3a3168', edge: '#a39bc0', ...PORTS }, +}; + +/** The colours of a mode. */ +export function colorsOf(mode: ThemeMode): Colors { + return mode === 'dark' ? DARK : LIGHT; +} + +/** + * The header band and the top bar are dark in both modes. These are the + * colours of the text and controls on them. Text and the selected-tab + * indicator reach their contrast (4.5:1 and 3:1) on `background`. + */ +export const BAND = { + background: '#141026', + text: '#f5f3ee', + textMuted: '#c9c2e0', + link: '#c4b2ff', + /** + * The hairline under the top bar and under the tabs. The band is dark in + * both modes, so it takes the hairline of the dark palette. + */ + divider: DARK.border, +} as const; + +/** The font of the page text. Self-hosted by `fonts.ts`. */ +export const FONT_TEXT = + "Outfit, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"; +/** The font of code and small labels. Self-hosted by `fonts.ts`. */ +export const FONT_CODE = + "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace"; + +/** The corner radius of cards and panels, of controls, and of small parts. */ +export const RADIUS = { card: '12px', control: '10px', small: '8px' } as const; + +/** The logo of the top bar: a violet square with a white K. */ +export const LOGO_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent( + '' + + '' + + '' + + `` + + '', +)}`; + +type Tokens = Theme['tokens']; + +// A token that has one value in light mode and one in dark mode. +function perMode(pick: (colors: Colors) => string) { + return { light: pick(LIGHT), dark: pick(DARK) }; +} + +function backgroundTokens(): Tokens { + return { + colorBackgroundLayoutMain: perMode((c) => c.bg), + colorBackgroundContainerContent: perMode((c) => c.surface), + colorBackgroundContainerHeader: perMode((c) => c.surface), + colorBackgroundLayoutPanelContent: perMode((c) => c.surface), + colorBackgroundPopover: perMode((c) => c.surfaceAlt), + colorBackgroundDropdownItemDefault: perMode((c) => c.surfaceAlt), + colorBackgroundDropdownItemHover: perMode((c) => c.tint), + colorBackgroundDropdownItemSelected: perMode((c) => c.tintStrong), + colorBackgroundInputDefault: perMode((c) => c.field), + colorBackgroundControlDefault: perMode((c) => c.field), + colorBackgroundControlChecked: perMode((c) => c.fill), + colorBackgroundItemSelected: perMode((c) => c.tintStrong), + colorBackgroundCellShaded: perMode((c) => c.surfaceAlt), + colorBackgroundToggleDefault: perMode((c) => c.control), + colorBackgroundHomeHeader: BAND.background, + colorBackgroundStatusError: perMode((c) => c.errorBg), + colorBackgroundStatusInfo: perMode((c) => c.infoBg), + colorBackgroundStatusSuccess: perMode((c) => c.successBg), + colorBackgroundStatusWarning: perMode((c) => c.warningBg), + }; +} + +function buttonTokens(): Tokens { + return { + colorBackgroundButtonNormalDefault: 'transparent', + colorBackgroundButtonNormalHover: perMode((c) => c.tint), + colorBackgroundButtonNormalActive: perMode((c) => c.tintStrong), + colorBackgroundButtonPrimaryDefault: perMode((c) => c.fill), + colorBackgroundButtonPrimaryHover: perMode((c) => c.fillHover), + colorBackgroundButtonPrimaryActive: perMode((c) => c.fillActive), + colorBackgroundButtonLinkHover: perMode((c) => c.tint), + colorBackgroundButtonLinkActive: perMode((c) => c.tintStrong), + colorTextButtonNormalDefault: perMode((c) => c.link), + colorTextButtonNormalHover: perMode((c) => c.linkHover), + colorTextButtonNormalActive: perMode((c) => c.linkHover), + colorTextButtonPrimaryDefault: WHITE, + colorTextButtonPrimaryHover: WHITE, + colorTextButtonPrimaryActive: WHITE, + colorTextButtonInlineIconDefault: perMode((c) => c.link), + colorTextButtonInlineIconHover: perMode((c) => c.linkHover), + colorBorderButtonNormalDefault: perMode((c) => c.link), + colorBorderButtonNormalHover: perMode((c) => c.linkHover), + colorBorderButtonNormalActive: perMode((c) => c.linkHover), + }; +} + +function textTokens(): Tokens { + return { + colorTextBodyDefault: perMode((c) => c.text), + colorTextBodySecondary: perMode((c) => c.textMuted), + colorTextHeadingDefault: perMode((c) => c.text), + colorTextHeadingSecondary: perMode((c) => c.textMuted), + colorTextLabel: perMode((c) => c.text), + colorTextFormLabel: perMode((c) => c.text), + colorTextFormDefault: perMode((c) => c.text), + colorTextFormSecondary: perMode((c) => c.textMuted), + colorTextEmpty: perMode((c) => c.textMuted), + colorTextAccent: perMode((c) => c.link), + colorTextLinkDefault: perMode((c) => c.link), + colorTextLinkHover: perMode((c) => c.linkHover), + colorTextInteractiveDefault: perMode((c) => c.textMuted), + colorTextInteractiveHover: perMode((c) => c.text), + colorTextInteractiveActive: perMode((c) => c.text), + colorTextInputPlaceholder: perMode((c) => c.textMuted), + colorTextStatusError: perMode((c) => c.error), + colorTextStatusInfo: perMode((c) => c.link), + colorTextStatusSuccess: perMode((c) => c.success), + colorTextStatusWarning: perMode((c) => c.warning), + colorTextStatusInactive: perMode((c) => c.textMuted), + colorTextDropdownItemDefault: perMode((c) => c.text), + colorTextDropdownItemHighlighted: perMode((c) => c.text), + colorTextDropdownItemSecondary: perMode((c) => c.textMuted), + colorTextGroupLabel: perMode((c) => c.textMuted), + colorTextHomeHeaderDefault: BAND.text, + colorTextHomeHeaderSecondary: BAND.textMuted, + colorTextBreadcrumbCurrent: perMode((c) => c.textMuted), + colorTextBreadcrumbIcon: perMode((c) => c.textMuted), + }; +} + +function borderTokens(): Tokens { + return { + colorBorderDividerDefault: perMode((c) => c.border), + colorBorderDividerSecondary: perMode((c) => c.border), + colorBorderContainerTop: perMode((c) => c.border), + colorBorderLayout: perMode((c) => c.border), + colorBorderInputDefault: perMode((c) => c.control), + colorBorderInputFocused: perMode((c) => c.link), + colorBorderControlDefault: perMode((c) => c.control), + colorBorderDropdownContainer: perMode((c) => c.borderStrong), + colorBorderDropdownItemFocused: perMode((c) => c.link), + colorBorderItemFocused: perMode((c) => c.link), + colorBorderItemSelected: perMode((c) => c.link), + colorBorderPopover: perMode((c) => c.borderStrong), + colorBorderStatusError: perMode((c) => c.error), + colorBorderStatusInfo: perMode((c) => c.link), + colorBorderStatusSuccess: perMode((c) => c.success), + colorBorderStatusWarning: perMode((c) => c.warning), + }; +} + +function shapeTokens(): Tokens { + const { card, control, small } = RADIUS; + return { + borderRadiusButton: control, + borderRadiusInput: control, + borderRadiusContainer: card, + borderRadiusDropdown: card, + borderRadiusPopover: card, + borderRadiusAlert: card, + borderRadiusFlashbar: card, + borderRadiusBadge: small, + borderRadiusToken: small, + borderRadiusItem: small, + borderRadiusTabsFocusRing: small, + fontFamilyBase: FONT_TEXT, + fontFamilyHeading: FONT_TEXT, + fontFamilyDisplay: FONT_TEXT, + fontFamilyMonospace: FONT_CODE, + }; +} + +// Inside the dark band the global text and border colours do not apply, so +// the same roles are set again here. Cloudscape reads these in the page +// header, which holds the breadcrumbs and the title. +function headerBandTokens(): Tokens { + return { + colorTextAccent: BAND.link, + colorTextLinkDefault: BAND.link, + colorTextLinkHover: WHITE, + colorTextBodySecondary: BAND.textMuted, + colorTextBreadcrumbCurrent: BAND.textMuted, + colorTextBreadcrumbIcon: BAND.textMuted, + colorTextInteractiveDefault: BAND.textMuted, + colorTextInteractiveHover: WHITE, + colorTextInteractiveActive: WHITE, + colorBorderItemFocused: BAND.link, + }; +} + +/** + * The Cloudscape theme of the page, for `applyTheme`: colours, fonts and + * corner radii, with a value for each mode. `applyMode` picks the mode. + */ +export const APP_THEME: Theme = { + tokens: { + ...backgroundTokens(), + ...buttonTokens(), + ...textTokens(), + ...borderTokens(), + ...shapeTokens(), + }, + contexts: { + header: { + tokens: { + ...headerBandTokens(), + colorBackgroundLayoutMain: BAND.background, + }, + }, + 'top-navigation': { + tokens: { + ...headerBandTokens(), + colorBackgroundContainerContent: BAND.background, + colorTextHeadingDefault: BAND.text, + colorTextTopNavigationTitle: BAND.text, + colorTextInteractiveDefault: BAND.text, + colorTextInteractiveHover: WHITE, + colorBorderDividerDefault: BAND.divider, + }, + }, + }, +}; + +/** + * Where the strip of the page tabs is, for `TAB_STRIP_THEME`: the element + * that holds the tab list, found through its role so no library class is + * needed. `chrome.css` paints the same element with the band. + */ +export const TAB_STRIP_SELECTOR = '.wf-tab-strip :has(> * > [role="tablist"])'; + +/** + * The same roles for the strip of the Designer and Execution tabs, which is + * page content that sits on the band, so the header context does not reach + * it. The selected-tab indicator is set too. + */ +export const TAB_STRIP_THEME: Theme = { + tokens: { + ...headerBandTokens(), + colorBorderItemSelected: BAND.link, + }, +}; + +/** + * The `style` of the page tabs: the line between two tabs, in the colour of + * the other hairlines of the band. (The rule under the strip is set in + * `chrome.css`.) + */ +export const TAB_STRIP_STYLE = { + tabSeparator: { color: BAND.divider }, +} as const; diff --git a/workflow-canvas/src/theme/use-theme-mode.ts b/workflow-canvas/src/theme/use-theme-mode.ts new file mode 100644 index 00000000..8b527e85 --- /dev/null +++ b/workflow-canvas/src/theme/use-theme-mode.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useLayoutEffect, useState } from 'react'; +import { showMode } from './install'; +import { initialMode, otherMode, rememberMode } from './mode'; +import type { ThemeMode } from './tokens'; + +/** + * The mode of the page and a function that switches it. The first mode is + * the saved choice or the system setting. Only a switch is saved, so a page + * that was never switched keeps following the system. + */ +export function useThemeMode(): [ThemeMode, () => void] { + const [mode, setMode] = useState(() => initialMode(window)); + + // Before paint, so the first frame is already in the right mode. + useLayoutEffect(() => { + showMode(mode, document.documentElement); + }, [mode]); + + const toggle = useCallback(() => { + const next = otherMode(mode); + rememberMode(window, next); + setMode(next); + }, [mode]); + + return [mode, toggle]; +} diff --git a/workflow-canvas/src/workflow/load-workflow.ts b/workflow-canvas/src/workflow/load-workflow.ts index 93aae073..d9138d62 100644 --- a/workflow-canvas/src/workflow/load-workflow.ts +++ b/workflow-canvas/src/workflow/load-workflow.ts @@ -192,3 +192,10 @@ export async function loadOnOpen( const result = await openWithCode(api, launchCode, memory, false); return result.ok ? result : resumeAny(api, before, memory, result.message); } + +/** The name of the workflow that a load drew, or `undefined` if none is open. */ +export function openWorkflowName( + result: LoadResult | undefined, +): string | undefined { + return result?.ok ? result.workflow.document.view?.name : undefined; +} diff --git a/workflow-canvas/test-support/canvas-contrast.test.ts b/workflow-canvas/test-support/canvas-contrast.test.ts new file mode 100644 index 00000000..9965784a --- /dev/null +++ b/workflow-canvas/test-support/canvas-contrast.test.ts @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { AA_GRAPHIC, AA_TEXT, contrastRatio } from '../src/theme/contrast.ts'; +import { + CANVAS, + colorsOf, + RADIUS, + type ThemeMode, +} from '../src/theme/tokens.ts'; + +const css = readFileSync( + new URL('../src/canvas/canvas.css', import.meta.url), + 'utf8', +); + +// The declarations of the rule that starts with `selector`. +function block(selector: string): string { + return css.split(`${selector} {`)[1]?.split('}')[0] ?? ''; +} + +// Every `--wf-*` declaration of the rule, whatever its value. +function declarations(selector: string): Record { + return Object.fromEntries( + [...block(selector).matchAll(/(--wf-[\w-]+):\s*([^;]+);/g)].map((m) => [ + m[1], + m[2]!.trim(), + ]), + ); +} + +const VARIABLE_BLOCKS = ['.wf-canvas', ":root[data-theme='dark'] .wf-canvas"]; +const HEX_COLOUR = /^#[0-9a-f]{6}$/; + +const light = declarations('.wf-canvas'); +const dark = { + ...light, + ...declarations(":root[data-theme='dark'] .wf-canvas"), +}; +const MODES: [ThemeMode, Record][] = [ + ['light', light], + ['dark', dark], +]; + +describe.each(MODES)('canvas.css colours in %s', (mode, v) => { + const ratio = (a: string, b: string) => contrastRatio(v[a]!, v[b]!); + + it('keeps the canvas text readable', () => { + expect(ratio('--wf-text', '--wf-node-bg')).toBeGreaterThanOrEqual(AA_TEXT); + expect(ratio('--wf-text-muted', '--wf-node-bg')).toBeGreaterThanOrEqual( + AA_TEXT, + ); + expect(ratio('--wf-text-muted', '--wf-canvas-bg')).toBeGreaterThanOrEqual( + AA_TEXT, + ); + expect(ratio('--wf-text', '--wf-controls-bg')).toBeGreaterThanOrEqual( + AA_TEXT, + ); + }); + + it('keeps nodes, edges and the focus ring visible', () => { + for (const name of ['--wf-node-border', '--wf-edge', '--wf-focus']) { + expect(ratio(name, '--wf-canvas-bg')).toBeGreaterThanOrEqual(AA_GRAPHIC); + } + }); + + it('keeps the port letters readable on their ports', () => { + expect( + ratio('--wf-port-letter-out', '--wf-port-fill'), + ).toBeGreaterThanOrEqual(AA_TEXT); + // The notch is white inside, and its letter is a dark gray. + expect( + ratio('--wf-port-letter-in', '--wf-port-notch-bg'), + ).toBeGreaterThanOrEqual(AA_TEXT); + }); + + it('keeps the tab visible on the canvas and on the node', () => { + expect(ratio('--wf-port-fill', '--wf-canvas-bg')).toBeGreaterThanOrEqual( + AA_GRAPHIC, + ); + expect(ratio('--wf-port-fill', '--wf-node-bg')).toBeGreaterThanOrEqual( + AA_GRAPHIC, + ); + }); + + it('keeps the card outline visible on the canvas and on the node', () => { + expect(ratio('--wf-node-border', '--wf-canvas-bg')).toBeGreaterThanOrEqual( + AA_GRAPHIC, + ); + expect(ratio('--wf-node-border', '--wf-node-bg')).toBeGreaterThanOrEqual( + AA_GRAPHIC, + ); + }); + + it('uses the colours of the theme module, and names every one it uses', () => { + const c = colorsOf(mode); + const k = CANVAS[mode]; + const expected: Record = { + '--wf-canvas-bg': c.bg, + '--wf-node-bg': c.surface, + '--wf-node-border': c.control, + '--wf-text': c.text, + '--wf-text-muted': c.textMuted, + '--wf-focus': c.link, + '--wf-controls-bg': c.surface, + '--wf-controls-border': c.borderStrong, + '--wf-controls-bg-hover': c.infoBg, + '--wf-canvas-dots': k.dots, + '--wf-edge': k.edge, + '--wf-port-fill': k.portFill, + '--wf-port-rim': k.portRim, + '--wf-port-letter-out': k.portLetterOut, + '--wf-port-letter-in': k.portLetterIn, + '--wf-port-notch-bg': k.portNotchBg, + }; + + expect(v).toEqual(expected); + }); +}); + +describe('canvas.css shapes', () => { + it('uses the radius of the theme module for the canvas', () => { + expect(block('.wf-canvas')).toContain(`border-radius: ${RADIUS.card}`); + }); +}); + +// A colour must be a 6-digit hex variable that the theme module is checked +// against, so that a hand-copied value cannot drift from the palette. +describe('canvas.css colours come only from the checked variables', () => { + const COLOUR_LITERAL = + /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i; + + it.each(VARIABLE_BLOCKS)('declares %s colours as 6-digit hex', (selector) => { + for (const [name, value] of Object.entries(declarations(selector))) { + expect(value, name).toMatch(HEX_COLOUR); + } + }); + + it('writes no colour outside the two variable blocks', () => { + const outside = css + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\.wf-canvas \{[^}]*\}/g, ''); + + expect(outside).not.toMatch(COLOUR_LITERAL); + }); +}); diff --git a/workflow-canvas/test-support/csp.test.ts b/workflow-canvas/test-support/csp.test.ts index 15bc6686..fc626342 100644 --- a/workflow-canvas/test-support/csp.test.ts +++ b/workflow-canvas/test-support/csp.test.ts @@ -1,16 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; +import { inlineScriptsOf } from './inline-script.ts'; const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const policy = /http-equiv="Content-Security-Policy"\s+content="([^"]*)"/.exec( html, )?.[1]; +const inlineScriptHashes = inlineScriptsOf(html).map( + (body) => `'sha256-${createHash('sha256').update(body).digest('base64')}'`, +); + describe('the Content-Security-Policy of index.html', () => { it('is exactly this policy, so a widened source or a new directive fails', () => { expect(policy?.split('; ')).toEqual([ "default-src 'self'", + ['script-src', "'self'", ...inlineScriptHashes].join(' '), "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self' data:", diff --git a/workflow-canvas/test-support/early-mode.test.ts b/workflow-canvas/test-support/early-mode.test.ts new file mode 100644 index 00000000..008e0d2b --- /dev/null +++ b/workflow-canvas/test-support/early-mode.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { MODE_STORAGE_KEY } from '../src/theme/mode.ts'; +import { inlineScriptOf } from './inline-script.ts'; + +const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const script = inlineScriptOf(html); + +interface FakeRoot { + style: { colorScheme?: string }; + dataset: { theme?: string }; +} + +// Runs the inline script of the page against a stand-in for the browser. +function runWith(saved: string | null | 'blocked', systemDark: boolean) { + const root: FakeRoot = { style: {}, dataset: {} }; + const keys: string[] = []; + const localStorage = { + getItem(key: string) { + keys.push(key); + if (saved === 'blocked') throw new Error('blocked'); + return saved; + }, + }; + const matchMedia = () => ({ matches: systemDark }); + new Function('document', 'localStorage', 'matchMedia', script)( + { documentElement: root }, + localStorage, + matchMedia, + ); + return { root, keys }; +} + +describe('the inline script of index.html', () => { + it('reads the same storage key as the app', () => { + expect(runWith(null, false).keys).toEqual([MODE_STORAGE_KEY]); + }); + + it.each([ + ['dark', false, 'dark'], + ['light', true, 'light'], + [null, true, 'dark'], + [null, false, 'light'], + ['blocked', true, 'dark'], + ['nonsense', true, 'dark'], + ] as const)( + 'with saved %s and system dark %s sets %s', + (saved, systemDark, expected) => { + const { root } = runWith(saved, systemDark); + + expect(root.dataset.theme).toBe(expected); + expect(root.style.colorScheme).toBe(expected); + }, + ); +}); diff --git a/workflow-canvas/test-support/global-styles.test.ts b/workflow-canvas/test-support/global-styles.test.ts new file mode 100644 index 00000000..a5e67ec4 --- /dev/null +++ b/workflow-canvas/test-support/global-styles.test.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { describe, expect, it } from 'vitest'; + +const main = readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8'); +const stylesDir = createRequire(import.meta.url) + .resolve('@cloudscape-design/global-styles/index-base.css') + .replace(/index-base\.css$/, ''); + +// The page sets every font token to its own faces (see src/theme/tokens.ts), +// so the Open Sans faces that `index.css` embeds would only add weight. +describe('the global styles of Cloudscape', () => { + it('are imported without the Open Sans faces', () => { + expect(main).toContain('@cloudscape-design/global-styles/index-base.css'); + expect(main).not.toMatch(/global-styles\/index\.css/); + }); + + it('hold no font face in the base file', () => { + const base = readFileSync(`${stylesDir}index-base.css`, 'utf8'); + + expect(base).not.toContain('@font-face'); + }); +}); diff --git a/workflow-canvas/test-support/inline-script.test.ts b/workflow-canvas/test-support/inline-script.test.ts new file mode 100644 index 00000000..6276f397 --- /dev/null +++ b/workflow-canvas/test-support/inline-script.test.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { inlineScriptOf, inlineScriptsOf } from './inline-script.ts'; + +describe('inlineScriptOf', () => { + it.each([ + ['a plain tag', ''], + [ + 'an upper case tag with an attribute', + '', + ], + ['an end tag with a space', ''], + ['an end tag with an attribute', ''], + ['a start tag with a space', ''], + ])('finds the body in %s', (_, html) => { + expect(inlineScriptOf(html)).toBe('a()'); + }); + + it('skips a script with no body, such as one that loads a file', () => { + expect( + inlineScriptOf( + '', + ), + ).toBe('b()'); + }); + + it('does not take a tag whose name only starts with script', () => { + expect(inlineScriptOf('a()')).toBe(''); + }); + + it('is empty when there is no script', () => { + expect(inlineScriptOf('

none

')).toBe(''); + }); +}); + +describe('inlineScriptsOf', () => { + it('lists every inline script in order, and skips one that loads a file', () => { + expect( + inlineScriptsOf( + '', + ), + ).toEqual(['a()', 'b()']); + }); + + it('is empty when there is no inline script', () => { + expect(inlineScriptsOf('')).toEqual([]); + }); +}); diff --git a/workflow-canvas/test-support/inline-script.ts b/workflow-canvas/test-support/inline-script.ts new file mode 100644 index 00000000..5d09e7c3 --- /dev/null +++ b/workflow-canvas/test-support/inline-script.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Tag names are case-insensitive, a start tag may carry attributes, and an end +// tag may carry whitespace. +const SCRIPT = /]*>([\s\S]*?)<\/script\b[^>]*>/gi; + +/** The bodies of the `