Files
webstudio/apps/builder/app/canvas/shared/styles.ts
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

913 lines
29 KiB
TypeScript

import { useLayoutEffect } from "react";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import {
type Instance,
ROOT_INSTANCE_ID,
getStyleDeclKey,
descendantComponent,
rootComponent,
type Breakpoint,
type StyleDecl,
type StyleSourceSelection,
createImageValueTransformer,
addFontRules,
} from "@webstudio-is/sdk";
import { inflatedAttribute, idAttribute } from "@webstudio-is/react-sdk";
import { INFLATE_PADDING } from "~/canvas/inflator";
import { isPseudoElement, parseMediaCondition } from "@webstudio-is/css-data";
import {
type StyleValue,
type MediaRuleOptions,
type StyleSheetRegular,
type TransformValue,
type VarValue,
createRegularStyleSheet,
hyphenateProperty,
toValue,
toVarFallback,
} from "@webstudio-is/css-engine";
import {
$registeredComponentMetas,
$selectedBreakpoint,
$selectedStyleState,
assetBaseUrl,
} from "~/shared/nano-states";
import { $assets } from "~/shared/sync/data-stores";
import {
$breakpoints,
$instances,
$props,
$styleSourceSelections,
$styles,
} from "~/shared/sync/data-stores";
import { setDifference } from "~/shared/shim";
import { $ephemeralStyles } from "../stores";
import { canvasApi } from "~/shared/canvas-api";
import {
$selectedInstance,
$selectedInstanceSelector,
$selectedPage,
} from "~/shared/nano-states";
import {
findAllNavigableTextInstanceSelectors,
type InstanceSelector,
} from "@webstudio-is/project-build/runtime";
import { getAllElementsByInstanceSelector } from "~/shared/dom-utils";
import { createComputedStyleDeclStore } from "~/builder/features/style-panel/shared/model";
import { cssVar, declareCssVar } from "@webstudio-is/design-system";
const userSheet = createRegularStyleSheet({ name: "user-styles" });
const stateSheet = createRegularStyleSheet({ name: "state-styles" });
const helpersSheet = createRegularStyleSheet({ name: "helpers" });
const fontsAndDefaultsSheet = createRegularStyleSheet({
name: "fonts-and-defaults",
});
const presetSheet = createRegularStyleSheet({ name: "preset-styles" });
/**
* maintain order of rendered stylesheets
* should be invoked before any subscription
*/
export const mountStyles = () => {
fontsAndDefaultsSheet.render();
presetSheet.render();
userSheet.render();
stateSheet.render();
helpersSheet.render();
};
export const editablePlaceholderAttribute = "data-ws-editable-placeholder";
// @todo replace with modern typed attr() when supported in all browsers
// see the second edge case
// https://developer.mozilla.org/en-US/docs/Web/CSS/attr#backwards_compatibility
export const editingPlaceholderVariable = declareCssVar(
"--editing-placeholder"
);
const helperStylesShared = [
// Display a placeholder text for elements that are editable but currently empty
`:is([${editablePlaceholderAttribute}]):empty::before {
content: attr(${editablePlaceholderAttribute});
opacity: 0.3;
}
`,
// Display a placeholder text for elements that are editing but empty (Lexical adds p>br children)
`:is([${editablePlaceholderAttribute}])[contenteditable] > p:only-child:has(br:only-child) {
position: relative;
display: block;
&:after {
content: ${cssVar(editingPlaceholderVariable)};
position: absolute;
left: 0;
right: 0;
top: 0;
/* Ensures placeholder text is visible even in narrow containers */
min-width: 100px;
opacity: 0.3;
}
}
`,
// Using :where allows to prevent increasing specificity, so that helper is overwritten by user styles.
`[${idAttribute}]:where([${inflatedAttribute}]:not(body)) {
outline: 1px dashed rgba(0,0,0,0.7);
outline-offset: -1px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7);
}`,
// Has no width, will collapse
// Ensure the TextEditor element doesn't uncollapse unintentionally:
// 1. Some TextEditor elements might appear empty for a few cycles after creation. (depends on existance of initial child)
// 2. If detected as empty, the collapsing algorithm could wrongly uncollapse them.
// 3. We prevent this by excluding elements with `data-lexical-editor`.
// 4. This rule is used here and not in collapsing detection because `data-lexical-editor` might not be set instantly.
// Mistakes in collapsing will be corrected in the next cycle.
// Grid containers use track-level minmax() inflation (applied inline by
// inflator.ts) instead of padding, so we skip padding for elements that
// have inline grid-template-columns or grid-template-rows (i.e. grids).
// Non-grid elements fall back to the default INFLATE_PADDING.
`[${idAttribute}]:where(:not(body):not([data-lexical-editor]):not([style*="grid-template"])[${inflatedAttribute}="w"]) {
padding-right: ${INFLATE_PADDING}px;
}`,
// Has no height, will collapse
`[${idAttribute}]:where(:not(body):not([data-lexical-editor]):not([style*="grid-template"])[${inflatedAttribute}="h"]) {
padding-top: ${INFLATE_PADDING}px;
}`,
// Has no width or height, will collapse
`[${idAttribute}]:where(:not(body):not([data-lexical-editor]):not([style*="grid-template"])[${inflatedAttribute}="wh"]) {
padding-right: ${INFLATE_PADDING}px;
padding-top: ${INFLATE_PADDING}px;
}`,
`[${idAttribute}][contenteditable], [${idAttribute}]:focus {
outline: 0;
}`,
`[${idAttribute}][contenteditable] {
box-shadow: 0 0 0 4px rgb(36 150 255 / 20%)
}`,
];
// Common user-select and cursor rules for canvas modes
const helperStylesUserSelect = [
// When double clicking into an element to edit text, it should not select the word.
`[${idAttribute}] {
user-select: none;
/* Safari */
-webkit-user-select: none;
cursor: default;
}`,
`[${idAttribute}][contenteditable] {
/* Safari */
cursor: initial;
}`,
];
// Helper styles on for canvas in design mode
// - Only instances that would collapse without helper should receive helper
// - Helper is removed when any CSS property is changed on that instance that would prevent collapsing, so that helper is not needed
// - Helper doesn't show on the preview or publish
// - Helper goes away if an instance inserted as a child
// - There is no need to set padding-right or padding-bottom if you just need a small div with a defined or layout-based size, as soon as div is not collapsing, helper should not apply
// - Padding will be only added on the side that would collapse otherwise
//
// For example when I add a div, it is a block element, it grows automatically full width but has 0 height, in this case spacing helper with padidng-top: 50px should apply, so that it doesn't collapse.
// If user sets `height: 100px` or does anything that would give it a height - we remove the helper padding right away, so user can actually see the height they set
//
// In other words we prevent elements from collapsing when they have 0 height or width by making them non-zero on canvas, but then we remove those paddings as soon as element doesn't collapse.
const helperStyles = [...helperStylesUserSelect, ...helperStylesShared];
// Find all editable elements and set cursor text inside
const helperStylesContentEdit = [
...helperStylesUserSelect,
...helperStylesShared,
];
const subscribeDesignModeHelperStyles = () => {
helpersSheet.setAttribute("media", "all");
for (const style of helperStyles) {
helpersSheet.addPlaintextRule(style);
}
helpersSheet.render();
return () => {
helpersSheet.clear();
helpersSheet.render();
};
};
/**
* Compute CSS rules for editable elements in content edit mode.
* Groups selectors into chunks to avoid overly long :is() selectors.
*/
const computeEditableCursorRules = (
editableInstanceSelectors: InstanceSelector[]
) => {
const rules: string[] = [];
// 20 is arbitrary but keeps selector length reasonable
const chunkSize = 20;
for (let i = 0; i < editableInstanceSelectors.length; i += chunkSize) {
const chunk = editableInstanceSelectors.slice(i, i + chunkSize);
const selectors = chunk.map(
(selector) => `[${idAttribute}="${selector[0]}"]`
);
rules.push(
`:is(${selectors.join(", ")}), :is(${selectors.join(
", "
)}) a { cursor: text; }`
);
}
return rules;
};
const subscribeContentEditModeHelperStyles = () => {
const renderHelperStyles = () => {
helpersSheet.clear();
helpersSheet.setAttribute("media", "all");
for (const style of helperStylesContentEdit) {
helpersSheet.addPlaintextRule(style);
}
// Show text cursor on all editable elements (including links and buttons)
// to indicate they are editable in the content editor mode
//
// @todo Consider setting cursor: pointer on non-editable elements by default
// to better distinguish clickable vs editable elements, needs more investigation
const rootInstanceId = $selectedPage.get()?.rootInstanceId;
if (rootInstanceId !== undefined) {
const instances = $instances.get();
const editableInstanceSelectors = findAllNavigableTextInstanceSelectors({
instanceSelector: [rootInstanceId],
instances,
props: $props.get(),
metas: $registeredComponentMetas.get(),
});
for (const rule of computeEditableCursorRules(
editableInstanceSelectors
)) {
helpersSheet.addPlaintextRule(rule);
}
}
helpersSheet.render();
};
renderHelperStyles();
const requestIdleCallbackFn =
globalThis.requestIdleCallback ?? requestAnimationFrame;
const cancelIdleCallbackFn =
globalThis.cancelIdleCallback ?? cancelAnimationFrame;
let idleId: number;
const renderHelperStylesIdle = () => {
cancelIdleCallbackFn(idleId);
idleId = requestIdleCallbackFn(renderHelperStyles);
};
const unsubscribeInstances = $instances.listen(renderHelperStylesIdle);
const unsubscribeSelectedPage = $selectedPage.listen(renderHelperStylesIdle);
return () => {
unsubscribeInstances();
unsubscribeSelectedPage();
helpersSheet.clear();
helpersSheet.render();
};
};
// keep stable transformValue in store
// to preserve cache in css engine
const $transformValue = computed($assets, (assets) =>
createImageValueTransformer(assets, {
assetBaseUrl,
})
);
const getEphemeralProperty = (
styleDecl: Pick<StyleDecl, "styleSourceId" | "state" | "property">
) => {
const { styleSourceId, state = "", property } = styleDecl;
return `--${styleSourceId}-${state}-${property}`;
};
// wrap normal style value with var(--namespace, value) to support ephemeral styles updates
// between all token usages
const toVarValue = (
styleDecl: StyleDecl,
transformValue: TransformValue,
fallback?: StyleValue
): VarValue => {
return {
type: "var",
// var style value is relying on name without leading "--"
// escape complex selectors in state like ":hover"
// setProperty and removeProperty escape automatically
value: CSS.escape(getEphemeralProperty(styleDecl).slice(2)),
fallback: fallback
? toVarFallback(fallback, transformValue)
: toVarFallback(styleDecl.value, transformValue),
};
};
const computeDescendantSelectors = <
P extends { instanceId: string; name: string; type: string; value?: unknown },
>(
instances: Map<Instance["id"], Instance>,
props: Map<string, P>
) => {
const parentIdByInstanceId = new Map<Instance["id"], Instance["id"]>();
const descendantInstanceIds: Instance["id"][] = [];
for (const instance of instances.values()) {
if (instance.component === descendantComponent) {
descendantInstanceIds.push(instance.id);
}
for (const child of instance.children) {
if (child.type === "id") {
parentIdByInstanceId.set(child.value, instance.id);
}
}
}
const descendantSelectorByInstanceId = new Map<Instance["id"], string>();
for (const prop of props.values()) {
if (prop.name === "selector" && prop.type === "string") {
descendantSelectorByInstanceId.set(prop.instanceId, prop.value as string);
}
}
const descendantSelectors = new Map<Instance["id"], string>();
for (const instanceId of descendantInstanceIds) {
const parentId = parentIdByInstanceId.get(instanceId);
const selector = descendantSelectorByInstanceId.get(instanceId);
if (parentId && selector) {
descendantSelectors.set(
instanceId,
`[${idAttribute}="${parentId}"]${selector}`
);
}
}
return descendantSelectors;
};
/**
* Convert StyleDecl to declaration params for sheet operations.
*/
const toDeclarationParams = (styleDecl: StyleDecl) => ({
breakpoint: styleDecl.breakpointId,
selector: styleDecl.state ?? "",
property: styleDecl.property,
});
/**
* Compute added and deleted styles by comparing current styles with previous.
* Returns new prevStylesSet to track for next diff.
*/
const computeStylesDiff = ({
styles,
transformValue,
prevStylesSet,
prevTransformValue,
}: {
styles: Map<string, StyleDecl>;
transformValue: TransformValue;
prevStylesSet: Set<StyleDecl>;
prevTransformValue: TransformValue | undefined;
}) => {
// invalidate styles cache when assets are changed
let effectivePrevStyles = prevStylesSet;
if (prevTransformValue !== transformValue) {
effectivePrevStyles = new Set();
}
const stylesSet = new Set(styles.values());
const addedStyles = setDifference(stylesSet, effectivePrevStyles);
const deletedStyles = setDifference(effectivePrevStyles, stylesSet);
return { addedStyles, deletedStyles, nextPrevStylesSet: stylesSet };
};
/**
* Convert instance ID to CSS selector.
* ROOT_INSTANCE_ID maps to ":root", others to attribute selector.
*/
const getInstanceSelector = (instanceId: string) =>
instanceId === ROOT_INSTANCE_ID
? ":root"
: `[${idAttribute}="${instanceId}"]`;
/**
* Convert component and tag to preset CSS selector.
* rootComponent maps to ":root", others use :where() for low specificity.
*/
const getPresetStyleSelector = (component: string, tag: string) =>
component === rootComponent
? ":root"
: `${tag}:where([data-ws-component="${component}"])`;
const $descendantSelectors = computed(
[$instances, $props],
computeDescendantSelectors
);
/**
* Transform breakpoint options to simulate condition-based breakpoints.
* When a condition breakpoint is selected (e.g. prefers-color-scheme:dark),
* breakpoints with the same feature+value render as base (always apply),
* same feature but different value render as "not all" (never apply),
* and all others are unchanged.
*/
const simulateConditionBreakpoints = ({
breakpoints,
selectedBreakpoint,
}: {
breakpoints: Iterable<Breakpoint>;
selectedBreakpoint: Breakpoint | undefined;
}): Map<string, MediaRuleOptions> => {
const result = new Map<string, MediaRuleOptions>();
const simulated = selectedBreakpoint?.condition
? parseMediaCondition(selectedBreakpoint.condition)
: undefined;
for (const breakpoint of breakpoints) {
if (simulated && breakpoint.condition) {
const parsed = parseMediaCondition(breakpoint.condition);
if (parsed?.feature === simulated.feature) {
const mediaType = parsed.value === simulated.value ? "all" : "not all";
result.set(breakpoint.id, { mediaType });
continue;
}
}
result.set(breakpoint.id, breakpoint);
}
return result;
};
const shouldRenderInBackgroundTask = (
visibilityState: DocumentVisibilityState | "prerender" | undefined
) => {
return visibilityState !== undefined && visibilityState !== "visible";
};
/**
* track new or deleted styles and style source selections items
* and update style sheet accordingly
*/
export const subscribeStyles = () => {
let renderJob:
| undefined
| {
type: "raf";
id: number;
}
| {
type: "timeout";
id: ReturnType<typeof setTimeout>;
};
const cancelScheduledRender = () => {
if (renderJob === undefined) {
return;
}
if (renderJob.type === "raf") {
cancelAnimationFrame(renderJob.id);
}
if (renderJob.type === "timeout") {
clearTimeout(renderJob.id);
}
renderJob = undefined;
};
const renderUserSheetInTheNextFrame = () => {
cancelScheduledRender();
const visibilityState =
typeof document === "undefined" ? undefined : document.visibilityState;
if (shouldRenderInBackgroundTask(visibilityState)) {
// Background tabs throttle or pause requestAnimationFrame, which would
// delay remote style updates until the window regains focus.
renderJob = {
type: "timeout",
id: setTimeout(() => {
renderJob = undefined;
userSheet.setTransformer($transformValue.get());
userSheet.render();
}, 50),
};
return;
}
renderJob = {
type: "raf",
id: requestAnimationFrame(() => {
renderJob = undefined;
userSheet.setTransformer($transformValue.get());
userSheet.render();
}),
};
};
const unsubscribeBreakpoints = computed(
[$breakpoints, $selectedBreakpoint],
(breakpoints, selectedBreakpoint) =>
[breakpoints, selectedBreakpoint] as const
).subscribe(([breakpoints, selectedBreakpoint]) => {
const mediaRules = simulateConditionBreakpoints({
breakpoints: breakpoints.values(),
selectedBreakpoint,
});
for (const [id, options] of mediaRules) {
userSheet.addMediaRule(id, options);
}
renderUserSheetInTheNextFrame();
});
const unsubscribeTransformValue = $transformValue.subscribe(() => {
renderUserSheetInTheNextFrame();
});
// add/delete declarations in mixins
let prevStylesSet = new Set<StyleDecl>();
let prevTransformValue: undefined | TransformValue;
// track value transformer to properly serialize var() fallback as unparsed
// before it was managed css engine but here toValue is invoked by styles renderer directly
const unsubscribeStyles = computed(
[$styles, $transformValue],
(styles, transformValue) => [styles, transformValue] as const
).subscribe(([styles, transformValue]) => {
const { addedStyles, deletedStyles, nextPrevStylesSet } = computeStylesDiff(
{
styles,
transformValue,
prevStylesSet,
prevTransformValue,
}
);
prevTransformValue = transformValue;
prevStylesSet = nextPrevStylesSet;
// delete before adding declarations by the same key
for (const styleDecl of deletedStyles) {
const rule = userSheet.addMixinRule(styleDecl.styleSourceId);
rule.deleteDeclaration(toDeclarationParams(styleDecl));
}
for (const styleDecl of addedStyles) {
const rule = userSheet.addMixinRule(styleDecl.styleSourceId);
rule.setDeclaration({
...toDeclarationParams(styleDecl),
value: toVarValue(styleDecl, transformValue),
});
}
renderUserSheetInTheNextFrame();
});
// apply mixins to nesting rules
let prevSelectionsSet = new Set<StyleSourceSelection>();
const unsubscribeStyleSourceSelections = $styleSourceSelections.subscribe(
(styleSourceSelections) => {
const selectionsSet = new Set(styleSourceSelections.values());
const addedSelections = setDifference(selectionsSet, prevSelectionsSet);
prevSelectionsSet = selectionsSet;
for (const { instanceId, values } of addedSelections) {
const rule = userSheet.addNestingRule(getInstanceSelector(instanceId));
rule.applyMixins(values);
}
renderUserSheetInTheNextFrame();
}
);
const unsubscribeDescendantSelectors = $descendantSelectors.subscribe(
(descendantSelectors) => {
let selectorsUpdated = false;
for (const [instanceId, descendantSelector] of descendantSelectors) {
// access descendant component rule
// and change its selector to parent id + selector prop
const key = `[${idAttribute}="${instanceId}"]`;
const rule = userSheet.addNestingRule(key);
// invalidate only when necessary
if (rule.getSelector() !== descendantSelector) {
selectorsUpdated = true;
rule.setSelector(descendantSelector);
}
}
if (selectorsUpdated) {
renderUserSheetInTheNextFrame();
}
}
);
return () => {
unsubscribeBreakpoints();
unsubscribeStyles();
unsubscribeStyleSourceSelections();
unsubscribeDescendantSelectors();
unsubscribeTransformValue();
cancelScheduledRender();
};
};
export const manageContentEditModeStyles = ({
signal,
}: {
signal: AbortSignal;
}) => {
const unsubscribePreviewMode = subscribeContentEditModeHelperStyles();
signal.addEventListener("abort", () => {
unsubscribePreviewMode();
});
};
export const manageDesignModeStyles = ({ signal }: { signal: AbortSignal }) => {
const unsubscribeStateStyles = subscribeStateStyles();
const unsubscribeEphemeralStyle = subscribeEphemeralStyle();
const unsubscribePreviewMode = subscribeDesignModeHelperStyles();
signal.addEventListener("abort", () => {
unsubscribeStateStyles();
unsubscribeEphemeralStyle();
unsubscribePreviewMode();
});
};
export const GlobalStyles = () => {
const assets = useStore($assets);
const metas = useStore($registeredComponentMetas);
useLayoutEffect(() => {
fontsAndDefaultsSheet.clear();
addFontRules({
sheet: fontsAndDefaultsSheet,
assets,
assetBaseUrl,
});
fontsAndDefaultsSheet.render();
}, [assets]);
useLayoutEffect(() => {
presetSheet.clear();
presetSheet.addMediaRule("presets");
for (const [component, meta] of metas) {
for (const [tag, styles] of Object.entries(meta.presetStyle ?? {})) {
const rule = presetSheet.addNestingRule(
getPresetStyleSelector(component, tag)
);
for (const declaration of styles) {
rule.setDeclaration({
breakpoint: "presets",
selector: declaration.state ?? "",
property: declaration.property,
value: declaration.value,
});
}
}
}
presetSheet.render();
}, [metas]);
return null;
};
const computeInstanceStyles = ({
selectedInstance,
selectedStyleState,
breakpoints,
styleSourceSelections,
styles,
}: {
selectedInstance: Instance | undefined;
selectedStyleState: string | undefined;
breakpoints: Map<string, Breakpoint>;
styleSourceSelections: Map<string, { values: string[] }>;
styles: Map<string, StyleDecl>;
}) => {
if (selectedInstance === undefined || selectedStyleState === undefined) {
return;
}
const styleSources = new Set(
styleSourceSelections.get(selectedInstance.id)?.values
);
const instanceStyles: StyleDecl[] = [];
for (const styleDecl of styles.values()) {
if (
styleDecl.state === selectedStyleState &&
styleSources.has(styleDecl.styleSourceId)
) {
instanceStyles.push(styleDecl);
}
}
return {
instanceId: selectedInstance.id,
selectedState: selectedStyleState,
breakpoints: Array.from(breakpoints.values()),
styles: instanceStyles,
};
};
const $instanceStyles = computed(
[
$selectedInstance,
$selectedStyleState,
$breakpoints,
$styleSourceSelections,
$styles,
],
(
selectedInstance,
selectedStyleState,
breakpoints,
styleSourceSelections,
styles
) =>
computeInstanceStyles({
selectedInstance,
selectedStyleState,
breakpoints,
styleSourceSelections,
styles,
})
);
/**
* Render state styles to a stylesheet.
*
* For pseudo-classes like :hover, render without state so users can preview
* the styles without triggering the state.
* For pseudo-elements like ::before, keep the selector so styles apply
* to the pseudo-element, not the parent.
*/
const renderStateStyles = ({
instanceStyles,
sheet,
transformValue,
toStyleValue,
}: {
instanceStyles: ReturnType<typeof $instanceStyles.get>;
sheet: StyleSheetRegular;
transformValue: TransformValue;
toStyleValue: (
styleDecl: StyleDecl,
transformValue: TransformValue
) => StyleValue;
}) => {
sheet.clear();
if (instanceStyles === undefined) {
sheet.render();
return;
}
const { instanceId, selectedState, breakpoints, styles } = instanceStyles;
for (const breakpoint of breakpoints) {
sheet.addMediaRule(breakpoint.id, breakpoint);
}
const selector = `[${idAttribute}="${instanceId}"]`;
const rule = sheet.addNestingRule(selector);
const stateSelector = isPseudoElement(selectedState) ? selectedState : "";
for (const styleDecl of styles) {
rule.setDeclaration({
breakpoint: styleDecl.breakpointId,
selector: stateSelector,
property: styleDecl.property,
value: toStyleValue(styleDecl, transformValue),
});
}
sheet.setTransformer(transformValue);
sheet.render();
};
/**
* render currently selected state styles as stateless
* in separate sheet and clear when state is not selected
*/
const subscribeStateStyles = () => {
return $instanceStyles.subscribe((instanceStyles) => {
renderStateStyles({
instanceStyles,
sheet: stateSheet,
transformValue: $transformValue.get(),
toStyleValue: (styleDecl, transformValue) =>
toVarValue(styleDecl, transformValue),
});
});
};
const subscribeEphemeralStyle = () => {
// track custom properties added on previous ephemeral update
const appliedEphemeralDeclarations = new Map<
string,
[StyleDecl, HTMLElement[]]
>();
return $ephemeralStyles.subscribe((ephemeralStyles) => {
const instance = $selectedInstance.get();
const instanceSelector = $selectedInstanceSelector.get();
if (instance === undefined || instanceSelector === undefined) {
return;
}
// reset ephemeral styles
if (ephemeralStyles.length === 0) {
canvasApi.resetInert();
for (const [
styleDecl,
elements,
] of appliedEphemeralDeclarations.values()) {
document.documentElement.style.removeProperty(
getEphemeralProperty(styleDecl)
);
for (const element of elements) {
element.style.removeProperty(getEphemeralProperty(styleDecl));
}
}
userSheet.setTransformer($transformValue.get());
userSheet.render();
appliedEphemeralDeclarations.clear();
}
// add ephemeral styles
if (ephemeralStyles.length > 0) {
canvasApi.setInert();
const selector = getInstanceSelector(instance.id);
const rule = userSheet.addNestingRule(selector);
let ephemeralSheetUpdated = false;
for (const styleDecl of ephemeralStyles) {
// update custom property
document.documentElement.style.setProperty(
getEphemeralProperty(styleDecl),
toValue(styleDecl.value, $transformValue.get())
);
// We need to apply the custom property to the selected element as well.
// Otherwise, variables defined on it will not be visible on documentElement.
const elements = getAllElementsByInstanceSelector(instanceSelector);
for (const element of elements) {
element.style.setProperty(
getEphemeralProperty(styleDecl),
toValue(styleDecl.value, $transformValue.get())
);
}
// Lazily add a rule to the user stylesheet (it might not be created yet if no styles have been added to the instance property).
const styleDeclKey = getStyleDeclKey(styleDecl);
if (appliedEphemeralDeclarations.has(styleDeclKey) === false) {
ephemeralSheetUpdated = true;
const mixinRule = userSheet.addMixinRule(styleDecl.styleSourceId);
// Use the actual style value as a fallback (non-ephemeral); see the "Lazy" comment above.
const computedStyleDecl = createComputedStyleDeclStore(
hyphenateProperty(styleDecl.property)
).get();
const value = toVarValue(
styleDecl,
$transformValue.get(),
computedStyleDecl.cascadedValue
);
mixinRule.setDeclaration({
...toDeclarationParams(styleDecl),
value,
});
rule.addMixin(styleDecl.styleSourceId);
// When editing state styles (e.g., :hover), also render var() in stateSheet
// so ephemeral updates are visible in the state preview
if (styleDecl.state !== undefined) {
const stateRule = stateSheet.addNestingRule(selector);
// For pseudo-elements (::before, ::after), keep the selector so styles
// apply to the pseudo-element. For pseudo-classes (:hover, :focus),
// render without state so users can preview without triggering the state.
const stateSelector = isPseudoElement(styleDecl.state)
? styleDecl.state
: "";
stateRule.setDeclaration({
breakpoint: styleDecl.breakpointId,
selector: stateSelector,
property: styleDecl.property,
value,
});
}
}
appliedEphemeralDeclarations.set(styleDeclKey, [styleDecl, elements]);
}
// avoid stylesheet rerendering on every ephemeral update
if (ephemeralSheetUpdated) {
userSheet.render();
stateSheet.render();
}
}
});
};
export const __testing__ = {
getEphemeralProperty,
getInstanceSelector,
getPresetStyleSelector,
computeDescendantSelectors,
computeInstanceStyles,
computeEditableCursorRules,
computeStylesDiff,
toDeclarationParams,
toVarValue,
renderStateStyles,
simulateConditionBreakpoints,
shouldRenderInBackgroundTask,
};