WIP: Abilities API ignores "Custom CSS sync" being off and moves Custom CSS into style controls

# Bricks 2.4.2 — Abilities API ignores “Custom CSS sync” being off and moves Custom CSS into style controls

**Version:** Bricks 2.4.2 (also checked against the identical files on a live 2.4.2 install)

**Area:** AI / Abilities API — `includes/abilities/element-style-normalizer.php`

**Severity:** High. The site owner has turned sync off, but the abilities still rewrite their Custom CSS. With nested CSS, the rewrite also corrupts it, and the save still reports success.

## Summary

Bricks has a setting called **“Bi-directional sync between Custom CSS and style controls”** (`builderCssSync`). When it is **off**, Custom CSS should stay as Custom CSS. It should not be moved into the element’s or class’s style controls.

The builder follows this setting. **The Abilities API does not.** Every `_cssCustom` written through the abilities goes through `Element_Style_Normalizer::map_base_custom_css_to_settings()`, whatever the setting is. Root declarations are taken out of the CSS and written into style controls (`row-gap` → `_rowGap`, `color` → `_typography.color`, `display` → `_display`, …). The remaining CSS is rebuilt from parsed rules. That covers page and template element saves, `update-element`, global class create/update and the design workspace.

So with sync turned off, the same CSS is:

- **saved from the builder:** stored verbatim in Custom CSS (correct)

- **saved through the abilities:** split into style controls and rebuilt CSS (wrong)

## Where the setting is ignored

- `builderCssSync` is only read in `includes/builder.php:1335` (passed to the builder) and `includes/assets.php:4459` (render-time exclusions).

- `includes/abilities/element-style-normalizer.php` never reads it. `normalize_element()` (line 102) and `normalize_global_class_settings()` (line 144) call `map_base_custom_css_to_settings()` unconditionally. The only skip is `$preserve_unchanged`, which applies only when the incoming CSS is identical to what is already saved.

- Callers: `abilities/save-pipeline.php:291`, `abilities/conversion.php:219`, `abilities/design.php:4010`, `4222`, `6858`, `8091`, `abilities/design-workspace.php:967`.

**Expected:** with sync off, `_cssCustom` written through the abilities is stored as-is, the same as a builder save (only `%root%` placeholder replacement applied).

**Actual:** root declarations are always moved into style controls, and `_cssCustom` is rebuilt from the parsed result.

## Why this matters: the forced conversion is lossy and corrupts CSS

Users often turn sync off because they write CSS the controls can’t represent, such as native nesting, `@container` inside rules, or custom properties holding shorthands. Since the abilities force the conversion anyway, that CSS goes through a parser that doesn’t support it.

### Nested CSS is corrupted

`Html_To_Bricks_Css_Parser::parse_rules_block()` passes a top-level rule’s whole block to `parse_declarations()` (`includes/html-to-bricks/css-parser.php:596`). That function:

1. splits on every `;` at function depth 0 (line 672), without tracking `{ }`

2. splits each part at its first `:` (line 691)

3. keys the result by property (line 701)

For nested rules, this creates fake declarations, drops closing braces and overwrites repeated properties.

Parser-only repro:

```php

$css = “.x{\n color: var(–a);\n\n &:hover{\n color: var(–b);\n }\n}\n”;

var_export( \Bricks\Html_To_Bricks_Css_Parser::parse_css( $css )[0][‘declarations’] );

```

```php

array (

‘color’ => ‘var(–a)’,

‘&’ => 'hover{

color: var(--b)',

)

```

End-to-end repro, with **sync off**: `bricks/update-element` (or `bricks/set-page-elements`) with this element CSS:

```css

%root%{

display: flex;

color: var(–_c);

&:hover,

&:focus-visible{

background: var(–_bg-hover);

color: var(–_c-hover);

}

@container (inline-size <= 600px){

flex-direction: column;

.x__child{ color: blue; }

}

}

```

Stored result (abridged):

```css

#brxe-abc123 {

&: hover,

&:focus-visible{

background: var(–_bg-hover);

}

```

- `display` is moved into `_display`, even though sync is off.

- `color` is moved into controls with the **hover** value. The nested `color: var(–_c-hover)` overwrote the outer `color` because declarations are keyed by property.

- `&:hover` becomes the fake declaration `&: hover…`.

- The `@container` block and its closing braces are lost or truncated, which leaves the stored CSS with unbalanced braces. The ability still returns `ok: true`.

The same CSS saved in the builder with sync off is stored exactly as written and works.

### `border: var(–x)` is stored as a border width (minor)

`Html_To_Bricks_Css_Value_Parsers::parse_border()` (`css-to-controls/parsers.php:438`) intentionally treats a lone `var()` as the width (“legacy width interpretation of `var(–border-width)`”). So `border: var(–card-border)`, where `–card-border: 1px solid …`, is written to `_border.width.{top,right,bottom,left}` and renders as an invalid width. With sync respected, this CSS would never reach the parser.

### Cascade order changes

Top-level at-rules are kept verbatim (`build_fallback_custom_css()`, line 219) but moved **above** all rebuilt rules. That changes cascade order for top-level `@container` blocks that override earlier rules of equal specificity.

## Workaround

Wrap the entire CSS in a single top-level at-rule (for example `@supports (display: grid) { … }`). This is currently the only way to get CSS through the abilities unchanged.

## Suggested fix

1. **Primary:** in `Element_Style_Normalizer`, only call `map_base_custom_css_to_settings()` when `builderCssSync` is enabled. When it is off, run only `replace_root_placeholder()` and store the CSS verbatim, matching the builder save path.

2. With sync **on**, never rebuild `_cssCustom` from a lossy parse. If the CSS contains nesting the parser can’t handle, keep the original text or fail the save rather than returning `ok: true` with corrupted CSS.

3. Make `parse_declarations()` / `parse_rules_block()` nesting-aware by tracking brace depth and treating nested blocks as child rules.

Hi @alanblair,

you don’t expect us to read this, do you? :slight_smile: Can you please structure your reports in a way that we can understand and still manually replicate it, without needed to trust the AI in the process.

Saying that, I was able to replicate something, using the following prompt:

Reproduce an Abilities API Custom CSS preservation bug on this Bricks test site.

1. Read the Bricks version and confirm builderCssSync is disabled. If enabled, stop and ask me to disable it.
2. Create a temporary draft page with one native Div element. Use the advertised ability schemas.
3. Through bricks/update-element or bricks/set-page-elements, write this exact _cssCustom value:

%root% {
  display: flex;
  color: red;
  &:hover {
    color: blue;
  }
}

4. Capture the ability response, then independently read back the saved element settings.
5. Repeat with a new temporary global class, using the same CSS through the global-class create/update ability.
6. For both writes, show:
   - The ability name and exact submitted payload.
   - Whether the response reports success.
   - The persisted _cssCustom, _display, and _typography settings.
   - Whether the nested hover rule remains intact.

Expected: With CSS sync disabled, Custom CSS stays unchanged except for %root% selector substitution. No display or typography controls should be added.

Bug indicator: Root declarations move into style controls and the nested rule is rebuilt as malformed CSS, despite a successful save response.

Use fresh test resources so unchanged-CSS preservation cannot bypass normalization. Do not fix the code or enable CSS sync. Leave the test resources available for inspection and return their IDs and URLs.

And at the end, the Custom CSS was broken. We will investigate this, but I would like to ask you if you can test this yourself and let me know if this is the same issue or not.

Thanks,
Matej

:slight_smile: Yep..

Sorry I wanted to provide as much detail as possible.

Result from your prompt:

Expected: the CSS stays unchanged apart from %root% being replaced, and no display or typography controls are added. Both tests instead show the bug, even though both writes reported success.

In both cases the response contained the already-mangled settings. So the change happens inside the ability’s own save step, not afterwards when the data is read back.

Hey @alanblair,

thanks for the response and confirmation that the issue is correctly identified :victory_hand:

And I’m glad you did, but we really prefer bug reports in a readable format :blush:

Thanks again! Once we release a fix, we will update this topic.

Thanks,
Matej