mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-02 18:18:35 +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
1816 lines
50 KiB
TypeScript
1816 lines
50 KiB
TypeScript
import { describe, test, expect } from "vitest";
|
|
import { parseCssValue, isValidDeclaration } from "./parse-css-value";
|
|
import {
|
|
toValue,
|
|
type CssProperty,
|
|
type ColorValue,
|
|
} from "@webstudio-is/css-engine";
|
|
|
|
describe("Parse CSS value", () => {
|
|
describe("number value", () => {
|
|
test("unitless", () => {
|
|
expect(parseCssValue("line-height", "10")).toEqual({
|
|
type: "unit",
|
|
unit: "number",
|
|
value: 10,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("unit value", () => {
|
|
test("with unit", () => {
|
|
expect(parseCssValue("width", "10px")).toEqual({
|
|
type: "unit",
|
|
unit: "px",
|
|
value: 10,
|
|
});
|
|
});
|
|
|
|
test("empty input", () => {
|
|
expect(parseCssValue("width", "")).toEqual({
|
|
type: "invalid",
|
|
value: "",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("keyword value", () => {
|
|
test("keyword", () => {
|
|
expect(parseCssValue("width", "auto")).toEqual({
|
|
type: "keyword",
|
|
value: "auto",
|
|
});
|
|
});
|
|
|
|
test("keyword display block", () => {
|
|
expect(parseCssValue("display", "block")).toEqual({
|
|
type: "keyword",
|
|
value: "block",
|
|
});
|
|
});
|
|
|
|
test("keyword with unit", () => {
|
|
expect(parseCssValue("width", "autopx")).toEqual({
|
|
type: "invalid",
|
|
value: "autopx",
|
|
});
|
|
});
|
|
|
|
test("invalid", () => {
|
|
// This will return px as a fallback unit, as number is not valid for width.
|
|
expect(parseCssValue("width", "10")).toEqual({
|
|
type: "invalid",
|
|
value: "10",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Unparesd valid values", () => {
|
|
test("Simple valid function values", () => {
|
|
expect(parseCssValue("width", "calc(4px + 16em)")).toEqual({
|
|
type: "unparsed",
|
|
value: "calc(4px + 16em)",
|
|
});
|
|
});
|
|
|
|
test("Nested CSS math values", () => {
|
|
const value =
|
|
"clamp(1rem, calc(1rem + (2rem - 1rem) * ((100vw - 20rem) / (80rem - 20rem))), 2rem)";
|
|
|
|
expect(parseCssValue("font-size", value)).toEqual({
|
|
type: "unparsed",
|
|
value,
|
|
});
|
|
});
|
|
|
|
test("CSS numeric functions", () => {
|
|
expect(parseCssValue("width", "round(10px, 1px)")).toEqual({
|
|
type: "unparsed",
|
|
value: "round(10px, 1px)",
|
|
});
|
|
expect(parseCssValue("width", "abs(-10px)")).toEqual({
|
|
type: "unparsed",
|
|
value: "abs(-10px)",
|
|
});
|
|
expect(parseCssValue("width", "calc(pi * 1px)")).toEqual({
|
|
type: "unparsed",
|
|
value: "calc(pi * 1px)",
|
|
});
|
|
});
|
|
|
|
test("Invalid function values", () => {
|
|
expect(parseCssValue("width", "blur(4)")).toEqual({
|
|
type: "invalid",
|
|
value: "blur(4)",
|
|
});
|
|
expect(parseCssValue("font-size", "clamp(foo)")).toEqual({
|
|
type: "invalid",
|
|
value: "clamp(foo)",
|
|
});
|
|
expect(parseCssValue("color", "abs(-10px)")).toEqual({
|
|
type: "invalid",
|
|
value: "abs(-10px)",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Tuples", () => {
|
|
test("objectPosition", () => {
|
|
expect(parseCssValue("object-position", "left top")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "keyword",
|
|
value: "left",
|
|
},
|
|
{
|
|
type: "keyword",
|
|
value: "top",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Colors", () => {
|
|
test("Color rgba values", () => {
|
|
expect(parseCssValue("background-color", "rgba(0,0,0,0)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0,
|
|
components: [0, 0, 0],
|
|
});
|
|
});
|
|
|
|
test("modern format", () => {
|
|
expect(parseCssValue("background-color", "rgb(99 102 241/0.5)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0.5,
|
|
components: [0.3882, 0.4, 0.9451],
|
|
});
|
|
});
|
|
|
|
test("Color rgba values", () => {
|
|
expect(parseCssValue("background-color", "#00220011")).toEqual({
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 0.0667,
|
|
components: [0, 0.1333, 0],
|
|
});
|
|
});
|
|
|
|
test("Color rgba values", () => {
|
|
expect(parseCssValue("color", "red")).toEqual({
|
|
type: "keyword",
|
|
value: "red",
|
|
});
|
|
});
|
|
|
|
test("oklch color", () => {
|
|
expect(
|
|
parseCssValue("background-color", "oklch(59.686% 0.1009 29.234)")
|
|
).toEqual({
|
|
type: "color",
|
|
colorSpace: "oklch",
|
|
alpha: 1,
|
|
components: [0.5969, 0.1009, 29.234],
|
|
});
|
|
});
|
|
|
|
test("oklch color with alpha", () => {
|
|
expect(
|
|
parseCssValue("background-color", "oklch(59.686% 0.1009 29.234 / 0.5)")
|
|
).toEqual({
|
|
type: "color",
|
|
colorSpace: "oklch",
|
|
alpha: 0.5,
|
|
components: [0.5969, 0.1009, 29.234],
|
|
});
|
|
});
|
|
|
|
test("oklch color with modern format", () => {
|
|
expect(parseCssValue("color", "oklch(80% 0.15 240)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "oklch",
|
|
alpha: 1,
|
|
components: [0.8, 0.15, 240],
|
|
});
|
|
});
|
|
|
|
test("hsl color", () => {
|
|
expect(parseCssValue("color", "hsl(120 100% 50%)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "hsl",
|
|
alpha: 1,
|
|
components: [120, 100, 50],
|
|
});
|
|
});
|
|
|
|
test("hwb color", () => {
|
|
expect(parseCssValue("color", "hwb(120 0% 0%)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "hwb",
|
|
alpha: 1,
|
|
components: [120, 0, 0],
|
|
});
|
|
});
|
|
|
|
test("lab color", () => {
|
|
expect(parseCssValue("color", "lab(50 20 30)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "lab",
|
|
alpha: 1,
|
|
components: [50, 20, 30],
|
|
});
|
|
});
|
|
|
|
test("lch color", () => {
|
|
expect(parseCssValue("color", "lch(50 40 120)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "lch",
|
|
alpha: 1,
|
|
components: [50, 40, 120],
|
|
});
|
|
});
|
|
|
|
test("oklab color", () => {
|
|
expect(parseCssValue("color", "oklab(0.7 0.1 -0.1)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "oklab",
|
|
alpha: 1,
|
|
components: [0.7, 0.1, -0.1],
|
|
});
|
|
});
|
|
|
|
test("srgb-linear color", () => {
|
|
expect(parseCssValue("color", "color(srgb-linear 1 0 0)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "srgb-linear",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
});
|
|
});
|
|
|
|
test("display-p3 color", () => {
|
|
expect(parseCssValue("color", "color(display-p3 0.4 0.6 0.3)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "p3",
|
|
alpha: 1,
|
|
components: [0.4, 0.6, 0.3],
|
|
});
|
|
});
|
|
|
|
test("a98-rgb color", () => {
|
|
expect(parseCssValue("color", "color(a98-rgb 0.4 0.6 0.3)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "a98rgb",
|
|
alpha: 1,
|
|
components: [0.4, 0.6, 0.3],
|
|
});
|
|
});
|
|
|
|
test("prophoto-rgb color", () => {
|
|
expect(parseCssValue("color", "color(prophoto-rgb 0.4 0.6 0.3)")).toEqual(
|
|
{
|
|
type: "color",
|
|
colorSpace: "prophoto",
|
|
alpha: 1,
|
|
components: [0.4, 0.6, 0.3],
|
|
}
|
|
);
|
|
});
|
|
|
|
test("rec2020 color", () => {
|
|
expect(parseCssValue("color", "color(rec2020 0.4 0.6 0.3)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "rec2020",
|
|
alpha: 1,
|
|
components: [0.4, 0.6, 0.3],
|
|
});
|
|
});
|
|
|
|
test("xyz-d65 color", () => {
|
|
expect(parseCssValue("color", "color(xyz-d65 0.4 0.6 0.3)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "xyz-d65",
|
|
alpha: 1,
|
|
components: [0.4, 0.6, 0.3],
|
|
});
|
|
});
|
|
|
|
test("xyz-d50 color", () => {
|
|
expect(parseCssValue("color", "color(xyz-d50 0.4 0.6 0.3)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "xyz-d50",
|
|
alpha: 1,
|
|
components: [0.4, 0.6, 0.3],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("CSS variable in alpha channel", () => {
|
|
// hex is excluded: #RRGGBB syntax has no / alpha slot in CSS.
|
|
// Using satisfies Record<Exclude<...>, unknown> ensures TypeScript errors
|
|
// when a new color space is added to ColorValue without a test entry here.
|
|
const cases = {
|
|
srgb: {
|
|
css: "rgb(255 0 0 / var(--opacity))",
|
|
components: [1, 0, 0],
|
|
},
|
|
hsl: {
|
|
css: "hsl(120 100% 50% / var(--opacity))",
|
|
components: [120, 100, 50],
|
|
},
|
|
hwb: {
|
|
css: "hwb(120 0% 0% / var(--opacity))",
|
|
components: [120, 0, 0],
|
|
},
|
|
lab: {
|
|
css: "lab(50 20 30 / var(--opacity))",
|
|
components: [50, 20, 30],
|
|
},
|
|
lch: {
|
|
css: "lch(50 40 120 / var(--opacity))",
|
|
components: [50, 40, 120],
|
|
},
|
|
oklab: {
|
|
css: "oklab(0.7 0.1 -0.1 / var(--opacity))",
|
|
components: [0.7, 0.1, -0.1],
|
|
},
|
|
oklch: {
|
|
css: "oklch(0.5 0.1 180 / var(--opacity))",
|
|
components: [0.5, 0.1, 180],
|
|
},
|
|
p3: {
|
|
css: "color(display-p3 0.4 0.6 0.3 / var(--opacity))",
|
|
components: [0.4, 0.6, 0.3],
|
|
},
|
|
"srgb-linear": {
|
|
css: "color(srgb-linear 1 0 0 / var(--opacity))",
|
|
components: [1, 0, 0],
|
|
},
|
|
a98rgb: {
|
|
css: "color(a98-rgb 0.5 0.3 0.7 / var(--opacity))",
|
|
components: [0.5, 0.3, 0.7],
|
|
},
|
|
prophoto: {
|
|
css: "color(prophoto-rgb 0.6 0.4 0.2 / var(--opacity))",
|
|
components: [0.6, 0.4, 0.2],
|
|
},
|
|
rec2020: {
|
|
css: "color(rec2020 0.4 0.6 0.3 / var(--opacity))",
|
|
components: [0.4, 0.6, 0.3],
|
|
},
|
|
"xyz-d65": {
|
|
css: "color(xyz-d65 0.5 0.3 0.2 / var(--opacity))",
|
|
components: [0.5, 0.3, 0.2],
|
|
},
|
|
"xyz-d50": {
|
|
css: "color(xyz-d50 0.4 0.6 0.3 / var(--opacity))",
|
|
components: [0.4, 0.6, 0.3],
|
|
},
|
|
} satisfies Record<Exclude<ColorValue["colorSpace"], "hex">, unknown>;
|
|
|
|
for (const [colorSpace, { css, components }] of Object.entries(cases)) {
|
|
test(colorSpace, () => {
|
|
expect(parseCssValue("color", css)).toEqual({
|
|
type: "color",
|
|
colorSpace,
|
|
components,
|
|
alpha: {
|
|
type: "var",
|
|
value: "opacity",
|
|
fallback: { type: "unit", unit: "number", value: 1 },
|
|
},
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
test("preserve explicit CSS fallback in var alpha channel", () => {
|
|
expect(
|
|
parseCssValue("color", "rgb(24 24 27 / var(--tw-bg-opacity, 0.5))")
|
|
).toEqual({
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
components: [0.0941, 0.0941, 0.1059],
|
|
alpha: {
|
|
type: "var",
|
|
value: "tw-bg-opacity",
|
|
fallback: { type: "unparsed", value: "0.5" },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
test("parse color-mix() as unparsed value", () => {
|
|
expect(parseCssValue("color", "color-mix(in oklch, red 50%, blue)")).toEqual({
|
|
type: "unparsed",
|
|
value: "color-mix(in oklch, red 50%, blue)",
|
|
});
|
|
});
|
|
|
|
test("parse color-mix() on background-color as unparsed value", () => {
|
|
expect(
|
|
parseCssValue(
|
|
"background-color",
|
|
"color-mix(in srgb, #ff0000 30%, transparent)"
|
|
)
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "color-mix(in srgb, #ff0000 30%, transparent)",
|
|
});
|
|
});
|
|
|
|
test("parse color-mix() with var() as first color argument", () => {
|
|
expect(
|
|
parseCssValue("color", "color-mix(in oklch, var(--primary), blue)")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "color-mix(in oklch, var(--primary), blue)",
|
|
});
|
|
});
|
|
|
|
test("parse color-mix() with var() as second color argument", () => {
|
|
expect(
|
|
parseCssValue("color", "color-mix(in oklch, red, var(--secondary))")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "color-mix(in oklch, red, var(--secondary))",
|
|
});
|
|
});
|
|
|
|
test("parse color-mix() with var() as percentage", () => {
|
|
expect(
|
|
parseCssValue("color", "color-mix(in srgb, red var(--pct), blue)")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "color-mix(in srgb, red var(--pct), blue)",
|
|
});
|
|
});
|
|
|
|
describe("relative color syntax", () => {
|
|
test("static relative color (no var)", () => {
|
|
expect(parseCssValue("color", "rgb(from blue r g b / 50%)")).toEqual({
|
|
type: "unparsed",
|
|
value: "rgb(from blue r g b / 50%)",
|
|
});
|
|
});
|
|
|
|
test("var() as origin color", () => {
|
|
expect(
|
|
parseCssValue("color", "rgb(from var(--brand-primary) r g b / 25%)")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "rgb(from var(--brand-primary) r g b / 25%)",
|
|
});
|
|
});
|
|
|
|
test("var() as channel value", () => {
|
|
expect(parseCssValue("color", "oklch(from red l var(--chroma) h)")).toEqual(
|
|
{
|
|
type: "unparsed",
|
|
value: "oklch(from red l var(--chroma) h)",
|
|
}
|
|
);
|
|
});
|
|
|
|
test("var() as alpha", () => {
|
|
expect(
|
|
parseCssValue("color", "rgb(from red r g b / var(--alpha))")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "rgb(from red r g b / var(--alpha))",
|
|
});
|
|
});
|
|
|
|
test("hsl relative with var() as origin", () => {
|
|
expect(parseCssValue("color", "hsl(from var(--brand) h s 75%)")).toEqual({
|
|
type: "unparsed",
|
|
value: "hsl(from var(--brand) h s 75%)",
|
|
});
|
|
});
|
|
|
|
test("oklch relative on background-color", () => {
|
|
expect(
|
|
parseCssValue("background-color", "oklch(from var(--brand) l c h / 0.5)")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "oklch(from var(--brand) l c h / 0.5)",
|
|
});
|
|
});
|
|
|
|
test("var() in both origin and alpha", () => {
|
|
expect(
|
|
parseCssValue("color", "rgb(from var(--brand) r g b / var(--alpha))")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "rgb(from var(--brand) r g b / var(--alpha))",
|
|
});
|
|
});
|
|
});
|
|
|
|
test("parse background-image property as layers", () => {
|
|
expect(
|
|
parseCssValue(
|
|
"background-image",
|
|
`linear-gradient(180deg, hsla(0, 0.00%, 0.00%, 0.11), white), url("https://667d0b7769e0cc3754b584f6"), none, url("https://667d0fe180995eadc1534a26")`
|
|
)
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "unparsed",
|
|
value: "linear-gradient(180deg,hsla(0,0.00%,0.00%,0.11),white)",
|
|
},
|
|
{
|
|
type: "image",
|
|
value: { type: "url", url: "https://667d0b7769e0cc3754b584f6" },
|
|
},
|
|
{
|
|
type: "keyword",
|
|
value: "none",
|
|
},
|
|
{
|
|
type: "image",
|
|
value: { type: "url", url: "https://667d0fe180995eadc1534a26" },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parse background-position-* properties as layers", () => {
|
|
expect(
|
|
parseCssValue("background-position-x", `0px, 550px, 0px, 0px`)
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", unit: "px", value: 0 },
|
|
{ type: "unit", unit: "px", value: 550 },
|
|
{ type: "unit", unit: "px", value: 0 },
|
|
{ type: "unit", unit: "px", value: 0 },
|
|
],
|
|
});
|
|
expect(parseCssValue("background-position-y", `0px, 0px, 0px, 0px`)).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", unit: "px", value: 0 },
|
|
{ type: "unit", unit: "px", value: 0 },
|
|
{ type: "unit", unit: "px", value: 0 },
|
|
{ type: "unit", unit: "px", value: 0 },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parse background-size property as layers", () => {
|
|
expect(parseCssValue("background-size", `auto, contain, auto, auto`)).toEqual(
|
|
{
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "auto" },
|
|
{ type: "keyword", value: "contain" },
|
|
{ type: "keyword", value: "auto" },
|
|
{ type: "keyword", value: "auto" },
|
|
],
|
|
}
|
|
);
|
|
expect(
|
|
parseCssValue("background-repeat", `repeat, no-repeat, repeat, repeat`)
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "repeat" },
|
|
{ type: "keyword", value: "no-repeat" },
|
|
{ type: "keyword", value: "repeat" },
|
|
{ type: "keyword", value: "repeat" },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parse background-attachment property as layers", () => {
|
|
expect(
|
|
parseCssValue("background-attachment", `scroll, fixed, scroll, scroll`)
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "scroll" },
|
|
{ type: "keyword", value: "fixed" },
|
|
{ type: "keyword", value: "scroll" },
|
|
{ type: "keyword", value: "scroll" },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parse repeated value with css wide keywords", () => {
|
|
expect(parseCssValue("background-attachment", "initial")).toEqual({
|
|
type: "keyword",
|
|
value: "initial",
|
|
});
|
|
expect(parseCssValue("background-attachment", "INHERIT")).toEqual({
|
|
type: "keyword",
|
|
value: "inherit",
|
|
});
|
|
expect(parseCssValue("background-attachment", "unset")).toEqual({
|
|
type: "keyword",
|
|
value: "unset",
|
|
});
|
|
expect(parseCssValue("background-attachment", "revert")).toEqual({
|
|
type: "keyword",
|
|
value: "revert",
|
|
});
|
|
expect(parseCssValue("background-attachment", "revert-layer")).toEqual({
|
|
type: "keyword",
|
|
value: "revert-layer",
|
|
});
|
|
});
|
|
|
|
test("parse transition-property property", () => {
|
|
expect(parseCssValue("transition-property", "none")).toEqual({
|
|
type: "keyword",
|
|
value: "none",
|
|
});
|
|
expect(parseCssValue("transition-property", "opacity, width, all")).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unparsed", value: "opacity" },
|
|
{ type: "unparsed", value: "width" },
|
|
{ type: "keyword", value: "all" },
|
|
],
|
|
});
|
|
expect(
|
|
parseCssValue("transition-property", "opacity, none, unknown")
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unparsed", value: "opacity" },
|
|
{ type: "unparsed", value: "none" },
|
|
{ type: "unparsed", value: "unknown" },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parse transition-duration property", () => {
|
|
expect(parseCssValue("transition-duration", `10ms, 10ms`)).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", unit: "ms", value: 10 },
|
|
{ type: "unit", unit: "ms", value: 10 },
|
|
],
|
|
});
|
|
expect(parseCssValue("transition-duration", `10ms, foo`)).toEqual({
|
|
type: "invalid",
|
|
value: "10ms, foo",
|
|
});
|
|
});
|
|
|
|
test("parse transition-timing-function property", () => {
|
|
const parsedValue = parseCssValue(
|
|
"transition-timing-function",
|
|
"ease, ease-in, cubic-bezier(0.68,-0.6,.32,1.6), steps(4, jump-start)"
|
|
);
|
|
expect(parsedValue).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "ease" },
|
|
{ type: "keyword", value: "ease-in" },
|
|
{
|
|
type: "function",
|
|
name: "cubic-bezier",
|
|
args: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", value: 0.68, unit: "number" },
|
|
{ type: "unit", value: -0.6, unit: "number" },
|
|
{ type: "unit", value: 0.32, unit: "number" },
|
|
{ type: "unit", value: 1.6, unit: "number" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
name: "steps",
|
|
args: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", value: 4, unit: "number" },
|
|
{ type: "keyword", value: "jump-start" },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(toValue(parsedValue)).toMatchInlineSnapshot(
|
|
`"ease, ease-in, cubic-bezier(0.68, -0.6, 0.32, 1.6), steps(4, jump-start)"`
|
|
);
|
|
expect(parseCssValue("transition-timing-function", "ease, testing")).toEqual({
|
|
type: "invalid",
|
|
value: "ease, testing",
|
|
});
|
|
expect(
|
|
parseCssValue("transition-timing-function", "linear(0 0%, 1 100%)")
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [{ type: "unparsed", value: "linear(0 0%,1 100%)" }],
|
|
});
|
|
});
|
|
|
|
test("parse transition-behavior property as layers", () => {
|
|
expect(parseCssValue("transition-behavior", `normal`)).toEqual({
|
|
type: "layers",
|
|
value: [{ type: "keyword", value: "normal" }],
|
|
});
|
|
expect(
|
|
parseCssValue("transition-behavior", `NORMAL, allow-discrete`)
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "normal" },
|
|
{ type: "keyword", value: "allow-discrete" },
|
|
],
|
|
});
|
|
expect(parseCssValue("transition-behavior", `normal, invalid`)).toEqual({
|
|
type: "invalid",
|
|
value: "normal, invalid",
|
|
});
|
|
});
|
|
|
|
test("parse properties unknown to css-tree", () => {
|
|
expect(parseCssValue("future-property" as CssProperty, "auto")).toEqual({
|
|
type: "unparsed",
|
|
value: "auto",
|
|
});
|
|
expect(
|
|
parseCssValue("animation-timing-function", "linear(0 0%, 1 100%)")
|
|
).toEqual({ type: "unparsed", value: "linear(0 0%, 1 100%)" });
|
|
});
|
|
|
|
test("parse anchor positioning functions as unparsed values", () => {
|
|
for (const [property, value] of [
|
|
["width", "fit-content(10px)"],
|
|
["top", "calc(10px + 5%)"],
|
|
["align-items", "safe center"],
|
|
["justify-items", "legacy right"],
|
|
["position-anchor", "auto"],
|
|
] as const) {
|
|
expect(isValidDeclaration(property, value), `${property}: ${value}`).toBe(
|
|
true
|
|
);
|
|
}
|
|
|
|
expect(parseCssValue("top", "anchor(--trigger bottom, 1rem)")).toEqual({
|
|
type: "unparsed",
|
|
value: "anchor(--trigger bottom, 1rem)",
|
|
});
|
|
expect(parseCssValue("width", "anchor-size(--trigger inline, 50%)")).toEqual({
|
|
type: "unparsed",
|
|
value: "anchor-size(--trigger inline, 50%)",
|
|
});
|
|
expect(
|
|
parseCssValue("inset-block-start", "calc(anchor(--trigger top) + 1rem)")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "calc(anchor(--trigger top) + 1rem)",
|
|
});
|
|
expect(
|
|
parseCssValue("margin-inline-start", "anchor-size(--trigger width)")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "anchor-size(--trigger width)",
|
|
});
|
|
expect(
|
|
parseCssValue("inline-size", "anchor-size(--trigger self-inline)")
|
|
).toEqual({
|
|
type: "unparsed",
|
|
value: "anchor-size(--trigger self-inline)",
|
|
});
|
|
expect(parseCssValue("top", "anchor(trigger bottom)")).toEqual({
|
|
type: "invalid",
|
|
value: "anchor(trigger bottom)",
|
|
});
|
|
});
|
|
|
|
test("parse current position-anchor keywords", () => {
|
|
expect(parseCssValue("position-anchor", "normal")).toEqual({
|
|
type: "keyword",
|
|
value: "normal",
|
|
});
|
|
expect(parseCssValue("position-anchor", "match-parent")).toEqual({
|
|
type: "keyword",
|
|
value: "match-parent",
|
|
});
|
|
});
|
|
|
|
test("parse anchor-center alignment", () => {
|
|
for (const property of [
|
|
"align-items",
|
|
"align-self",
|
|
"justify-items",
|
|
"justify-self",
|
|
] as const) {
|
|
expect(parseCssValue(property, "anchor-center"), property).toEqual({
|
|
type: "keyword",
|
|
value: "anchor-center",
|
|
});
|
|
}
|
|
});
|
|
|
|
test("parse transform property as tuple", () => {
|
|
expect(
|
|
parseCssValue("transform", "rotateX(45deg) rotateY(30deg) rotateZ(60deg)")
|
|
).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "rotateX",
|
|
args: {
|
|
type: "layers",
|
|
value: [{ type: "unit", value: 45, unit: "deg" }],
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
name: "rotateY",
|
|
args: {
|
|
type: "layers",
|
|
value: [{ type: "unit", value: 30, unit: "deg" }],
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
name: "rotateZ",
|
|
args: {
|
|
type: "layers",
|
|
value: [{ type: "unit", value: 60, unit: "deg" }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(parseCssValue("transform", "skew(30deg, 20deg)")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "skew",
|
|
args: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", value: 30, unit: "deg" },
|
|
{ type: "unit", value: 20, unit: "deg" },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(
|
|
parseCssValue("transform", "translate3d(-100px, 50px, -150px)")
|
|
).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "translate3d",
|
|
args: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", value: -100, unit: "px" },
|
|
{ type: "unit", value: 50, unit: "px" },
|
|
{ type: "unit", value: -150, unit: "px" },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parses transform values and returns invalid for invalid values", () => {
|
|
expect(parseCssValue("transform", "scale(1.5, 50px)")).toEqual({
|
|
type: "invalid",
|
|
value: "scale(1.5, 50px)",
|
|
});
|
|
|
|
expect(parseCssValue("transform", "matrix(1, 0.5, -0.5, 1, 100)")).toEqual({
|
|
type: "invalid",
|
|
value: "matrix(1, 0.5, -0.5, 1, 100)",
|
|
});
|
|
});
|
|
|
|
test("parses a valid translate value", () => {
|
|
expect(parseCssValue("translate", "100px")).toEqual({
|
|
type: "tuple",
|
|
value: [{ type: "unit", unit: "px", value: 100 }],
|
|
});
|
|
expect(parseCssValue("translate", "100px 200px")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "unit", unit: "px", value: 100 },
|
|
{ type: "unit", unit: "px", value: 200 },
|
|
],
|
|
});
|
|
expect(parseCssValue("translate", "10em 10em 10em")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "unit", unit: "em", value: 10 },
|
|
{ type: "unit", unit: "em", value: 10 },
|
|
{ type: "unit", unit: "em", value: 10 },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parses and returns invalid for invalid translate values", () => {
|
|
expect(parseCssValue("translate", "foo bar")).toEqual({
|
|
type: "invalid",
|
|
value: "foo bar",
|
|
});
|
|
expect(parseCssValue("translate", "100px 200px 300px 400px")).toEqual({
|
|
type: "invalid",
|
|
value: "100px 200px 300px 400px",
|
|
});
|
|
expect(parseCssValue("translate", "100%, 200%")).toEqual({
|
|
type: "invalid",
|
|
value: "100%, 200%",
|
|
});
|
|
});
|
|
|
|
test("parses a valid scale value", () => {
|
|
expect(parseCssValue("scale", "1.5")).toEqual({
|
|
type: "tuple",
|
|
value: [{ type: "unit", value: 1.5, unit: "number" }],
|
|
});
|
|
expect(parseCssValue("scale", "5 10 15")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "unit", value: 5, unit: "number" },
|
|
{ type: "unit", value: 10, unit: "number" },
|
|
{ type: "unit", value: 15, unit: "number" },
|
|
],
|
|
});
|
|
expect(parseCssValue("scale", "50%")).toEqual({
|
|
type: "tuple",
|
|
value: [{ type: "unit", value: 50, unit: "%" }],
|
|
});
|
|
});
|
|
|
|
test("throws error for invalid scale proeprty values", () => {
|
|
expect(parseCssValue("scale", "10 foo")).toEqual({
|
|
type: "invalid",
|
|
value: "10 foo",
|
|
});
|
|
expect(parseCssValue("scale", "5 10 15 20")).toEqual({
|
|
type: "invalid",
|
|
value: "5 10 15 20",
|
|
});
|
|
expect(parseCssValue("scale", "5, 15")).toEqual({
|
|
type: "invalid",
|
|
value: "5, 15",
|
|
});
|
|
expect(parseCssValue("scale", "5px")).toEqual({
|
|
type: "invalid",
|
|
value: "5px",
|
|
});
|
|
});
|
|
|
|
test("support custom properties as unparsed values", () => {
|
|
expect(parseCssValue("--my-property", "blue")).toEqual({
|
|
type: "unparsed",
|
|
value: "blue",
|
|
});
|
|
expect(parseCssValue("--my-property", "url(https://my-image.com)")).toEqual({
|
|
type: "unparsed",
|
|
value: "url(https://my-image.com)",
|
|
});
|
|
expect(parseCssValue("--my-property", "blue red")).toEqual({
|
|
type: "unparsed",
|
|
value: "blue red",
|
|
});
|
|
expect(parseCssValue("--my-property", "blue, red")).toEqual({
|
|
type: "unparsed",
|
|
value: "blue, red",
|
|
});
|
|
});
|
|
|
|
test("support custom properties var reference", () => {
|
|
expect(parseCssValue("color", "var(--color)")).toEqual({
|
|
type: "var",
|
|
value: "color",
|
|
});
|
|
expect(parseCssValue("color", "var(--color, red)")).toEqual({
|
|
type: "var",
|
|
value: "color",
|
|
fallback: { type: "unparsed", value: "red" },
|
|
});
|
|
});
|
|
|
|
test("support unit in custom property", () => {
|
|
expect(parseCssValue("--size", "10")).toEqual({
|
|
type: "unit",
|
|
value: 10,
|
|
unit: "number",
|
|
});
|
|
expect(parseCssValue("--size", "10px")).toEqual({
|
|
type: "unit",
|
|
value: 10,
|
|
unit: "px",
|
|
});
|
|
expect(parseCssValue("--size", "10%")).toEqual({
|
|
type: "unit",
|
|
value: 10,
|
|
unit: "%",
|
|
});
|
|
});
|
|
|
|
test("support color in custom property", () => {
|
|
expect(parseCssValue("--color", "rgb(61 77 4)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 1,
|
|
components: [0.2392, 0.302, 0.0157],
|
|
});
|
|
expect(parseCssValue("--color", "rgba(61, 77, 4, 0.5)")).toEqual({
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0.5,
|
|
components: [0.2392, 0.302, 0.0157],
|
|
});
|
|
expect(parseCssValue("--color", "#3d4d04")).toEqual({
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [0.2392, 0.302, 0.0157],
|
|
});
|
|
expect(parseCssValue("--color", "red")).toEqual({
|
|
type: "unparsed",
|
|
value: "red",
|
|
});
|
|
});
|
|
|
|
test("support custom properties var reference in custom property", () => {
|
|
expect(parseCssValue("--bg", "var(--color)")).toEqual({
|
|
type: "var",
|
|
value: "color",
|
|
});
|
|
expect(parseCssValue("--bg", "var(--color, red)")).toEqual({
|
|
type: "var",
|
|
value: "color",
|
|
fallback: { type: "unparsed", value: "red" },
|
|
});
|
|
});
|
|
|
|
test("parse empty custom property as empty unparsed", () => {
|
|
expect(parseCssValue("--inset", "")).toEqual({
|
|
type: "unparsed",
|
|
value: "",
|
|
});
|
|
});
|
|
|
|
test("parse single var in repeated value without layers or tuples", () => {
|
|
expect(parseCssValue("background-image", "var(--gradient)")).toEqual({
|
|
type: "var",
|
|
value: "gradient",
|
|
});
|
|
expect(parseCssValue("filter", "var(--noise)")).toEqual({
|
|
type: "var",
|
|
value: "noise",
|
|
});
|
|
});
|
|
|
|
test("parse multiple var in repeated value as layers and tuples", () => {
|
|
expect(
|
|
parseCssValue("background-image", "var(--gradient-1), var(--gradient-2)")
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "var", value: "gradient-1" },
|
|
{ type: "var", value: "gradient-2" },
|
|
],
|
|
});
|
|
expect(parseCssValue("filter", "var(--noise-1) var(--noise-2)")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "var", value: "noise-1" },
|
|
{ type: "var", value: "noise-2" },
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("parse shadows", () => {
|
|
test("parses value and returns invalid when used a invalid boxShadow is passed", () => {
|
|
expect(parseCssValue("box-shadow", `10px 10px 5px foo`)).toEqual({
|
|
type: "invalid",
|
|
value: "10px 10px 5px foo",
|
|
});
|
|
});
|
|
|
|
test("parses value and returns invalid when a invalid textShadow is passed", () => {
|
|
expect(parseCssValue("text-shadow", `10px 10px 5px foo`)).toEqual({
|
|
type: "invalid",
|
|
value: "10px 10px 5px foo",
|
|
});
|
|
});
|
|
|
|
test("throws error when passed a value without a unit", () => {
|
|
expect(parseCssValue("box-shadow", `10 10px 5px red`)).toEqual({
|
|
type: "invalid",
|
|
value: "10 10px 5px red",
|
|
});
|
|
});
|
|
|
|
test("parses values and returns a layer when a valid textShadow is passes", () => {
|
|
expect(parseCssValue("text-shadow", "1px 1px 2px black")).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "px", value: 1 },
|
|
offsetY: { type: "unit", unit: "px", value: 1 },
|
|
blur: { type: "unit", unit: "px", value: 2 },
|
|
color: { type: "keyword", value: "black" },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("inset and color values can be interchanged", () => {
|
|
expect(parseCssValue("box-shadow", `inset 10px 10px 5px black`)).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "shadow",
|
|
position: "inset",
|
|
offsetX: { type: "unit", unit: "px", value: 10 },
|
|
offsetY: { type: "unit", unit: "px", value: 10 },
|
|
blur: { type: "unit", unit: "px", value: 5 },
|
|
color: { type: "keyword", value: "black" },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parses value when inset is used but missing blur-radius", () => {
|
|
expect(parseCssValue("box-shadow", `inset 5em 1em gold`)).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "shadow",
|
|
position: "inset",
|
|
offsetX: { type: "unit", unit: "em", value: 5 },
|
|
offsetY: { type: "unit", unit: "em", value: 1 },
|
|
color: { type: "keyword", value: "gold" },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parses value when offsetX and offsetY are used", () => {
|
|
expect(parseCssValue("box-shadow", `60px -16px teal`)).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "px", value: 60 },
|
|
offsetY: { type: "unit", unit: "px", value: -16 },
|
|
color: { type: "keyword", value: "teal" },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parses value from figma", () => {
|
|
expect(
|
|
parseCssValue(
|
|
"box-shadow",
|
|
"0 60px 80px rgba(0,0,0,0.60), 0 45px 26px rgba(0,0,0,0.14)"
|
|
)
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "number", value: 0 },
|
|
offsetY: { type: "unit", unit: "px", value: 60 },
|
|
blur: { type: "unit", unit: "px", value: 80 },
|
|
color: {
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0.6,
|
|
components: [0, 0, 0],
|
|
},
|
|
},
|
|
{
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "number", value: 0 },
|
|
offsetY: { type: "unit", unit: "px", value: 45 },
|
|
blur: { type: "unit", unit: "px", value: 26 },
|
|
color: {
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0.14,
|
|
components: [0, 0, 0],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test(`parses multiple layers of box-shadow property`, () => {
|
|
expect(
|
|
parseCssValue(
|
|
"box-shadow",
|
|
`
|
|
0 0 5px rgba(0, 0, 0, 0.2),
|
|
inset 0 0 10px rgba(0, 0, 0, 0.3),
|
|
0 0 15px rgba(0, 0, 0, 0.4)
|
|
`
|
|
)
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "number", value: 0 },
|
|
offsetY: { type: "unit", unit: "number", value: 0 },
|
|
blur: { type: "unit", unit: "px", value: 5 },
|
|
color: {
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0.2,
|
|
components: [0, 0, 0],
|
|
},
|
|
},
|
|
{
|
|
type: "shadow",
|
|
position: "inset",
|
|
offsetX: { type: "unit", unit: "number", value: 0 },
|
|
offsetY: { type: "unit", unit: "number", value: 0 },
|
|
blur: { type: "unit", unit: "px", value: 10 },
|
|
color: {
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0.3,
|
|
components: [0, 0, 0],
|
|
},
|
|
},
|
|
{
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "number", value: 0 },
|
|
offsetY: { type: "unit", unit: "number", value: 0 },
|
|
blur: { type: "unit", unit: "px", value: 15 },
|
|
color: {
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0.4,
|
|
components: [0, 0, 0],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parse var in box-shadow", () => {
|
|
expect(parseCssValue("box-shadow", "var(--shadow)")).toEqual({
|
|
type: "var",
|
|
value: "shadow",
|
|
});
|
|
expect(
|
|
parseCssValue("box-shadow", "var(--shadow-1), var(--shadow-2)")
|
|
).toEqual({
|
|
type: "layers",
|
|
value: [
|
|
{ type: "var", value: "shadow-1" },
|
|
{ type: "var", value: "shadow-2" },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parse filters", () => {
|
|
test("parse values and returns the valid style property values", () => {
|
|
expect(parseCssValue("filter", "blur(4px)")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "blur",
|
|
args: {
|
|
type: "tuple",
|
|
value: [{ type: "unit", unit: "px", value: 4 }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
parseCssValue("filter", "drop-shadow(10px 10px 25px rgba(0, 0, 255, 1))")
|
|
).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "drop-shadow",
|
|
args: {
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "px", value: 10 },
|
|
offsetY: { type: "unit", unit: "px", value: 10 },
|
|
blur: { type: "unit", unit: "px", value: 25 },
|
|
color: {
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 1,
|
|
components: [0, 0, 1],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(
|
|
parseCssValue("filter", "drop-shadow(10px 10px 25px #0000FF)")
|
|
).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "drop-shadow",
|
|
args: {
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "px", value: 10 },
|
|
offsetY: { type: "unit", unit: "px", value: 10 },
|
|
blur: { type: "unit", unit: "px", value: 25 },
|
|
color: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [0, 0, 1],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
parseCssValue("filter", "drop-shadow(10px 10px 25px var(--color))")
|
|
).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "drop-shadow",
|
|
args: {
|
|
type: "unparsed",
|
|
value: "10px 10px 25px var(--color)",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parse backdrop-filter", () => {
|
|
expect(parseCssValue("backdrop-filter", "blur(4px)")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "blur",
|
|
args: {
|
|
type: "tuple",
|
|
value: [{ type: "unit", unit: "px", value: 4 }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("Multiple valid function values", () => {
|
|
expect(
|
|
parseCssValue(
|
|
"filter",
|
|
"blur(4px) drop-shadow(16px 16px 20px blue) opacity(25%)"
|
|
)
|
|
).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "blur",
|
|
args: {
|
|
type: "tuple",
|
|
value: [{ type: "unit", unit: "px", value: 4 }],
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
name: "drop-shadow",
|
|
args: {
|
|
type: "shadow",
|
|
position: "outset",
|
|
offsetX: { type: "unit", unit: "px", value: 16 },
|
|
offsetY: { type: "unit", unit: "px", value: 16 },
|
|
blur: { type: "unit", unit: "px", value: 20 },
|
|
color: { type: "keyword", value: "blue" },
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
name: "opacity",
|
|
args: {
|
|
type: "tuple",
|
|
value: [{ type: "unit", unit: "%", value: 25 }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// parsers are used to use copied value. At the moment, we don't have support
|
|
// for complex functions in the UI like the one below like calc(4px + 16em)
|
|
test("Using complex functions inside filter function", () => {
|
|
expect(parseCssValue("filter", "blur(calc(4px + 16em))")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{
|
|
type: "function",
|
|
name: "blur",
|
|
args: {
|
|
type: "tuple",
|
|
value: [],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("aspect-ratio", () => {
|
|
test("support single numeric value", () => {
|
|
expect(parseCssValue("aspect-ratio", "10")).toEqual({
|
|
type: "unit",
|
|
unit: "number",
|
|
value: 10,
|
|
});
|
|
});
|
|
test("support keyword", () => {
|
|
expect(parseCssValue("aspect-ratio", "auto")).toEqual({
|
|
type: "keyword",
|
|
value: "auto",
|
|
});
|
|
});
|
|
test("support two values", () => {
|
|
expect(parseCssValue("aspect-ratio", "16 / 9")).toEqual({
|
|
type: "unparsed",
|
|
value: "16 / 9",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("font-family", () => {
|
|
test("support single value", () => {
|
|
expect(parseCssValue("font-family", "sans-serif")).toEqual({
|
|
type: "fontFamily",
|
|
value: ["sans-serif"],
|
|
});
|
|
});
|
|
|
|
test("support multiple values", () => {
|
|
expect(parseCssValue("font-family", "serif, sans-serif")).toEqual({
|
|
type: "fontFamily",
|
|
value: ["serif", "sans-serif"],
|
|
});
|
|
});
|
|
|
|
test("support space separated values", () => {
|
|
expect(parseCssValue("font-family", "Song Ti, Hei Ti")).toEqual({
|
|
type: "fontFamily",
|
|
value: ["Song Ti", "Hei Ti"],
|
|
});
|
|
// only two keywords
|
|
expect(parseCssValue("font-family", "Song Ti")).toEqual({
|
|
type: "fontFamily",
|
|
value: ["Song Ti"],
|
|
});
|
|
});
|
|
|
|
test("support quoted values", () => {
|
|
expect(parseCssValue("font-family", "\"Song Ti\", 'Hei Ti'")).toEqual({
|
|
type: "fontFamily",
|
|
value: ["Song Ti", "Hei Ti"],
|
|
});
|
|
});
|
|
});
|
|
|
|
test("parse transform-origin", () => {
|
|
expect(parseCssValue("transform-origin", "bottom")).toEqual({
|
|
type: "tuple",
|
|
value: [{ type: "keyword", value: "bottom" }],
|
|
});
|
|
|
|
expect(parseCssValue("transform-origin", "left 2px")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "keyword", value: "left" },
|
|
{ type: "unit", value: 2, unit: "px" },
|
|
],
|
|
});
|
|
|
|
expect(parseCssValue("transform-origin", "right top")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "keyword", value: "right" },
|
|
{ type: "keyword", value: "top" },
|
|
],
|
|
});
|
|
|
|
expect(parseCssValue("transform-origin", "2px 30% 10px")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "unit", value: 2, unit: "px" },
|
|
{ type: "unit", value: 30, unit: "%" },
|
|
{ type: "unit", value: 10, unit: "px" },
|
|
],
|
|
});
|
|
|
|
expect(parseCssValue("transform-origin", "top left right")).toEqual({
|
|
type: "invalid",
|
|
value: "top left right",
|
|
});
|
|
});
|
|
|
|
test("parse perspective-origin", () => {
|
|
expect(parseCssValue("perspective-origin", "center")).toEqual({
|
|
type: "tuple",
|
|
value: [{ type: "keyword", value: "center" }],
|
|
});
|
|
|
|
expect(parseCssValue("perspective-origin", "bottom right")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "keyword", value: "bottom" },
|
|
{ type: "keyword", value: "right" },
|
|
],
|
|
});
|
|
|
|
expect(parseCssValue("perspective-origin", "bottom 55%")).toEqual({
|
|
type: "invalid",
|
|
value: "bottom 55%",
|
|
});
|
|
|
|
expect(parseCssValue("perspective-origin", "75% bottom")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "unit", value: 75, unit: "%" },
|
|
{ type: "keyword", value: "bottom" },
|
|
],
|
|
});
|
|
|
|
expect(parseCssValue("perspective-origin", "-175%")).toEqual({
|
|
type: "tuple",
|
|
value: [{ type: "unit", value: -175, unit: "%" }],
|
|
});
|
|
|
|
expect(parseCssValue("perspective-origin", "50% 50%")).toEqual({
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "unit", value: 50, unit: "%" },
|
|
{ type: "unit", value: 50, unit: "%" },
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("isValidDeclaration", () => {
|
|
test("custom properties accept valid token streams", () => {
|
|
expect(isValidDeclaration("--my-color", "anything")).toBe(true);
|
|
expect(isValidDeclaration("--x", "rgb(0 0 0)")).toBe(true);
|
|
expect(isValidDeclaration("--x", "not-valid-garbage")).toBe(true);
|
|
expect(
|
|
isValidDeclaration("--x", "url(https://example.com/image.png)")
|
|
).toBe(true);
|
|
expect(isValidDeclaration("--x", "calc(100% - 1rem)")).toBe(true);
|
|
});
|
|
|
|
test("custom properties reject malformed token streams", () => {
|
|
for (const value of [
|
|
"#sd'",
|
|
'#sd"',
|
|
"abc(",
|
|
"abc[",
|
|
"abc{",
|
|
"abc/*",
|
|
"abc\\",
|
|
"url(foo",
|
|
"var(--x",
|
|
]) {
|
|
expect(isValidDeclaration("--x", value), value).toBe(false);
|
|
expect(parseCssValue("--x", value)).toEqual({
|
|
type: "invalid",
|
|
value,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("var() is valid on any property, detected via AST", () => {
|
|
expect(isValidDeclaration("color", "var(--primary)")).toBe(true);
|
|
expect(isValidDeclaration("color", "var(--primary, red)")).toBe(true);
|
|
expect(isValidDeclaration("width", "var(--size)")).toBe(true);
|
|
});
|
|
|
|
// Critical: var() must be detected by the AST walk before reaching the
|
|
// keyword-only check, otherwise var() on these properties would be rejected.
|
|
test("var() on keyword-only properties is valid", () => {
|
|
expect(isValidDeclaration("white-space-collapse", "var(--ws)")).toBe(true);
|
|
expect(isValidDeclaration("text-wrap-mode", "var(--tw)")).toBe(true);
|
|
expect(isValidDeclaration("text-wrap-style", "var(--ts)")).toBe(true);
|
|
});
|
|
|
|
test("white-space-collapse: valid keywords accepted", () => {
|
|
expect(isValidDeclaration("white-space-collapse", "collapse")).toBe(true);
|
|
expect(isValidDeclaration("white-space-collapse", "preserve")).toBe(true);
|
|
});
|
|
|
|
test("white-space-collapse: unknown keywords rejected", () => {
|
|
expect(isValidDeclaration("white-space-collapse", "not-valid")).toBe(false);
|
|
expect(isValidDeclaration("white-space-collapse", "wrap")).toBe(false);
|
|
});
|
|
|
|
test("text-wrap-mode: valid keywords accepted", () => {
|
|
expect(isValidDeclaration("text-wrap-mode", "wrap")).toBe(true);
|
|
expect(isValidDeclaration("text-wrap-mode", "nowrap")).toBe(true);
|
|
});
|
|
|
|
test("text-wrap-mode: unknown keywords rejected", () => {
|
|
expect(isValidDeclaration("text-wrap-mode", "not-valid")).toBe(false);
|
|
});
|
|
|
|
test("text-wrap-style: valid keywords accepted", () => {
|
|
expect(isValidDeclaration("text-wrap-style", "balance")).toBe(true);
|
|
expect(isValidDeclaration("text-wrap-style", "auto")).toBe(true);
|
|
});
|
|
|
|
test("text-wrap-style: unknown keywords rejected", () => {
|
|
expect(isValidDeclaration("text-wrap-style", "not-valid")).toBe(false);
|
|
});
|
|
|
|
// Relative color syntax: csstree returns identical "Mismatch" errors for both
|
|
// relative colors and genuinely invalid values, so we detect structurally via
|
|
// the `from` identifier being the first child of the color function node.
|
|
test("static relative color syntax is valid", () => {
|
|
expect(isValidDeclaration("color", "rgb(from blue r g b / 50%)")).toBe(
|
|
true
|
|
);
|
|
expect(isValidDeclaration("color", "oklch(from red l c h)")).toBe(true);
|
|
expect(
|
|
isValidDeclaration("background-color", "hsl(from green h s 75%)")
|
|
).toBe(true);
|
|
});
|
|
|
|
test("relative color with var() origin is valid", () => {
|
|
expect(isValidDeclaration("color", "rgb(from var(--brand) r g b)")).toBe(
|
|
true
|
|
);
|
|
expect(
|
|
isValidDeclaration(
|
|
"color",
|
|
"oklch(from var(--brand) l c h / var(--alpha))"
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
test("standard color values are valid via csstree lexer", () => {
|
|
expect(isValidDeclaration("color", "oklch(0.7 0.15 200)")).toBe(true);
|
|
expect(isValidDeclaration("color", "color-mix(in oklch, red, blue)")).toBe(
|
|
true
|
|
);
|
|
expect(isValidDeclaration("color", "rgb(255 0 0)")).toBe(true);
|
|
expect(isValidDeclaration("color", "#ff0000")).toBe(true);
|
|
});
|
|
|
|
test("genuinely invalid CSS values are rejected", () => {
|
|
expect(isValidDeclaration("color", "not-valid-garbage")).toBe(false);
|
|
// blur() is not a valid <color> value
|
|
expect(isValidDeclaration("color", "blur(4)")).toBe(false);
|
|
// red is not a valid <length>
|
|
expect(isValidDeclaration("width", "red")).toBe(false);
|
|
});
|
|
|
|
// The AST walk is deep, not shallow. var() nested inside calc() or color-mix()
|
|
// must be detected even though it isn't the top-level node.
|
|
test("var() nested deep inside another function is valid", () => {
|
|
expect(isValidDeclaration("width", "calc(var(--size) + 10px)")).toBe(true);
|
|
expect(
|
|
isValidDeclaration("color", "color-mix(in oklch, var(--primary), blue)")
|
|
).toBe(true);
|
|
});
|
|
|
|
// When csstree's tokenizer itself fails (malformed syntax like unmatched braces)
|
|
// cssTryParseValue returns undefined. In the non-browser path this must return false.
|
|
test("syntactically broken value that csstree cannot parse at all is rejected", () => {
|
|
expect(isValidDeclaration("color", "}")).toBe(false);
|
|
});
|
|
|
|
// The newer CSS linear() easing function isn't in csstree's grammar for
|
|
// transition/animation-timing-function, so there's a special lexer.match() call
|
|
// before the normal lexer.matchProperty() call.
|
|
test("transition-timing-function: CSS linear() easing syntax is valid", () => {
|
|
expect(
|
|
isValidDeclaration("transition-timing-function", "linear(0 0%, 1 100%)")
|
|
).toBe(true);
|
|
expect(
|
|
isValidDeclaration("transition-timing-function", "linear(0, 0.5 25%, 1)")
|
|
).toBe(true);
|
|
});
|
|
|
|
test("animation-timing-function: CSS linear() easing syntax is valid", () => {
|
|
expect(
|
|
isValidDeclaration("animation-timing-function", "linear(0 0%, 1 100%)")
|
|
).toBe(true);
|
|
});
|
|
|
|
// Unknown CSS properties (future or vendor-prefixed) are allowed through so they
|
|
// can be stored as UnparsedValue rather than silently dropped.
|
|
test("unknown CSS properties are accepted via the 'Unknown property' error path", () => {
|
|
expect(
|
|
isValidDeclaration("animation-timeline" as CssProperty, "auto")
|
|
).toBe(true);
|
|
expect(
|
|
isValidDeclaration("animation-range-start" as CssProperty, "normal")
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("keyword-only properties via parseCssValue", () => {
|
|
test("white-space-collapse: valid keyword", () => {
|
|
expect(parseCssValue("white-space-collapse", "collapse")).toEqual({
|
|
type: "keyword",
|
|
value: "collapse",
|
|
});
|
|
expect(parseCssValue("white-space-collapse", "preserve")).toEqual({
|
|
type: "keyword",
|
|
value: "preserve",
|
|
});
|
|
});
|
|
|
|
test("white-space-collapse: invalid keyword returns invalid", () => {
|
|
expect(parseCssValue("white-space-collapse", "not-valid")).toEqual({
|
|
type: "invalid",
|
|
value: "not-valid",
|
|
});
|
|
});
|
|
|
|
test("white-space-collapse: var() is accepted and returned as VarValue", () => {
|
|
expect(parseCssValue("white-space-collapse", "var(--collapse)")).toEqual({
|
|
type: "var",
|
|
value: "collapse",
|
|
});
|
|
});
|
|
|
|
test("text-wrap-mode: valid keyword", () => {
|
|
expect(parseCssValue("text-wrap-mode", "wrap")).toEqual({
|
|
type: "keyword",
|
|
value: "wrap",
|
|
});
|
|
expect(parseCssValue("text-wrap-mode", "nowrap")).toEqual({
|
|
type: "keyword",
|
|
value: "nowrap",
|
|
});
|
|
});
|
|
|
|
test("text-wrap-mode: invalid value", () => {
|
|
expect(parseCssValue("text-wrap-mode", "not-valid")).toEqual({
|
|
type: "invalid",
|
|
value: "not-valid",
|
|
});
|
|
});
|
|
|
|
test("text-wrap-mode: var() is accepted", () => {
|
|
expect(parseCssValue("text-wrap-mode", "var(--tw-mode)")).toEqual({
|
|
type: "var",
|
|
value: "tw-mode",
|
|
});
|
|
});
|
|
|
|
test("text-wrap-style: valid keyword", () => {
|
|
expect(parseCssValue("text-wrap-style", "balance")).toEqual({
|
|
type: "keyword",
|
|
value: "balance",
|
|
});
|
|
expect(parseCssValue("text-wrap-style", "auto")).toEqual({
|
|
type: "keyword",
|
|
value: "auto",
|
|
});
|
|
});
|
|
|
|
test("text-wrap-style: var() is accepted", () => {
|
|
expect(parseCssValue("text-wrap-style", "var(--style)")).toEqual({
|
|
type: "var",
|
|
value: "style",
|
|
});
|
|
});
|
|
});
|