Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
19494a5
Add the docs theme: tokens, bundled fonts and a light/dark mode switch
knihit Oct 25, 2026
2b60c17
Give the page a top bar, a header band and themed canvas nodes and ports
knihit Oct 25, 2026
6588fdc
Cover the theme in e2e tests, add a dark screenshot, document the theme
knihit Oct 25, 2026
1270cb2
Put the tab strip on the header band and finish the dark canvas ports
knihit Oct 25, 2026
c837e60
Use a plain comment for the font wait helper in the screenshot support
knihit Oct 25, 2026
985761f
Draw the S ports as jigsaw knobs and sockets, and tidy the theme
knihit Oct 25, 2026
1cdb04d
Match the dark canvas muted text to the theme module
knihit Oct 25, 2026
355abbc
Give the knob a longer pinched neck and draw the focus ring around th…
knihit Oct 26, 2026
5c3f890
Show the system outline for the focused node in forced-colors mode
knihit Oct 26, 2026
1727cba
Draw the S ports as square tabs and notches to match the connector de…
knihit Oct 27, 2026
629803f
Give stages and the end node room for the notch and a state badge
knihit Oct 27, 2026
92180f1
Add the theme changelog line
knihit Oct 27, 2026
aa220e1
Match the inline script tag case-insensitively in the early-mode test
knihit Oct 27, 2026
f6eb906
Address review findings on the theme: one palette, forced colors, lea…
knihit Oct 28, 2026
d96ee24
Keep text readable on the selected wash in both modes
knihit Oct 28, 2026
38bd90a
Match a script tag with attributes in the early-mode test
knihit Oct 28, 2026
53751e8
Make the colour guard see every colour literal in the canvas CSS
knihit Oct 28, 2026
1e34eb5
Drop the claim that a reload drops the session from the top bar comment
knihit Oct 28, 2026
734b57e
Allow the early theme script in the page policy by its hash
knihit Oct 28, 2026
9655781
Pin every inline script of the page in the policy test
knihit Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
71 changes: 61 additions & 10 deletions workflow-canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 > <workflow>`
(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.
Expand All @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions workflow-canvas/e2e/capture/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified workflow-canvas/e2e/screenshots/designer-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified workflow-canvas/e2e/screenshots/execution-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 16 additions & 23 deletions workflow-canvas/e2e/support/screenshots.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void> {
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<void> {
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/<name>.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<void> {
await pinFont(page);
await waitForFonts(page);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, `${name}.png`),
fullPage: true,
Expand Down
31 changes: 31 additions & 0 deletions workflow-canvas/e2e/support/workflow-canvas-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';

Expand Down Expand Up @@ -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' });
}

/**
Expand All @@ -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<void> {
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<ThemeName> {
const text = (await this.themeButton.textContent()) ?? '';
return text.endsWith('dark') ? 'dark' : 'light';
}

tab(name: TabName): Locator {
return this.page.getByRole('tab', { name });
}
Expand All @@ -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 });
Expand Down
85 changes: 83 additions & 2 deletions workflow-canvas/e2e/tests/canvas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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,
}) => {
Expand Down
Loading
Loading