From 034a30b611b9f0d9d839b396504b17d2292361e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:43:02 +0000 Subject: [PATCH] 0.13.1: forms save again, dark sections stay light, classes in Bricks' own form Found while rebuilding a real club website on a Bricks 2.4.2 site: - Forms: Bricks rejects submitButtonPadding; the padding moves to the bs-form class. - Dark and primary surfaces: Bricks prints class controls as .class.brxe-element, as strong as the surface rules, so titles stayed dark when a dark section came first. Surface and parent overrides now outrank Bricks' class rules. - Class CSS: Bricks moves gap, padding, grid-column etc. of a class's own rule into controls, splits selector lists and puts at-rules first. Classes are now written in that form, so updates verify and stop reporting "another version". Eyebrow and tabs use _gap, the gap control Bricks prints on text and nested tabs. - Saving a page compares classes without CSS layout (no false "keeps the site's definition" warnings). stableJson moves to its own module to avoid an import cycle. - Tests cover rejected and unprinted controls, rewritten class CSS and weak override rules across all layouts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01APhfigKq7DMdgedQ4Pr9VK --- CHANGELOG.md | 15 ++++ docs/RELEASE-0.13.1.md | 76 +++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/lib/bricks-validator.ts | 2 +- src/lib/kit/preview.ts | 2 +- src/lib/kit/sections/common.ts | 6 +- src/lib/kit/sections/conversion-plus.ts | 9 +- src/lib/kit/sections/header-hero-plus.ts | 2 +- src/lib/kit/sections/header-hero.ts | 2 +- src/lib/kit/sections/more-plus.ts | 2 +- src/lib/kit/sections/more.ts | 7 +- src/lib/kit/sections/services.ts | 2 +- src/lib/kit/styles.ts | 12 +-- src/lib/stable-json.ts | 8 ++ src/lib/template-classes.ts | 2 +- src/lib/template-staging.ts | 12 ++- src/lib/wordpress-client.ts | 2 +- tests/kit.test.ts | 102 ++++++++++++++++++++++- tests/template-staging.test.ts | 6 ++ 20 files changed, 241 insertions(+), 34 deletions(-) create mode 100644 docs/RELEASE-0.13.1.md create mode 100644 src/lib/stable-json.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d833e75..243d404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.13.1 — 2026-10-01 + +Fixes found while rebuilding a real club website on a Bricks 2.4.2 site. See the [release notes](docs/RELEASE-0.13.1.md). + +- **Forms save again.** Layouts with a form (contact, hero with form, login, coming soon) were refused by Bricks' `set-page-elements`: the submit button used `submitButtonPadding`, a control Bricks 2.4 does not have. The padding now comes from the `bs-form` class. +- **Light text on dark sections, on every page.** + - Bricks prints class controls as `.bs-title.brxe-heading`, as strong as the dark-section rule. + - When a dark section (a cover hero, a dark header) came before the first title, titles stayed dark on the dark background. + - Surface rules now outrank Bricks' class rules, wherever Bricks prints them. +- **Classes are written the way Bricks stores them.** Saving a class, Bricks moves `gap`, `padding`, `grid-column` and similar declarations into controls, splits selector lists and puts at-rules first. Affected classes reported "another version on the site" forever, and the class update ended "not verified". Two of those conversions also dropped CSS: + - the eyebrow's gap (Bricks' `_gridGap` is not printed on text); + - the tabs' gap. +- **No false warnings.** Saving a page no longer reports "keeps the site's definition" for classes that differ only in how Bricks laid out their CSS. +- **Tests** check every layout for controls Bricks rejects or does not render, for CSS Bricks rewrites, and for override rules weaker than Bricks' own class rules. + ## 0.13.0 — 2026-10-01 Mobile menus, carousels, motion and class updates. See the [release notes](docs/RELEASE-0.13.0.md). diff --git a/docs/RELEASE-0.13.1.md b/docs/RELEASE-0.13.1.md new file mode 100644 index 0000000..b496a10 --- /dev/null +++ b/docs/RELEASE-0.13.1.md @@ -0,0 +1,76 @@ +# BricksSnap 0.13.1: Fixes from a real rebuild + +We rebuilt a real club website, a German heritage society's homepage, with BricksSnap layouts on a Bricks 2.4.2 site. The page used: + +- the club's own copy and photos; +- a cover hero, about, timeline, gallery and board sections; +- an FAQ accordion and a contact form. + +Saving it showed four problems that the tests and the earlier live checks had missed. All four are fixed and covered by new tests. + +## 1. Forms save again + +Bricks refused every layout with a form: + +> Invalid value for "settings.submitButtonPadding". Expected a registered setting for the 'form' element. + +Bricks 2.4 has no padding control for the submit button. The padding now comes from the `bs-form` class, so the button looks the same. + +Affected layouts: Hero with enquiry form, Contact with form, Centred form, Sign in, Sign in split, and Coming soon split. + +## 2. Light text on dark sections, on every page + +Bricks prints a class's controls as `.bs-title.brxe-heading`. That rule is as strong as the rule that turns titles light on dark sections, so whichever came later on the page won. When a dark section came before the first title, titles stayed dark on the dark background, for example with a cover hero or a dark header. + +Dark and brand-colored sections now use rules that outrank Bricks' class rules wherever Bricks prints them. Some rules also overrode a class from a parent at only equal strength: + +- the bento feature card; +- logo tiles; +- the rating card; +- button rows in heroes. + +They now outrank it too. + +## 3. Classes are written the way Bricks stores them + +A probe class on the test site showed what Bricks 2.4.2 does when it saves a class's custom CSS: + +- **Controls.** It moves declarations of the class's own rule into controls: + - `display`, `gap`, `row-gap`, `column-gap`, `padding`, `width`; + - `grid-column`, `grid-row`, `opacity`, `visibility`; + - some typography. +- **Selector lists.** It splits them into one rule per selector. +- **Order.** It puts at-rules (`@media`, `@keyframes`) first. + +BricksSnap compared the stored version with its own, so these classes showed "another version on the site" forever, and **Update BricksSnap classes** ended "not verified". Two conversions also lost CSS: + +- `gap` became `_gridGap`, which Bricks does not print on text, so the eyebrow lost its spacing; +- the tabs' row gap was a control Bricks does not print on nested tabs. + +BricksSnap now writes these classes in Bricks' own form. On sites with an earlier version, **Update N BricksSnap classes** in Staging brings them up to date. + +## 4. No false warnings when saving + +Saving a page compared classes strictly and warned "keeps the site's definition" for classes that differed only in how Bricks laid out their CSS. That check now ignores layout like the other comparisons. + +## Verified + +- **Automated:** 267 tests. New checks across all 92 layouts in every style: + - no control Bricks rejects; + - no class control Bricks does not print for the element it is on; + - no class CSS that Bricks rewrites when it saves a class; + - no rule that overrides another class at only equal strength. +- **Live on the Bricks 2.4.2 test site:** the rebuilt page with 227 elements and 97 classes. + + | Check | Result | + |---|---| + | Saving | page verified, no warnings | + | Class update | verified; 0 of 97 classes differ afterwards | + | Cover hero | light title on the dark photo | + | Eyebrow spacing | rendered (`gap: 10px`) | + | Mobile menu and FAQ accordion | open as expected | + | Images and JavaScript | no broken images, no JavaScript errors | + +## Known limit + +Bricks loads web fonts only from its own typography settings. Add the kit's fonts under **Bricks → Settings → Custom fonts** or in a theme style; otherwise visitors see the fallback fonts. See [Studio](STUDIO.md). diff --git a/package-lock.json b/package-lock.json index 4f2cd27..a8115dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "brickssnap", - "version": "0.13.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "brickssnap", - "version": "0.13.0", + "version": "0.13.1", "license": "PolyForm-Shield-1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.1", diff --git a/package.json b/package.json index 8460740..391f9e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "brickssnap", - "version": "0.13.0", + "version": "0.13.1", "scripts": { "dev": "next dev --webpack", "dev:local": "node scripts/dev-local.mjs", diff --git a/src/lib/bricks-validator.ts b/src/lib/bricks-validator.ts index c1e87b7..84e5372 100644 --- a/src/lib/bricks-validator.ts +++ b/src/lib/bricks-validator.ts @@ -10,7 +10,7 @@ export const BRICKS_ELEMENT_NAMES = new Set(Object.keys(schema.elements) // _hidden carries the classes Bricks' nestable elements rely on (tab-title, accordion-content-wrapper, …). const metaControls = new Set(["_cssGlobalClasses", "_conditions", "_interactions", "_hideElementBuilder", "_hideElementFrontend", "_attributes", "_hidden"]); // Controls the published schema leaves out, verified against Bricks 2.4.2's rendered output. -const verifiedControls: Record = { "nav-nested": ["ariaLabel", "mobileMenu"] }; +export const verifiedControls: Record = { "nav-nested": ["ariaLabel", "mobileMenu"], image: ["_objectFit", "_objectPosition"] }; const controlSets = new Map(Object.entries(schema.elements).map(([name, element]) => [name, new Set([...schema.commonControls, ...element.controls, ...(verifiedControls[name] ?? []), ...metaControls])])); // A Bricks id is always a 6-char lowercase alphanumeric string. diff --git a/src/lib/kit/preview.ts b/src/lib/kit/preview.ts index 7147d22..9230b70 100644 --- a/src/lib/kit/preview.ts +++ b/src/lib/kit/preview.ts @@ -191,7 +191,7 @@ function formCss(selector: string, s: Settings): string { part("label", declarations("_typography", s.labelTypography)), part(":is(input, textarea)", [...declarations("_typography", s.fieldTypography), ...(color(s.fieldBackgroundColor) ? [`background-color: ${color(s.fieldBackgroundColor)}`] : []), ...sides("padding", s.fieldPadding), ...(fieldBorder ? declarations("_border", fieldBorder) : [])]), part(".form-group", sides("margin", s.fieldMargin)), - part(".bricks-button", [...declarations("_typography", s.submitButtonTypography), ...(color(s.submitButtonBackgroundColor) ? [`background-color: ${color(s.submitButtonBackgroundColor)}`] : []), ...sides("padding", s.submitButtonPadding), ...(submitBorder ? declarations("_border", submitBorder) : [])]), + part(".bricks-button", [...declarations("_typography", s.submitButtonTypography), ...(color(s.submitButtonBackgroundColor) ? [`background-color: ${color(s.submitButtonBackgroundColor)}`] : []), ...(submitBorder ? declarations("_border", submitBorder) : [])]), ].filter(Boolean).join("\n"); } diff --git a/src/lib/kit/sections/common.ts b/src/lib/kit/sections/common.ts index f0f6d45..1abd7f1 100644 --- a/src/lib/kit/sections/common.ts +++ b/src/lib/kit/sections/common.ts @@ -129,7 +129,8 @@ export function commonClasses(r: ResolvedKit): ClassLibrary { "bs-info__value": { ...x.type({ size: "text-m", weight: "500", color: "heading", decoration: "none" }), _margin: { top: "0", bottom: "0" } }, // Tabs: layout classes plus one look per part. - "bs-tabs": { _display: "flex", _direction: "column", _rowGap: x.v("space-l"), _alignItems: "stretch", _width: "100%" }, + // Bricks lays tabs out as a column; tabs-nested has _gap but no row gap or direction control. + "bs-tabs": { _display: "flex", _gap: x.v("space-l"), _alignItems: "stretch", _width: "100%" }, "bs-tabs__menu": { _direction: "row", _flexWrap: "wrap", _rowGap: "4px" }, "bs-tabs__menu--pill": merge(x.bg("surface"), x.line("1px", "border"), x.round("999px"), x.pad("4px"), { _columnGap: "4px", _width: "auto", _alignSelf: "center" }), "bs-tabs__menu--line": { _columnGap: x.v("space-m"), _width: "100%", _border: { width: { top: "0", right: "0", bottom: "1px", left: "0" }, style: "solid", color: x.color("border") } }, @@ -155,7 +156,8 @@ export function commonClasses(r: ResolvedKit): ClassLibrary { "bs-acc__q": merge(x.type({ size: "text-l", weight: "heading-weight", ls: "heading-tracking", lh: "1.35", color: "heading" }), { _margin: { top: "0", bottom: "0" } }, x.font("bs-acc__q", "heading", " text-wrap: balance;")), "bs-acc__icon": merge(x.type({ size: "22px", color: "link" }), { _flexShrink: "0", _cssTransition: "transform .2s ease", - _cssCustom: `.bs-acc__item.brx-open .bs-acc__icon, .bs-acc__title[aria-expanded="true"] .bs-acc__icon { transform: rotate(45deg); }`, + // One rule per selector: Bricks splits selector lists when it saves a class. + _cssCustom: `.bs-acc__item.brx-open .bs-acc__icon { transform: rotate(45deg); }\n.bs-acc__title[aria-expanded="true"] .bs-acc__icon { transform: rotate(45deg); }`, }), "bs-acc__content": { _widthMax: "720px", _padding: { top: "0", right: "0", bottom: x.v("space-m"), left: "0" } }, }; diff --git a/src/lib/kit/sections/conversion-plus.ts b/src/lib/kit/sections/conversion-plus.ts index 68e90b0..d2cf832 100644 --- a/src/lib/kit/sections/conversion-plus.ts +++ b/src/lib/kit/sections/conversion-plus.ts @@ -25,10 +25,10 @@ function convertPlusClasses(r: ResolvedKit) { "bs-price-tiles": { _display: "grid", _gridTemplateColumns: "repeat(3, minmax(0, 1fr))", "_gridTemplateColumns:tablet_portrait": "repeat(2, minmax(0, 1fr))", "_gridTemplateColumns:mobile_landscape": "minmax(0, 1fr)", _gridGap: x.v("space-s"), _width: "100%" }, // Testimonials - "bs-wall": { _display: "block", _width: "100%", _cssCustom: `.bs-wall { columns: 3 260px; column-gap: ${x.v("space-m")}; }\n.bs-wall > * { break-inside: avoid; margin-bottom: ${x.v("space-m")}; }` }, + "bs-wall": { _display: "block", _width: "100%", _columnGap: x.v("space-m"), _cssCustom: `.bs-wall { columns: 3 260px; }\n.bs-wall > * { break-inside: avoid; margin-bottom: ${x.v("space-m")}; }` }, "bs-wall__rating": merge(x.bg("primary"), x.round("radius-l"), x.pad("space-card"), x.type({ color: "on-primary" }), { _display: "flex", _direction: "column", _rowGap: "8px", _alignItems: "flex-start", - _cssCustom: `.bs-wall__rating .bs-stars__icon { color: ${x.v("on-primary")}; }`, + _cssCustom: `.bs-wall__rating.bs-wall__rating .bs-stars__icon { color: ${x.v("on-primary")}; }`, }), "bs-wall__score": merge(x.type({ size: "text-display", weight: "heading-weight", ls: "heading-tracking", lh: "1", color: "inherit" }), { _margin: none }, x.font("bs-wall__score", "heading")), "bs-wall__meta": merge(x.type({ size: "text-s", color: "inherit" }), { _margin: none, _opacity: "0.88" }), @@ -59,9 +59,10 @@ function convertPlusClasses(r: ResolvedKit) { // Logos "bs-logo-tiles": { _display: "grid", _gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", _gridGap: x.v("space-s"), _width: "100%" }, - "bs-logo-tile": merge(x.bg("surface-alt"), x.round("radius-m"), x.pad("space-s"), { _display: "flex", _alignItems: "center", _justifyContent: "center", _heightMin: "96px", _cssCustom: ".bs-logo-tile .bs-logos__name { white-space: normal; text-align: center; }" }), + "bs-logo-tile": merge(x.bg("surface-alt"), x.round("radius-m"), x.pad("space-s"), { _display: "flex", _alignItems: "center", _justifyContent: "center", _heightMin: "96px", _cssCustom: ".bs-logo-tile.bs-logo-tile .bs-logos__name { white-space: normal; text-align: center; }" }), "bs-marquee": { _width: "100%", _overflow: "hidden", _cssCustom: ".bs-marquee { mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); }" }, - "bs-marquee__track": { _display: "flex", _direction: "row", _width: "max-content", _cssCustom: ".bs-marquee__track { animation: bs-marquee 32s linear infinite; }\n.bs-marquee:hover .bs-marquee__track { animation-play-state: paused; }\n@keyframes bs-marquee { to { transform: translateX(-50%); } }\n@media (prefers-reduced-motion: reduce) { .bs-marquee__track { animation: none; } }" }, + "bs-marquee__track": { _display: "flex", _direction: "row", _width: "max-content", // At-rules first, as Bricks stores them. + _cssCustom: "@keyframes bs-marquee { to { transform: translateX(-50%); } }\n@media (prefers-reduced-motion: reduce) { .bs-marquee__track { animation: none; } }\n.bs-marquee__track { animation: bs-marquee 32s linear infinite; }\n.bs-marquee:hover .bs-marquee__track { animation-play-state: paused; }" }, "bs-marquee__group": { _display: "flex", _direction: "row", _alignItems: "center", _columnGap: "clamp(40px, 6vw, 80px)", _padding: { top: "8px", right: "clamp(40px, 6vw, 80px)", bottom: "8px", left: "0" } }, }; } diff --git a/src/lib/kit/sections/header-hero-plus.ts b/src/lib/kit/sections/header-hero-plus.ts index 6299857..8874303 100644 --- a/src/lib/kit/sections/header-hero-plus.ts +++ b/src/lib/kit/sections/header-hero-plus.ts @@ -53,7 +53,7 @@ function heroPlusClasses(r: ResolvedKit) { return { "bs-hero-bento": { _display: "grid", _gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr)", _gridGap: x.v("space-s"), _width: "100%" }, // Bricks has no grid span control on image elements, so the span lives in class CSS. - "bs-hero-bento__main": merge(x.round("radius-l"), { _width: "100%", _height: "100%", _heightMin: "340px", _objectFit: "cover", _cssCustom: ".bs-hero-bento__main { grid-row: span 2; }" }), + "bs-hero-bento__main": merge(x.round("radius-l"), { _width: "100%", _height: "100%", _heightMin: "340px", _objectFit: "cover", _cssCustom: ".bs-hero-bento .bs-hero-bento__main { grid-row: span 2; }" }), "bs-hero-bento__tile": merge(x.round("radius-l"), x.pad("space-card"), { _display: "flex", _direction: "column", _justifyContent: "flex-end", _rowGap: "6px", _heightMin: "160px" }), "bs-hero-bento__tile--primary": merge(x.bg("primary"), x.type({ color: "on-primary" })), "bs-hero-bento__tile--soft": merge(x.bg("primary-soft"), x.type({ color: "heading" })), diff --git a/src/lib/kit/sections/header-hero.ts b/src/lib/kit/sections/header-hero.ts index c33f18c..c214f64 100644 --- a/src/lib/kit/sections/header-hero.ts +++ b/src/lib/kit/sections/header-hero.ts @@ -105,7 +105,7 @@ function heroClasses(r: ResolvedKit) { const x = sx(r); return { // The hero's buttons get extra room below the lead, most noticeable on phones. - "bs-hero": { _cssCustom: ".bs-hero .bs-btn-row { margin-top: 8px; }" }, + "bs-hero": { _cssCustom: ".bs-hero.bs-hero .bs-btn-row { margin-top: 8px; }" }, "bs-hero__content": { _display: "flex", _direction: "column", _rowGap: x.v("space-m"), _alignItems: "flex-start" }, "bs-hero__visual": { _position: "relative", _width: "100%" }, "bs-hero__badge": merge(x.bg("surface"), x.round("radius-m"), x.pad("12px", "16px"), x.shadow("16px", "40px", "-16px"), { diff --git a/src/lib/kit/sections/more-plus.ts b/src/lib/kit/sections/more-plus.ts index 4031b37..10379d2 100644 --- a/src/lib/kit/sections/more-plus.ts +++ b/src/lib/kit/sections/more-plus.ts @@ -21,7 +21,7 @@ function morePlusClasses(r: ResolvedKit) { "bs-post-line__img": merge(x.round("radius-m"), { _width: "100%", _aspectRatio: "4/3", _objectFit: "cover", "_order:mobile_landscape": "-1" }), // Galleries - "bs-masonry": { _display: "block", _width: "100%", _cssCustom: `.bs-masonry { columns: 3 150px; column-gap: ${x.v("space-s")}; }` }, + "bs-masonry": { _display: "block", _width: "100%", _columnGap: x.v("space-s"), _cssCustom: ".bs-masonry { columns: 3 150px; }" }, "bs-masonry__img": merge(x.round("radius-m"), { _display: "block", _width: "100%", _objectFit: "cover", _margin: { top: "0", bottom: x.v("space-s") }, _cssCustom: ".bs-masonry__img { break-inside: avoid; }" }), "bs-gbento": { _display: "grid", _gridTemplateColumns: "repeat(4, minmax(0, 1fr))", "_gridTemplateColumns:mobile_landscape": "repeat(2, minmax(0, 1fr))", _gridAutoRows: "clamp(130px, 15vw, 220px)", _gridGap: x.v("space-s"), _width: "100%" }, "bs-gbento__cell": { _overflow: "hidden" }, diff --git a/src/lib/kit/sections/more.ts b/src/lib/kit/sections/more.ts index 889330f..d42420e 100644 --- a/src/lib/kit/sections/more.ts +++ b/src/lib/kit/sections/more.ts @@ -103,14 +103,16 @@ function moreClasses(r: ResolvedKit) { "bs-gallery": { _display: "grid", _gridTemplateColumns: "repeat(3, minmax(0, 1fr))", "_gridTemplateColumns:mobile_landscape": "repeat(2, minmax(0, 1fr))", _gridAutoRows: "clamp(150px, 20vw, 280px)", _gridGap: x.v("space-s"), _width: "100%" }, "bs-gallery__item": merge(x.round("radius-m"), { _width: "100%", _height: "100%", _objectFit: "cover", _overflow: "hidden" }), // Bricks has no grid span control on image elements, so the span lives in class CSS. - "bs-gallery__wide": { _cssCustom: ".bs-gallery__wide { grid-column: span 2; }" }, + // Inside the grid's rule: Bricks turns `.bs-gallery__wide { grid-column }` into a control. + "bs-gallery__wide": { _cssCustom: ".bs-gallery .bs-gallery__wide { grid-column: span 2; }" }, "bs-contact": { _display: "grid", _gridTemplateColumns: "minmax(0, 0.9fr) minmax(0, 1.1fr)", "_gridTemplateColumns:tablet_portrait": "minmax(0, 1fr)", _gridGap: x.v("space-xl"), _alignItems: "start", _width: "100%" }, "bs-contact__info": { _display: "flex", _direction: "column", _rowGap: x.v("space-m") }, "bs-form-card": merge(x.bg("surface"), x.line("1px", "border"), x.round("radius-l"), x.pad("space-card"), { _display: "flex", _direction: "column", _rowGap: x.v("space-s"), _width: "100%" }), "bs-center": merge({ _display: "flex", _direction: "column", _rowGap: x.v("space-m"), _alignItems: "center", _widthMax: "640px", _margin: { left: "auto", right: "auto" } }, x.type({ align: "center" })), "bs-code": merge(x.type({ size: "clamp(88px, 60px + 8vw, 160px)", weight: "heading-weight", ls: "heading-tracking", lh: "1", color: "link" }), { _margin: { top: "0", bottom: "0" } }, x.font("bs-code", "heading")), "bs-screen": { _heightMin: "min(80vh, 760px)", _justifyContent: "center" }, - "bs-form": { _width: "100%" }, + // Bricks 2.4 has no padding control for the submit button. + "bs-form": { _width: "100%", _cssCustom: ".bs-form .bricks-button { padding: 14px 24px; }" }, }; } @@ -134,7 +136,6 @@ export function form(ctx: Ctx, fields: Array<{ type: string; label: string; requ submitButtonBackgroundColor: x.color("primary"), submitButtonTypography: { "font-size": "16px", "font-weight": "600", color: x.color("on-primary") }, submitButtonBorder: { radius: x.sides(x.v("radius-btn")) }, - submitButtonPadding: { top: "14px", right: "24px", bottom: "14px", left: "24px" }, }); } diff --git a/src/lib/kit/sections/services.ts b/src/lib/kit/sections/services.ts index 1fa4a2f..091bcab 100644 --- a/src/lib/kit/sections/services.ts +++ b/src/lib/kit/sections/services.ts @@ -25,7 +25,7 @@ function serviceClasses(r: ResolvedKit) { _gridItemColumnSpan: "2", "_gridItemColumnSpan:mobile_landscape": "1", _gridItemRowSpan: "2", "_gridItemRowSpan:mobile_landscape": "1", _position: "relative", _overflow: "hidden", _heightMin: "420px", _display: "flex", _direction: "column", _justifyContent: "flex-end", _alignItems: "flex-start", _rowGap: "10px", _padding: { top: x.v("space-l"), right: x.v("space-l"), bottom: x.v("space-l"), left: x.v("space-l") }, - _cssCustom: `.bs-bento__feature::before { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 30%, color-mix(in srgb, ${x.v("inverse")} 85%, transparent) 100%); }\n.bs-bento__feature > * { position: relative; z-index: 1; }\n.bs-bento__feature :is(.bs-card__title, .bs-card__text) { color: ${x.v("on-inverse")}; }`, + _cssCustom: `.bs-bento__feature::before { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 30%, color-mix(in srgb, ${x.v("inverse")} 85%, transparent) 100%); }\n.bs-bento__feature > * { position: relative; z-index: 1; }\n.bs-bento__feature.bs-bento__feature :is(.bs-card__title, .bs-card__text) { color: ${x.v("on-inverse")}; }`, }), "bs-icon-grid": { _display: "grid", _gridTemplateColumns: "repeat(4, minmax(0, 1fr))", "_gridTemplateColumns:tablet_portrait": "repeat(2, minmax(0, 1fr))", "_gridTemplateColumns:mobile_portrait": "minmax(0, 1fr)", _gridGap: x.v("space-l"), _width: "100%" }, "bs-icon-item": { _display: "flex", _direction: "column", _rowGap: "12px", _alignItems: "flex-start" }, diff --git a/src/lib/kit/styles.ts b/src/lib/kit/styles.ts index 6d9a1cb..5b13533 100644 --- a/src/lib/kit/styles.ts +++ b/src/lib/kit/styles.ts @@ -65,9 +65,9 @@ export function baseClasses(r: ResolvedKit): ClassLibrary { const eyebrow: Record = { text: x.type({ size: "text-xs", weight: "600", ls: "0.12em", transform: "uppercase", color: "link" }), pill: merge(x.type({ size: "text-xs", weight: "600", ls: "0.02em", color: "link" }), x.bg("primary-soft"), x.pad("6px", "12px"), x.round("999px"), { _display: "inline-flex" }), - rule: merge(x.type({ size: "text-xs", weight: "700", ls: "0.14em", transform: "uppercase", color: "heading" }), { _display: "inline-flex", _alignItems: "center", _cssCustom: `.bs-eyebrow { gap: 12px; }\n.bs-eyebrow::before { content: ""; width: 28px; height: 2px; background: ${v("primary")}; }` }), + rule: merge(x.type({ size: "text-xs", weight: "700", ls: "0.14em", transform: "uppercase", color: "heading" }), { _display: "inline-flex", _alignItems: "center", _gap: "12px", _cssCustom: `.bs-eyebrow::before { content: ""; width: 28px; height: 2px; background: ${v("primary")}; }` }), italic: merge(x.type({ size: "text-m", weight: "400", style: "italic", color: "link" }), x.font("bs-eyebrow", "heading")), - dot: merge(x.type({ size: "text-s", weight: "600", color: "link" }), { _display: "inline-flex", _alignItems: "center", _cssCustom: `.bs-eyebrow { gap: 10px; }\n.bs-eyebrow::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: ${v("primary")}; }` }), + dot: merge(x.type({ size: "text-s", weight: "600", color: "link" }), { _display: "inline-flex", _alignItems: "center", _gap: "10px", _cssCustom: `.bs-eyebrow::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: ${v("primary")}; }` }), }; const card: Record = { @@ -88,11 +88,13 @@ export function baseClasses(r: ResolvedKit): ClassLibrary { "bs-section": merge(x.type({ size: "text-m", lh: "1.65" }), { _width: "100%" }, x.font("bs-section", "body", " -webkit-font-smoothing: antialiased;")), "bs-surface--page": merge(x.bg("bg"), x.type({ color: "text" })), "bs-surface--alt": merge(x.bg("surface-alt"), x.type({ color: "text" })), + // Bricks prints class controls as `.bs-title.brxe-heading`; the doubled surface class outranks them + // wherever Bricks happens to print the surface class. "bs-surface--inverse": merge(x.bg("inverse"), x.type({ color: "on-inverse" }), { - _cssCustom: `.bs-surface--inverse :is(.bs-title, .bs-card__title, .bs-stat__value, .bs-quote__text, .bs-eyebrow, .bs-price, .bs-brand, .bs-info__value) { color: ${v("on-inverse")}; }\n.bs-surface--inverse :is(.bs-lead, .bs-small, .bs-card__text, .bs-stat__label, .bs-quote__meta, .bs-nav__link, .bs-info__label) { color: ${v("inverse-muted")}; }\n.bs-surface--inverse .bs-card { background: transparent; border-color: ${v("inverse-muted")}; box-shadow: none; }`, + _cssCustom: `.bs-surface--inverse.bs-surface--inverse :is(.bs-title, .bs-card__title, .bs-stat__value, .bs-quote__text, .bs-eyebrow, .bs-price, .bs-brand, .bs-info__value) { color: ${v("on-inverse")}; }\n.bs-surface--inverse.bs-surface--inverse :is(.bs-lead, .bs-small, .bs-card__text, .bs-stat__label, .bs-quote__meta, .bs-nav__link, .bs-info__label) { color: ${v("inverse-muted")}; }\n.bs-surface--inverse.bs-surface--inverse .bs-card { background: transparent; border-color: ${v("inverse-muted")}; box-shadow: none; }`, }), "bs-surface--primary": merge(x.bg("primary"), x.type({ color: "on-primary" }), { - _cssCustom: `.bs-surface--primary :is(.bs-title, .bs-lead, .bs-eyebrow, .bs-small, .bs-card__title, .bs-card__text, .bs-stat__value, .bs-stat__label) { color: ${v("on-primary")}; }\n.bs-surface--primary :is(.bs-lead, .bs-small, .bs-stat__label) { opacity: 0.88; }\n.bs-surface--primary .bs-eyebrow { background: transparent; }\n.bs-surface--primary .bs-eyebrow::before { background: currentColor; }`, + _cssCustom: `.bs-surface--primary.bs-surface--primary :is(.bs-title, .bs-lead, .bs-eyebrow, .bs-small, .bs-card__title, .bs-card__text, .bs-stat__value, .bs-stat__label) { color: ${v("on-primary")}; }\n.bs-surface--primary.bs-surface--primary :is(.bs-lead, .bs-small, .bs-stat__label) { opacity: 0.88; }\n.bs-surface--primary.bs-surface--primary .bs-eyebrow { background: transparent; }\n.bs-surface--primary.bs-surface--primary .bs-eyebrow::before { background: currentColor; }`, }), "bs-space--section": x.pad("space-section", "gutter"), "bs-space--tight": x.pad("space-l", "gutter"), @@ -116,7 +118,7 @@ export function baseClasses(r: ResolvedKit): ClassLibrary { "bs-small": merge(x.type({ size: "text-s", lh: "1.55", color: "muted" }), { _margin: { top: "0", bottom: "0" } }), // On phones, buttons stack at full width instead of squeezing side by side. - "bs-btn-row": { _display: "flex", _direction: "row", _flexWrap: "wrap", _alignItems: "center", _columnGap: "12px", _rowGap: "12px", "_direction:mobile_portrait": "column", "_alignItems:mobile_portrait": "stretch", "_width:mobile_portrait": "100%", _cssCustom: ".bs-btn-row > .bs-btn { justify-content: center; }" }, + "bs-btn-row": { _display: "flex", _direction: "row", _flexWrap: "wrap", _alignItems: "center", _columnGap: "12px", _rowGap: "12px", "_direction:mobile_portrait": "column", "_alignItems:mobile_portrait": "stretch", "_width:mobile_portrait": "100%", _cssCustom: ".bs-btn-row.bs-btn-row > .bs-btn { justify-content: center; }" }, // Buttons: bs-btn (shape and type), exactly one size and exactly one variant (colors and border line). "bs-btn-size--m": merge(x.pad("14px", "24px"), x.type({ size: "16px" })), "bs-btn-size--s": merge(x.pad("10px", "18px"), x.type({ size: "15px" })), diff --git a/src/lib/stable-json.ts b/src/lib/stable-json.ts new file mode 100644 index 0000000..69ef870 --- /dev/null +++ b/src/lib/stable-json.ts @@ -0,0 +1,8 @@ +const object = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); + +/** Stable comparison of JSON data; deliberately not a WordPress concurrency token. */ +export function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; + return JSON.stringify(value) ?? "undefined"; +} diff --git a/src/lib/template-classes.ts b/src/lib/template-classes.ts index bf05098..b6b4275 100644 --- a/src/lib/template-classes.ts +++ b/src/lib/template-classes.ts @@ -1,6 +1,6 @@ import type { BricksGlobalClass, BricksTemplate } from "./bricks-engine"; import { classId } from "./kit/build"; -import { stableJson } from "./template-staging"; +import { stableJson } from "./stable-json"; /** IDs of global classes the elements use (`_cssGlobalClasses`). */ export function referencedClassIds(template: Pick): string[] { diff --git a/src/lib/template-staging.ts b/src/lib/template-staging.ts index 436ad26..44b07fc 100644 --- a/src/lib/template-staging.ts +++ b/src/lib/template-staging.ts @@ -2,17 +2,15 @@ import { wrapTemplate, type BricksElement, type BricksTemplate } from "./bricks- import { BRICKS_ELEMENT_NAMES } from "./bricks-validator"; import { templateWarnings } from "./template-warnings"; import { resolveElementCss } from "./bricks-css"; +import { stableJson } from "./stable-json"; +import { sameClassDefinition } from "./template-classes"; + +export { stableJson }; export type InsertPosition = { mode: "prepend" | "append" } | { mode: "after"; afterId: string }; export type ElementDelta = { id: string; name: string; label: string; status: "added" | "removed" | "changed" | "moved"; fields: string[] }; const object = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); -/** Stable comparison of JSON data; deliberately not a WordPress concurrency token. */ -export function stableJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; - if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; - return JSON.stringify(value) ?? "undefined"; -} /** Staging is strict: silently repairing an existing page would alter the baseline. */ export function readStagingTemplate(input: unknown, allowEmpty = false): BricksTemplate { @@ -183,7 +181,7 @@ export function siteClassWarnings(template: BricksTemplate, siteClasses: SiteCla const sameId = byId.get(cls.id); const sameName = byName.get(cls.name); if (sameId && sameId.name !== cls.name) warnings.push(`The site uses the ID ${cls.id} of global class ${cls.name} for another class (${sameId.name}). "Create missing global classes" gives ${cls.name} a new ID before saving.`); - else if (sameId && stableJson(sameId.settings ?? {}) !== stableJson(cls.settings ?? {})) warnings.push(`Global class ${cls.name} already exists on the site with a different definition (for example from another BricksSnap design). The page will use the site's version; it is never overwritten.`); + else if (sameId && !sameClassDefinition({ settings: object(sameId.settings) ? sameId.settings : {} }, cls)) warnings.push(`Global class ${cls.name} already exists on the site with a different definition (for example from another BricksSnap design). The page will use the site's version; it is never overwritten.`); else if (!sameId && sameName) warnings.push(`Global class name ${cls.name} already exists on the site with ID ${sameName.id}; importing would create a second class.`); } const known = new Set([...(template.globalClasses ?? []).map(c => c.id), ...byId.keys()]); diff --git a/src/lib/wordpress-client.ts b/src/lib/wordpress-client.ts index 8a0fcb8..2205c78 100644 --- a/src/lib/wordpress-client.ts +++ b/src/lib/wordpress-client.ts @@ -555,7 +555,7 @@ export async function applyWordPressPage( if (foreign.length) throw new RequestError(`The site uses the ID of ${foreign.map(f => `${f.name} (${f.id})`).join(", ")} for another class (${foreign.map(f => f.siteName).join(", ")}). Run "Create missing global classes" first; it gives these classes new IDs.`, 422); for (const cls of proposal.globalClasses ?? []) { const existing = site.get(cls.id); - if (referenced.has(cls.id) && existing && stableJson(isRecord(existing.settings) ? existing.settings : {}) !== stableJson(cls.settings ?? {})) warnings.push(`Global class ${cls.name} keeps the site's definition; staged settings for it are not saved.`); + if (referenced.has(cls.id) && existing && !sameClassDefinition({ settings: isRecord(existing.settings) ? existing.settings : {} }, cls)) warnings.push(`Global class ${cls.name} keeps the site's definition; staged settings for it are not saved.`); } } diff --git a/tests/kit.test.ts b/tests/kit.test.ts index 7d106d4..5ac7efe 100644 --- a/tests/kit.test.ts +++ b/tests/kit.test.ts @@ -7,7 +7,8 @@ import { renderPreview, settingsProperties } from "../src/lib/kit/preview"; import { classLibrary, SECTION_TYPES, variantsFor } from "../src/lib/kit/sections"; import { contrast } from "../src/lib/kit/color"; import { contrastChecks, normalizeKit, resolveKit, STYLE_IDS } from "../src/lib/kit/tokens"; -import { validateBricksElements } from "../src/lib/bricks-validator"; +import { validateBricksElements, verifiedControls } from "../src/lib/bricks-validator"; +import schema from "../src/data/bricks-schema.json"; import { readStagingTemplate } from "../src/lib/template-staging"; import { insertSection, kitPreview, SECTION_LABELS, STARTER_PAGES } from "../src/lib/kit/studio"; import { kitCatalog, kitTemplateType } from "../src/lib/kit/generate"; @@ -49,7 +50,10 @@ describe("generated templates", () => { for (const style of STYLE_IDS) { for (let i = 0; i < LAYOUTS.length; i += 10) { const { template } = generateKitTemplate({ kit: { style }, profile: { industry: "kfz", language: "de" }, sections: LAYOUTS.slice(i, i + 10) }); - expect(validateBricksElements(template.content).valid).toBe(true); + const validation = validateBricksElements(template.content); + expect(validation.valid).toBe(true); + // Bricks' set-page-elements rejects controls it has not registered (e.g. a form's submitButtonPadding). + expect(validation.violations.filter(v => /undocumented/.test(v))).toEqual([]); expect(() => readStagingTemplate(template)).not.toThrow(); const ids = new Set(template.globalClasses.map(c => c.id)); for (const el of template.content) for (const id of (el.settings._cssGlobalClasses as string[] | undefined) ?? []) expect(ids.has(id)).toBe(true); @@ -325,3 +329,97 @@ describe("phone layout", () => { expect(preview.html).toContain("Zahnarztpraxis in"); }); }); + +/** Top-level rules of a class's custom CSS; at-rule blocks are kept whole. */ +function cssRules(css: string): Array<{ selector: string; body: string; at: boolean }> { + const rules: Array<{ selector: string; body: string; at: boolean }> = []; + let i = 0; + while (i < css.length) { + const open = css.indexOf("{", i); + if (open < 0) break; + let depth = 1, j = open + 1; + while (j < css.length && depth) { if (css[j] === "{") depth++; else if (css[j] === "}") depth--; j++; } + const selector = css.slice(i, open).trim(); + rules.push({ selector, body: css.slice(open + 1, j - 1).trim(), at: selector.startsWith("@") }); + i = j; + } + return rules; +} +/** Splits at commas and combinators outside parentheses. */ +function splitOutside(value: string, separators: RegExp): string[] { + const parts: string[] = []; + let depth = 0, current = ""; + for (const ch of value) { + if (ch === "(") depth++; + if (ch === ")") depth--; + if (!depth && separators.test(ch)) { parts.push(current); current = ""; continue; } + current += ch; + } + return [...parts, current].map(p => p.trim()).filter(Boolean); +} +/** Class-level specificity (classes, attributes, pseudo-classes; :is/:has/:not count their strongest argument). */ +function classSpecificity(selector: string): number { + let count = 0; + const rest = selector.replace(/:(is|not|has|where)\(((?:[^()]|\([^()]*\))*)\)/g, (_, fn: string, args: string) => { + if (fn !== "where") count += Math.max(0, ...splitOutside(args, /,/).map(classSpecificity)); + return ""; + }); + count += (rest.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) ?? []).length; + return count; +} + +describe("class CSS as Bricks 2.4 stores and renders it", () => { + const libraries = STYLE_IDS.flatMap(style => (["light", "dark"] as const).map(mode => classLibrary(resolveKit({ style, mode })) as Record>)); + const customCss = function* () { + for (const lib of libraries) for (const [name, settings] of Object.entries(lib)) for (const [key, css] of Object.entries(settings)) if (key.startsWith("_cssCustom") && typeof css === "string") yield { name, css }; + }; + + it("keeps properties Bricks turns into controls out of the class's own rule", () => { + // Saving a class, Bricks moves these from `.class { … }` into controls (gap into _gridGap, which it + // does not render on flex elements); the site then never matches the definition. + const converted = /^(display|gap|row-gap|column-gap|color|grid-column|grid-row|opacity|visibility|padding|margin|width|height)$/; + const found = new Set(); + for (const { name, css } of customCss()) for (const rule of cssRules(css)) { + if (rule.at || rule.selector !== `.${name}`) continue; + for (const decl of rule.body.split(";")) { const prop = decl.split(":")[0].trim(); if (converted.test(prop)) found.add(`${name}: ${prop}`); } + } + expect([...found]).toEqual([]); + }); + + it("writes rules the way Bricks stores them: no selector lists, at-rules first", () => { + const found = new Set(); + for (const { name, css } of customCss()) { + const rules = cssRules(css); + for (const rule of rules) if (!rule.at && splitOutside(rule.selector, /,/).length > 1) found.add(`${name}: list ${rule.selector}`); + const firstRule = rules.findIndex(r => !r.at); + if (firstRule >= 0 && firstRule < rules.findLastIndex(r => r.at)) found.add(`${name}: at-rule after a rule`); + } + expect([...found]).toEqual([]); + }); + + it("gives each element's classes only controls Bricks renders for that element", () => { + // Bricks stores any control on a class but prints only those the element has (no _columnGap on text). + const controls = new Map(Object.entries(schema.elements).map(([name, element]) => [name, new Set([...schema.commonControls, ...element.controls, ...(verifiedControls[name] ?? []), "_cssCustom"])])); + const found = new Set(); + for (const style of STYLE_IDS) for (let i = 0; i < LAYOUTS.length; i += 10) { + const { template } = generateKitTemplate({ kit: { style, motion: "subtle" }, profile: { industry: "kfz", language: "de" }, sections: LAYOUTS.slice(i, i + 10) }); + const classes = new Map(template.globalClasses.map(c => [c.id, c])); + for (const el of template.content) for (const id of (el.settings._cssGlobalClasses as string[] | undefined) ?? []) { + const cls = classes.get(id)!; + for (const key of Object.keys(cls.settings ?? {})) if (!controls.get(el.name)?.has(key.split(":")[0])) found.add(`${cls.name} on ${el.name}: ${key}`); + } + } + expect([...found]).toEqual([]); + }); + + it("overrides other classes with rules stronger than Bricks' `.class.brxe-element`", () => { + // Equal specificity would make the result depend on which class Bricks prints first on a page. + const weak = new Set(); + for (const { name, css } of customCss()) for (const rule of cssRules(css).filter(r => !r.at)) for (const selector of splitOutside(rule.selector, /,/)) { + const compounds = splitOutside(selector.replace(/::[\w-]+$/, ""), /[\s>+~]/); + const target = compounds.at(-1)!.replace(/:has\((?:[^()]|\([^()]*\))*\)/g, ""); + if (compounds.length > 1 && /\.bs-/.test(target) && !target.includes(`.${name}`) && classSpecificity(selector) < 3) weak.add(`${name}: ${selector}`); + } + expect([...weak]).toEqual([]); + }); +}); diff --git a/tests/template-staging.test.ts b/tests/template-staging.test.ts index fb6ad88..415d392 100644 --- a/tests/template-staging.test.ts +++ b/tests/template-staging.test.ts @@ -101,6 +101,12 @@ describe("site class checks", () => { expect(siteClassWarnings(template, site)).toEqual([]); }); + it("ignores how Bricks lays out a class's custom CSS", () => { + const stored = [{ id: "cls003", name: "bs-x", settings: { _cssCustom: ".bs-x::before {\n content: \"\";\n}\n.a>.bs-x {\n color: red;\n}" } }]; + const template = { ...wrapTemplate([el("aaa001", ["cls003"])]), globalClasses: [{ id: "cls003", name: "bs-x", settings: { _cssCustom: ".bs-x::before { content: \"\"; }\n.a > .bs-x { color: red; }" } }] }; + expect(siteClassWarnings(template, stored)).toEqual([]); + }); + it("reports a reused name under another ID, changed settings and undefined references", () => { const template = { ...wrapTemplate([el("aaa001", ["new001", "cls002", "ghost1"])]),