Files
webstudio/apps/builder/e2e/flows/generated-app.ts
Oleg Isonen f6c524572d feat: add Builder dark theme and rebuild colors
## Summary

Rebuilds Webstudio’s color system, migrates the entire Builder to it,
and introduces a complete dark theme.

A small set of theme colors and contrast controls now drives the
interface through relative CSS colors:

```text
Theme colors and contrast
    ↓
Light/dark derivation bounds
    ↓
Derived color families
    ↓
Reusable semantic colors
    ↓
Component composition
    ↓
Builder interface
```

CSS is the runtime source of truth. Light and dark modes share the same
theme inputs, so changing a theme color automatically updates its
surfaces, content, borders, states, and component presentation. The
refactor turns dark mode into a first-class Builder theme rather than a
separately maintained palette.

This is an atomic migration. Legacy color names and compatibility
aliases will not remain after all consumers have moved.

## Scope

- The color source, derivation model, semantic vocabulary, validation,
typing, and Storybook overview.
- Every design-system component.
- Builder, dashboard, authentication, shared UI, Query Builder, stories,
and tests that consume the legacy color scale.
- Removal of the legacy Stitches color scale after its final consumer is
migrated.
- A complete dark theme across application UI while keeping authored
canvas content independent.
- Complete light and dark visual verification.
- Unified browser-test infrastructure: Vitest Browser Mode for Design
System browser contracts and standard Playwright Test for all Builder
E2E workflows.

Literal colors remain only where color is the actual content or belongs
to a deliberately separate system, such as user-authored values,
external previews, brand artwork, data visualization, functional canvas
overlays, and source assets. Every remaining exception must have an
explicit owner.

## Architecture decisions

- Theme authors control six color families and three bounded contrast
controls.
- Relative CSS colors derive the rest of the system.
- Dark mode changes derivation bounds instead of maintaining a second
independent palette.
- Components consume a small reusable semantic vocabulary.
- Component states and exceptional presentation are composed inside
components.
- Builder-specific categorical meaning uses focused composite recipes,
not intent semantics or a larger global vocabulary.
- TypeScript validates public semantic CSS-variable names but does not
generate runtime colors.
- No token-manager dependency, compatibility aliases, or
component-specific public color tokens.
- A new semantic or theme variable is added only when existing semantics
and local composition cannot represent a reusable cross-feature
decision.

## Todo

### Color-system foundation

- [x] Establish direct CSS as the runtime source of truth.
- [x] Define bounded theme colors and contrast controls.
- [x] Derive light and dark modes from the same theme inputs.
- [x] Reduce the semantic inventory to reusable decisions and explicit
accessibility pairs.
- [x] Generate only TypeScript names for public semantic CSS variables.
- [x] Validate layers, references, cycles, consumption, naming,
generated types, and browser-computed contrast.
- [x] Add the Storybook color-system overview and theme controls.
- [x] Document the architecture and its relationship to the public Craft
specification.

### Design-system migration

- [x] Replace every active legacy color consumer in design-system
components and Storybook chrome.
- [x] Compose component states locally without component-specific public
tokens.
- [x] Preserve intentional preview and sample-content colors.
- [x] Add complete positive, negative, warning, informative, and accent
accessibility pairings.
- [x] Validate light and dark scheme-bound references independently.
- [x] Verify the design-system package with typecheck, lint, formatting,
Storybook, and 222 tests.

### Inventory and contract

- [x] Inventory all 319 legacy references across 107 Builder, Query
Builder, story, test, and design-system files.
- [x] Classify every reference as a direct semantic replacement, local
composite recipe, documented literal exception, or removable code.
- [x] Record the intentional authored-content, external-preview,
functional-preview, data-visualization, brand, and source-asset
exceptions.
- [x] Treat the public semantic vocabulary as closed by default and
justify any proposed addition by cross-feature reuse and accessibility
need.
- [x] Keep Builder code limited to typed public variables accepted by
`cssVar()`.

### Theme infrastructure

- [x] Add `system` (default), `light`, and `dark` application
preferences.
- [x] Persist the preference in existing client settings and resolve
`system` through `matchMedia`.
- [x] Apply the resolved scheme before paint and respond to
operating-system changes.
- [x] Expose the persisted theme control under **View → Color scheme**.
- [x] Keep authored canvas documents independent from the application
theme.
- [x] Ensure portals, overlays, embedded surfaces, and third-party
controls receive the correct interface scheme.

### Generic interface migration

- [x] Migrate shared dialogs, project settings, notifications, controls,
and Query Builder.
- [x] Migrate dashboard, authentication, publishing, and shared
application UI.
- [x] Migrate the Builder loading state, topbar, sidebars, navigator,
workspace, footer, settings, pages, alerts, and menus.
- [x] Replace fixed interface literals alongside their owning surfaces.
- [x] Remove the old scrollbar variable and hardcoded loading color.

### Style panel and style-source recipes

- [x] Migrate ordinary style-panel inputs, labels, layouts, spacing,
alignment, menus, property states, and value editors.
- [x] Model style-source provenance, kind, interaction, and error as
separate axes.
- [x] Compose categorical style-source colors from neutral and
accent-relative colors.
- [x] Use negative semantics only for actual errors, not overwritten
provenance.
- [x] Reuse one feature-owned recipe across style-source controls,
badges, menus, and values without adding global tokens.

### Canvas and editors

- [x] Migrate canvas selection, reusable outlines, grid guides, resize
handles, labels, badges, and overlays.
- [x] Keep collaborator colors as data visualization while guaranteeing
readable labels.
- [x] Prevent Builder theme values from leaking into authored content.
- [x] Migrate editor surfaces, selections, autocomplete, lint states,
and tooltips.
- [x] Derive syntax visualization from neutral and accent-relative
colors; reserve intent colors for diagnostics.

### Explicit color domains

- [x] Isolate external search and social previews from the application
scheme.
- [x] Preserve user-authored CSS values, functional color previews, and
checkerboards.
- [x] Review brand surfaces independently in both schemes.
- [x] Document every retained literal in the exception ledger.

### Stories, tests, and legacy removal

- [x] Migrate all affected Builder stories and tests.
- [x] Replace the design-system test that intentionally references the
legacy scale.
- [x] Add theme-preference and scheme-resolution behavior tests.
- [x] Remove the legacy `design-tokens.color` registry.
- [x] Remove the Stitches `colors` scale and `rawTheme.colors`.
- [x] Remove color-name conversion code that exists only for the legacy
scale.
- [x] Add a focused repository check that rejects legacy references.
- [x] Confirm no compatibility mappings or deprecated aliases remain.

### Browser-test infrastructure

- [x] Run pure Design System tests in Node and browser-dependent tests
in Vitest Browser Mode with Playwright Chromium.
- [x] Migrate all 79 Builder E2E workflows from the custom harness to
standard Playwright Test.
- [x] Replace filename-based shard discovery with standard Playwright
sharding.
- [x] Preserve clean-backend shard retries and upload Playwright failure
artifacts in CI.
- [x] Remove the custom runner, registry, and timeout infrastructure.

### Verification and documentation

- [x] Compare light-mode visual regressions directly with `main`; review
every difference rather than accepting snapshots wholesale.
- [x] Add representative dark coverage for dashboard, Builder shell,
style panel, dialogs, editors, canvas tools, and status states.
- [x] Test default and extreme custom-theme presets.
- [x] Verify semantic and categorical-recipe contrast.
- [x] Test first paint, persistence, system changes, portals, and canvas
isolation.
- [x] Run affected tests, typechecks, lint, formatting, and Storybook
checks.
- [x] Review fixture impact; no fixture inputs or generated outputs are
affected.
- [x] Review authoritative `docs/` content and update it where needed.
- [x] Review the final diff and make this checklist reflect delivered
scope.

## Current verification

- Design-system typecheck and generated-type freshness: passed.
- Design-system tests: 222 passed, including the full theme-parameter
contrast sweep and browser component coverage.
- Builder typecheck and production build: passed.
- Theme preference and scheme-resolution tests: 10 passed.
- Scoped Oxlint, formatting, and diff checks: passed.
- Design-system Storybook build and representative light/dark visual
review: passed.
- Builder E2E: 80/80 Playwright entries passed (setup plus 79
workflows).

Ref #3818
Closes #6267
2026-08-30 03:24:56 +01:00

397 lines
12 KiB
TypeScript

import { execFile, spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { Page as PlaywrightPage } from "@playwright/test";
import { bundleVersion } from "@webstudio-is/protocol";
import { getStyleDeclKey, type Page } from "@webstudio-is/sdk";
import { loadDevBuild } from "../db";
import { stopChildProcess } from "../process";
const execFileAsync = promisify(execFile);
const cliLocalPath = fileURLToPath(
new URL("../../../../packages/cli/local.js", import.meta.url)
);
type BuildRow = Awaited<ReturnType<typeof loadDevBuild>>;
const getAvailablePort = async () => {
const server = createServer();
server.unref();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (address === null || typeof address === "string") {
await new Promise<void>((resolve) => server.close(() => resolve()));
throw new Error("Expected a numeric port for generated-app preview.");
}
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
};
const waitForGeneratedPreview = async ({
process,
url,
output,
}: {
process: ChildProcess;
url: string;
output: () => string;
}) => {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (process.exitCode !== null || process.signalCode !== null) {
throw new Error(
`Generated preview exited before it became ready at ${url}. Output:\n${output()}`
);
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(2_000),
});
if (response.ok) {
return await response.text();
}
} catch {
// The generated production server is still starting.
}
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`Generated preview did not become ready at ${url}. Output:\n${output()}`
);
};
const parseBuildField = <Value>(build: BuildRow, field: keyof BuildRow) =>
JSON.parse(String(build[field])) as Value;
const parseBuildEntriesById = <Value extends { id: string }>(
build: BuildRow,
field: keyof BuildRow
) => {
return parseBuildField<Value[]>(build, field).map((value) => [
value.id,
value,
]);
};
const parseBuildStyleEntries = (build: BuildRow) => {
return parseBuildField<Parameters<typeof getStyleDeclKey>[0][]>(
build,
"styles"
).map((style) => [getStyleDeclKey(style), style]);
};
const parseBuildStyleSourceSelectionEntries = (build: BuildRow) => {
return parseBuildField<Array<{ instanceId: string }>>(
build,
"styleSourceSelections"
).map((selection) => [selection.instanceId, selection]);
};
const createLocalBundleFromDevBuild = (build: BuildRow) => {
const pages = parseBuildField<{
homePageId: string;
pages: Page[];
}>(build, "pages");
const homePage = pages.pages.find((page) => page.id === pages.homePageId);
if (homePage === undefined) {
throw new Error("Expected dev build to have a home page");
}
return {
bundleVersion,
origin: "https://assets.example",
projectDomain: "generated-e2e.example",
projectTitle: pages.pages[0]?.name ?? "Generated E2E",
page: homePage,
pages: pages.pages,
assets: [],
build: {
id: build.id,
projectId: build.projectId,
version: build.version,
createdAt: build.createdAt,
updatedAt: build.updatedAt,
pages,
breakpoints: parseBuildEntriesById(build, "breakpoints"),
styles: parseBuildStyleEntries(build),
styleSources: parseBuildEntriesById(build, "styleSources"),
styleSourceSelections: parseBuildStyleSourceSelectionEntries(build),
props: parseBuildEntriesById(build, "props"),
instances: parseBuildEntriesById(build, "instances"),
dataSources: parseBuildEntriesById(build, "dataSources"),
resources: parseBuildEntriesById(build, "resources"),
},
};
};
export const expectGeneratedAppBuild = async ({
projectId,
expectedText,
}: {
projectId: string;
expectedText?: string;
}) => {
const tempDir = await mkdtemp(join(tmpdir(), "webstudio-e2e-generated-"));
try {
const build = await loadDevBuild({ projectId });
await mkdir(join(tempDir, ".webstudio"), { recursive: true });
await writeFile(
join(tempDir, ".webstudio", "data.json"),
`${JSON.stringify(createLocalBundleFromDevBuild(build), undefined, 2)}\n`
);
await execFileAsync(
process.execPath,
[cliLocalPath, "build", "--assets", "false", "--template", "defaults"],
{
cwd: tempDir,
env: { ...process.env, PWD: tempDir },
maxBuffer: 1024 * 1024 * 10,
}
);
if (expectedText !== undefined) {
const generatedHome = await readFile(
join(tempDir, "app", "__generated__", "_index.tsx"),
"utf8"
);
if (generatedHome.includes(expectedText) === false) {
throw new Error(
`Expected generated home route to include "${expectedText}".`
);
}
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
};
export const withGeneratedPreview = async <Result>({
projectId,
callback,
}: {
projectId: string;
callback: ({ url, html }: { url: string; html: string }) => Promise<Result>;
}) => {
const tempDir = await mkdtemp(join(tmpdir(), "webstudio-e2e-generated-"));
let preview: ChildProcess | undefined;
try {
const build = await loadDevBuild({ projectId });
await mkdir(join(tempDir, ".webstudio"), { recursive: true });
await writeFile(
join(tempDir, ".webstudio", "data.json"),
`${JSON.stringify(createLocalBundleFromDevBuild(build), undefined, 2)}\n`
);
const port = await getAvailablePort();
const output: string[] = [];
preview = spawn(
process.execPath,
[
cliLocalPath,
"preview",
"--assets",
"false",
"--template",
"defaults",
"--template",
"react-router",
"--host",
"127.0.0.1",
"--port",
String(port),
],
{
cwd: tempDir,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PWD: tempDir },
}
);
const appendOutput = (chunk: unknown) => {
output.push(String(chunk));
while (output.join("").length > 8_000) {
output.shift();
}
};
preview.stdout?.on("data", appendOutput);
preview.stderr?.on("data", appendOutput);
const url = `http://127.0.0.1:${port}/`;
const html = await waitForGeneratedPreview({
process: preview,
url,
output: () => output.join(""),
});
return await callback({ url, html });
} finally {
if (preview !== undefined) {
// The CLI starts npm and the generated server below it.
await stopChildProcess(preview, { killGroup: true });
}
await rm(tempDir, { recursive: true, force: true });
}
};
export const expectGeneratedAppToRender = async ({
projectId,
expectedText,
path = "/",
}: {
projectId: string;
expectedText: string;
path?: string;
}) => {
await withGeneratedPreview({
projectId,
callback: async ({ url, html }) => {
const response =
path === "/"
? undefined
: await fetch(new URL(path, url), { redirect: "manual" });
const routeHtml = response === undefined ? html : await response.text();
if (
(response !== undefined && response.ok === false) ||
routeHtml.includes(expectedText) === false
) {
throw new Error(
`Expected generated preview route ${JSON.stringify(path)} at ${url} to render ${JSON.stringify(expectedText)}.`
);
}
},
});
};
export const expectGeneratedAppRedirect = async ({
projectId,
path,
status,
location,
}: {
projectId: string;
path: string;
status: number;
location: string;
}) => {
await withGeneratedPreview({
projectId,
callback: async ({ url }) => {
const response = await fetch(new URL(path, url), {
redirect: "manual",
});
const actualLocation = response.headers.get("location");
if (response.status !== status || actualLocation !== location) {
throw new Error(
`Expected generated preview redirect ${JSON.stringify(path)} to return ${status} with location ${JSON.stringify(location)}. Received ${response.status} with location ${JSON.stringify(actualLocation)}.`
);
}
},
});
};
export const expectGeneratedAppNavigation = async ({
projectId,
page,
linkText,
targetPath,
}: {
projectId: string;
page: PlaywrightPage;
linkText: string;
targetPath: string;
}) => {
await withGeneratedPreview({
projectId,
callback: async ({ url }) => {
const targetUrl = new URL(targetPath, url).toString();
await page.goto(url);
await page.getByRole("link", { name: linkText, exact: true }).click();
await page.waitForURL(targetUrl);
const activeLink = page.getByRole("link", {
name: linkText,
exact: true,
});
try {
await activeLink.waitFor({ state: "visible" });
await page.waitForFunction(
({ linkText }) =>
Array.from(document.querySelectorAll("a")).some(
(link) =>
link.textContent?.trim() === linkText &&
link.getAttribute("aria-current") === "page"
),
{ linkText },
{ timeout: 10_000 }
);
} catch {
const ariaCurrent = await activeLink.getAttribute("aria-current");
throw new Error(
`Expected generated preview link ${JSON.stringify(linkText)} to be active after navigating to ${JSON.stringify(targetPath)}. Received aria-current=${JSON.stringify(ariaCurrent)}.`
);
}
},
});
};
export const expectGeneratedRedirects = async ({
projectId,
expectedRedirects,
}: {
projectId: string;
expectedRedirects: Array<{ old: string; new: string; status?: string }>;
}) => {
const tempDir = await mkdtemp(join(tmpdir(), "webstudio-e2e-generated-"));
try {
const build = await loadDevBuild({ projectId });
await mkdir(join(tempDir, ".webstudio"), { recursive: true });
await writeFile(
join(tempDir, ".webstudio", "data.json"),
`${JSON.stringify(createLocalBundleFromDevBuild(build), undefined, 2)}\n`
);
await execFileAsync(
process.execPath,
[cliLocalPath, "build", "--assets", "false", "--template", "defaults"],
{
cwd: tempDir,
env: { ...process.env, PWD: tempDir },
maxBuffer: 1024 * 1024 * 10,
}
);
const redirectsModule = await readFile(
join(tempDir, "app", "__generated__", "$resources.redirects.ts"),
"utf8"
);
const redirectsExport = redirectsModule.trim();
const prefix = "export const redirects = ";
const suffix = ";";
if (
redirectsExport.startsWith(prefix) === false ||
redirectsExport.endsWith(suffix) === false
) {
throw new Error(
`Expected generated redirects module to export redirects. Received ${redirectsModule}`
);
}
const redirectsJson = redirectsExport.slice(prefix.length, -suffix.length);
const redirects = JSON.parse(redirectsJson);
if (JSON.stringify(redirects) !== JSON.stringify(expectedRedirects)) {
throw new Error(
`Expected generated redirects ${JSON.stringify(
expectedRedirects
)}, received ${JSON.stringify(redirects)}.`
);
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
};