mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-29 16:17:39 +09:00
## 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
397 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
};
|