Files
webstudio/apps/builder/app/shared/code-editor-base.browser.test.tsx
Oleg Isonen d4b1994a6a fix: Improve editor selection and project tag contrast (#6302)
Closes #6301

## Summary

- Restore Dashboard project tags to the pre-color-refactor treatment:
fixed white text over a 30% black scrim in both application themes.
- Use one high-contrast text-selection treatment across native inputs,
textareas, contenteditable fields, and every shared CodeMirror editor.
- Preserve the existing subtle selection treatment for Navigator,
Project Settings, command menus, Dashboard navigation, and other
selected controls.
- Add direct Storybook coverage for the CSS fragment editor.
- Simplify chromatic color derivation from 571 to 329 lines without
changing computed colors.

## Technical choices

- Keep project tags independent from application-theme polarity because
they overlay arbitrary thumbnail artwork.
- Keep the existing `selectionBackground` control-state export unchanged
at its original 12% light / 20% dark accent blend.
- Define separate `--background-text-selection` and
`--foreground-on-text-selection` tokens exclusively for selected text.
- Use the bounded accent in light mode and a luminance-lifted accent in
dark mode so selected text remains distinct even with extreme custom
theme colors.
- Style CodeMirror through its existing selection layer and
`::selection` CSS hooks; no selection tracking or editor-state logic is
added.
- Leave canvas rich-text selection independent: authored canvas
documents do not load the Builder UI theme stylesheet.
- Replace five duplicated XYZ clamp formulas with the exact
upper-luminance cap they reduce to. The removed lower-bound and
white-mixing branches were unreachable because the minimum was fixed at
zero.

## Todo

- [x] Restore the original project-tag scrim and foreground in both
themes.
- [x] Inventory native, CodeMirror, and canvas rich-text selection
paths.
- [x] Unify native and shared CodeMirror text-selection colors.
- [x] Keep text selection independent from selected control and
navigation states.
- [x] Add the missing CSS fragment editor story.
- [x] Add light- and dark-mode browser regressions and confirm they fail
before the fix.
- [x] Enforce 4.5:1 selected-text contrast and 3:1 highlight-to-surface
contrast across supported theme inputs.
- [x] Remove duplicated and unreachable chromatic color derivation while
preserving outputs.
- [x] Review documentation impact; the design-system color-token
reference is updated, with no user-facing product documentation change
required.
- [x] Verify fixture impact; no fixture inputs or generated outputs are
affected.

## Verification

- `pnpm --filter @webstudio-is/design-system test` — 227 tests passed,
including the control/text separation regression, Navigator tree
interaction, default-palette comparisons, and the full supported-theme
contrast sweep
- `pnpm --filter @webstudio-is/design-system typecheck`
- `pnpm --filter @webstudio-is/builder exec vitest run
app/shared/code-editor-base.browser.test.tsx --project
app-browser-shared`
- `pnpm --filter @webstudio-is/builder exec vitest run
app/dashboard/projects/project-card.browser.test.tsx --project
app-browser-canvas-dashboard`
- `pnpm --filter @webstudio-is/builder typecheck`
- Targeted `oxlint`, formatting, and `git diff --check`
- Live dark-mode Dashboard verification of the restored subtle
navigation selection
- Storybook visual checks for the full color system and input/CodeMirror
selection in light and dark modes
2026-09-01 17:05:14 +02:00

140 lines
4.1 KiB
TypeScript

import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { cssVar } from "@webstudio-is/design-system";
import "@webstudio-is/design-system/global.css";
import { EditorContent } from "./code-editor-base";
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
const rangeGetClientRects = Object.getOwnPropertyDescriptor(
Range.prototype,
"getClientRects"
);
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
}
);
Object.defineProperty(Range.prototype, "getClientRects", {
configurable: true,
value: () => [new DOMRect(0, 0, 20, 16)],
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.documentElement.removeAttribute("data-color-scheme");
vi.unstubAllGlobals();
if (rangeGetClientRects === undefined) {
delete (Range.prototype as { getClientRects?: unknown }).getClientRects;
} else {
Object.defineProperty(
Range.prototype,
"getClientRects",
rangeGetClientRects
);
}
});
test.each(["light", "dark"] as const)(
"uses the shared selection color in %s mode",
async (mode) => {
document.documentElement.dataset.colorScheme = mode;
act(() => {
root.render(
<EditorContent
value="selected text"
onChange={() => {}}
onChangeComplete={() => {}}
/>
);
});
const editor = container.querySelector<HTMLElement>('[role="textbox"]');
if (editor === null) {
throw new Error("Expected the code editor");
}
const view = EditorView.findFromDOM(editor);
if (view === null) {
throw new Error("Expected the CodeMirror view");
}
act(() => {
view.dispatch({
selection: EditorSelection.range(0, view.state.doc.length),
});
view.focus();
});
await act(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
const selection = container.querySelector<HTMLElement>(
".cm-selectionBackground"
);
if (selection === null) {
throw new Error("Expected a rendered selection");
}
const reference = document.createElement("span");
reference.style.background = cssVar("--background-text-selection");
reference.style.color = "var(--foreground-on-text-selection)";
document.body.appendChild(reference);
const selectionColor = getComputedStyle(selection).backgroundColor;
const referenceColor = getComputedStyle(reference).backgroundColor;
const content = container.querySelector<HTMLElement>(".cm-content");
if (content === null) {
throw new Error("Expected editor content");
}
const selectedTextColor = getComputedStyle(content, "::selection").color;
const referenceTextColor = getComputedStyle(reference).color;
reference.remove();
expect(selectionColor).toBe(referenceColor);
expect(selectedTextColor).toBe(referenceTextColor);
}
);
test("scopes disabled styles to the Webstudio code editor", () => {
act(() => {
root.render(
<fieldset disabled>
<EditorContent
value="disabled editor"
onChange={() => {}}
onChangeComplete={() => {}}
/>
<div className="cm-editor" data-foreign-editor />
</fieldset>
);
});
const editors = container.querySelectorAll<HTMLElement>(".cm-editor");
const foreignEditor = container.querySelector<HTMLElement>(
"[data-foreign-editor]"
);
if (editors[0] === undefined || foreignEditor === null) {
throw new Error("Expected both code editors");
}
expect(getComputedStyle(editors[0]).opacity).toBe("0.3");
expect(getComputedStyle(foreignEditor).opacity).toBe("1");
});