diff --git a/CHANGELOG.md b/CHANGELOG.md index 366acf5..d833e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.13.0 — 2026-10-01 + +Mobile menus, carousels, motion and class updates. See the [release notes](docs/RELEASE-0.13.0.md). + +- **Mobile menu in every header.** Headers with links use Bricks' nestable nav, in the structure Bricks itself creates. + - Below 991 px a hamburger opens a full-screen menu in the kit's colors and turns into an X. + - On phones the header button moves into the menu. + - Before, the links were hidden with no menu. +- **Carousels.** New "Carousel" layouts for reviews and portfolio on Bricks' nested slider: 2 or 3 cards, 1 on phones, with swipe and dots (92 layouts). +- **Motion.** New brand kit option "Fade in". + - Content sections fade their intros, cards, items and images up once on scroll, using Bricks interactions. + - Headers, heroes and footers stay still. + - With reduced motion, everything is shown at once. + - Also `kit.motion` over MCP. +- **Update outdated BricksSnap classes.** In Staging, `bs-` classes the site has in another version can be updated site-wide (`update-global-class`). + - Each write is guarded and dropped keys are removed. + - The result is verified, and the update can be undone. +- **Fix.** Class comparisons ignore how Bricks reformats custom CSS (line breaks, indentation, spaces around `>`). Before, such classes were reported as differing definitions. +- **Remote library.** 210 templates. + ## 0.12.0 — 2026-09-30 Import & Modernize, and 90 layouts. See the [release notes](docs/RELEASE-0.12.0.md). diff --git a/README.md b/README.md index d4bf0fb..eb8f31b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A Next.js tool that creates editable **Bricks Builder JSON templates**. Build mo Free for your own and your clients' websites. You may not offer BricksSnap itself to others as a hosted app or sell it; see [licensing](LICENSING.md). +**v0.13: Mobile menus, carousels, motion and class updates** ([release notes](docs/RELEASE-0.13.0.md)). Every header gets a real mobile menu, reviews and portfolio come as carousels, content can fade in on scroll, and sites with older BricksSnap classes can update them with undo. + **v0.12: [Import & Modernize](docs/MODERNIZE.md) and 90 layouts** ([release notes](docs/RELEASE-0.12.0.md)). Paste any Bricks JSON and get it back on your design system: tokens instead of fixed colors, fluid sizes, clean `bs-` classes and mobile rules, restyled by your brand kit. The Studio grows from 40 to 90 layouts, including bento grids, a pricing switch and a FAQ accordion built on Bricks' native tabs and accordion. **v0.9: Library of ready-made designs.** 12 complete designs for different industries and styles, as pages and sections, in the app and in Bricks' remote library ([release notes](docs/RELEASE-0.9.0.md)). @@ -29,7 +31,7 @@ v0.5 builds on v0.4's [read-only connection](docs/RELEASE-0.4.0.md) and v0.3's [ ## Features -- Studio: 90 layouts in 22 section types and five style directions, a brand kit (color, fonts, corners, spacing, light/dark), industry copy in German or English, live preview at three widths, quality checks +- Studio: 92 layouts in 22 section types and five style directions, with mobile menus, carousels and optional scroll motion; a brand kit (color, fonts, corners, spacing, light/dark, motion), industry copy in German or English, live preview at three widths, quality checks - Modernize: any Bricks JSON rebuilt on the design system (tokens, fluid scale, `bs-` classes, mobile rules), locally in the browser - Design system: `bs-` global classes with `var(--bs-*, fallback)` values; palette and variables installable on a connected site with ownership guards - Pre-built template library (Hero, Navbar, Features, Pricing, Testimonials, Footer and more) diff --git a/docs/RELEASE-0.13.0.md b/docs/RELEASE-0.13.0.md new file mode 100644 index 0000000..7ad5aa4 --- /dev/null +++ b/docs/RELEASE-0.13.0.md @@ -0,0 +1,77 @@ +# BricksSnap 0.13.0: Mobile menus, carousels, motion and class updates + +0.13.0 closes a real gap in every header and adds two patterns many sites need. It also lets sites that installed an earlier BricksSnap release receive improvements. Everything is built on Bricks' own elements and features and checked live on a Bricks 2.4.2 test site. + +## 1. A real mobile menu in every header + +Until 0.12, headers hid their links below the tablet breakpoint and offered no menu. Now every header with links uses **Bricks' nestable nav**: + +- **Structure.** Bricks creates the same structure in the builder: a "Nav items" list with the links, a close toggle inside it, and an open toggle after it. We read it from a test page. +- **Behavior.** Below the tablet breakpoint (991 px), a hamburger opens the menu as a full-screen overlay. It turns into an X, and the header button moves into the menu on phones. +- **Kit colors.** The overlay uses the kit's background and heading colors instead of Bricks' fixed white, so it also works in dark mode. + +## 2. Carousels + +**Reviews** and **Portfolio** each get a carousel layout on **Bricks' nested slider** (Splide): + +- **Layout.** Two or three cards side by side, two on tablets, one on phones. +- **Navigation.** Visitors swipe or use the dots below. The dots are buttons and also work by keyboard. +- **Options.** They go in as Splide JSON, because Bricks does not accept breakpoint values on its slider controls. + +## 3. Motion + +A new brand kit option, **Motion: Fade in**, makes the content of a section fade up once as it scrolls into view: + +- **What moves:** intros, cards, list items and images, slightly staggered. +- **How:** with Bricks interactions (`enterView` → `startAnimation`), so you can edit or remove it in the builder. +- **What stays still:** + - headers, heroes, footers and single-purpose pages, which are on screen at once; + - content inside Bricks' tabs, accordion, slider, nav and forms. +- **Accessibility:** visitors who ask their system for reduced motion see everything at once. + +The option is also available to MCP clients (`kit.motion`). + +## 4. Update outdated BricksSnap classes + +A site keeps its `bs-` class definitions. Before 0.13, BricksSnap reported newer definitions as different and never wrote them, so improvements never reached existing sites. + +**Update N BricksSnap classes** (in Staging, after **Create missing global classes**) now brings them up to date: + +- **Scope.** Only `bs-` classes are changed, everywhere on the site. +- **Guarded.** Each write carries the class's ownership and the class lock ownership. A class changed in between is refused, and earlier updates stay undoable. +- **Exact.** Bricks merges class settings, so keys the new definition drops are removed explicitly, nested ones too. +- **Verified and undoable.** The result is read back and compared, and **Undo the update** restores the previous definitions. + +The update needs the `update-global-class` ability. + +**Fix.** Bricks reformats custom CSS when it stores a class: line breaks, indentation, and no spaces around `>`. BricksSnap took that for a different definition. Comparisons now ignore CSS layout. + +## Verified + +- **Automated:** 262 tests. + - The mobile menu structure in every header. + - Motion only where intended, with the reduced-motion rule. + - Slider options and breakpoints. + - The update patch, nested keys included. + - Guarded update, verify and undo, plus a concurrent edit, against a stand-in for Bricks 2.4.2. +- **Live on the Bricks 2.4.2 test site:** + + | Check | Result | + |---|---| + | Mobile menu at 390 px | hamburger opens the overlay with links and button; X closes it | + | Header at 1280 px | links in the bar, no toggle | + | Carousels | 1 slide at 390 px; 2 or 3 at 1280 px | + | Motion | content below the fold fades in on scroll | + | Reduced motion | everything visible at once | + | Class update | outdated → update → undo → update, each verified by read-back | + | 0.12's FAQ accordion and pricing switch | open and switch as expected | + | JavaScript errors | none | + +## Thanks + +Two MIT-licensed references helped us check Bricks' element structures: + +- [bricks-skills](https://github.com/wpgaurav/bricks-skills) by Gaurav Tiwari; +- [bricks-builder-skill](https://github.com/kenming/bricks-builder-skill) by Kenming Wang. + +Every structure in this release was then confirmed on Bricks 2.4.2 itself. diff --git a/docs/REMOTE-LIBRARY.md b/docs/REMOTE-LIBRARY.md index 9fc071c..cef8a53 100644 --- a/docs/REMOTE-LIBRARY.md +++ b/docs/REMOTE-LIBRARY.md @@ -2,10 +2,10 @@ A BricksSnap deployment can act as a **Remote Library** source in Bricks. Bricks users browse the design library in the builder and insert pages and sections like templates from any other Bricks site. No JSON download is needed. -Since 0.9.0 the library serves the Studio's designs instead of the older classic catalog, in German and English (206 templates since 0.12.0): +Since 0.9.0 the library serves the Studio's designs instead of the older classic catalog, in German and English (210 templates since 0.13.0): - **Pages.** Each of the 13 designs (for example "Nord", "Trattoria", "Lachfalte") as a complete page. -- **Sections.** All 90 layouts as single sections, in the "Fundament" design. +- **Sections.** All 92 layouts as single sections, in the "Fundament" design. Styling travels as `bs-` global classes. Class IDs are the same in every design, so sections inserted later take on the look of the first design the site received. To change the look everywhere, install the design system from the Studio (see [Studio](STUDIO.md)). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5970367..46b9c01 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -75,6 +75,12 @@ Delivered in 0.12.0 (see [release notes](RELEASE-0.12.0.md)): - **Layouts.** 90 instead of 40, with Bricks' nested tabs and accordion where a section switches or folds. - **Not included.** Importing from template libraries directly. BricksSnap does not fetch or redistribute other people's templates. +## v0.13 — Mobile menus, carousels, motion and class updates + +Delivered in 0.13.0 (see [release notes](RELEASE-0.13.0.md)): +- **Bricks-native interaction.** Headers use the nestable nav with a mobile menu; carousels use the nested slider; optional motion uses interactions. Each structure was read from or confirmed on Bricks 2.4.2. +- **Class updates.** Outdated `bs-` classes can be updated site-wide with guards, read-back and undo. + ## Later: cooperation instead of copying - **"Open in BricksSnap".** Template libraries that want it could link their sections to the Modernize tab, so users bring a section onto their own design system with one click. This needs the library's consent and a small, documented hand-over (for example a URL with the JSON or a postMessage from a library page). diff --git a/docs/STUDIO.md b/docs/STUDIO.md index 1a6d9d2..f8cef2a 100644 --- a/docs/STUDIO.md +++ b/docs/STUDIO.md @@ -24,6 +24,7 @@ The panel on the left sets how every layout looks. All previews update live. On - **Second color.** An optional highlight color. - **Fonts.** Eight Google font pairs, or system fonts without web fonts. - **Corners, spacing, mode.** Corners from none to round; spacing compact, normal or airy; light or dark. +- **Motion (0.13).** **Fade in** lets intros, cards, list items and images of content sections fade up once as they scroll into view, slightly staggered. It uses Bricks interactions, so you edit or remove it in the builder. Headers, heroes, footers and single-purpose pages stay still, and visitors who ask their system for reduced motion see everything at once. ## 2. Business @@ -35,8 +36,10 @@ The texts are templates: read and adjust them before publishing. Nothing leaves ## 3. Sections -The **Sections** view shows all 90 layouts in 22 section types, grouped by type and rendered live at 1280 px. The section types are header, hero, services, benefits, steps, numbers, pricing, reviews, team, portfolio, timeline, about, FAQ, blog, logos, gallery, call to action, contact, footer, login, 404 and coming soon. Since 0.12 each type has at least two layouts, among them bento grids, a pricing switch, service tabs, a FAQ accordion, a quote wall, a logo marquee and masonry galleries. +The **Sections** view shows all 92 layouts in 22 section types, grouped by type and rendered live at 1280 px. The section types are header, hero, services, benefits, steps, numbers, pricing, reviews, team, portfolio, timeline, about, FAQ, blog, logos, gallery, call to action, contact, footer, login, 404 and coming soon. Since 0.12 each type has at least two layouts, among them bento grids, a pricing switch, service tabs, a FAQ accordion, a quote wall, a logo marquee and masonry galleries. +- **Mobile menu (0.13).** Every header with links uses Bricks' nestable nav. Below the tablet breakpoint a hamburger opens the menu as a full-screen overlay in the kit's colors; on phones the header button moves into the menu. +- **Carousels (0.13).** Reviews and portfolio come as carousels on Bricks' nested slider: two or three cards side by side, one on phones, navigated by swipe and the dots below. - **Tabs and accordion.** The pricing switch and the service tabs use Bricks' nested tabs. The FAQ accordion uses Bricks' nested accordion with FAQ schema. Bricks' own script switches them; the preview shows the first tab open and the answers closed. Edit each tab and answer in the builder. For monthly and yearly prices, rename the switch's tabs (**Packages / Single prices**) and enter your prices in the panes. - **Copy.** Copies a single section. Paste it in the Bricks editor with Ctrl+V. @@ -104,6 +107,6 @@ The MCP server offers three Studio tools: - **`bricks_kit_options`** lists style directions, font pairs, industries and every section type with its layouts. - **`bricks_modernize_template`** rebuilds Bricks JSON on the design system (see [Modernize](MODERNIZE.md)). -- **`bricks_kit_page`** builds sections or a page. It takes `sections` (type and optional `variant`), an optional `kit` (style, primary, accent, fonts, radius, spacing, mode) and an optional `profile` (industry, language, name, city, phone, email, address, tagline, services). It returns the Bricks import object, the design system and the quality checks. +- **`bricks_kit_page`** builds sections or a page. It takes `sections` (type and optional `variant`), an optional `kit` (style, primary, accent, fonts, radius, spacing, mode, motion) and an optional `profile` (industry, language, name, city, phone, email, address, tagline, services). It returns the Bricks import object, the design system and the quality checks. For example: "Use BricksSnap to build a homepage for a vehicle registration service in Bremen in the Warm style with a green brand color, in German, and give me the Bricks JSON." diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index e4da056..c3824dc 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -11,7 +11,7 @@ BricksSnap can read a live Bricks page and the site's global classes through the - required: `get-page-elements` and `get-design-context`; - also used: `find-post`, `get-page-settings`, `list-global-classes`, `list-color-palettes`, `get-mcp-version`, `list-ability-status`, `render-elements` (for the rendered preview), `list-templates` and `get-template-settings` (for site templates), and `list-global-variables` (for the Studio design system). - Saving additionally needs `set-page-elements`, restoring needs `restore-revision`, importing images needs `upload-media` and `find-media`, creating missing classes needs `batch-create-global-classes`, creating templates needs `create-template`, changing template conditions needs `set-template-conditions`, installing the Studio design system needs `create-color-palette`, `create-color`, `update-color`, `set-global-variable-categories` and `set-global-variables`, and undoing or uninstalling it also needs `delete-global-variable`, `delete-color` and `delete-color-palette`. Keep them disabled if you only want to read. + Saving additionally needs `set-page-elements`, restoring needs `restore-revision`, importing images needs `upload-media` and `find-media`, creating missing classes needs `batch-create-global-classes`, updating outdated BricksSnap classes needs `update-global-class`, creating templates needs `create-template`, changing template conditions needs `set-template-conditions`, installing the Studio design system needs `create-color-palette`, `create-color`, `update-color`, `set-global-variable-categories` and `set-global-variables`, and undoing or uninstalling it also needs `delete-global-variable`, `delete-color` and `delete-color-palette`. Keep them disabled if you only want to read. ## Connect @@ -106,6 +106,17 @@ Sections from the catalog or another site can use global classes the connected s - If the site uses a staged class's ID for a class with another name, the staged class is created under a new ID and the elements follow it. Saving the page or a template is refused until then, so an element never silently picks up a foreign class. - If the site has the same ID and name with another definition (for example the same `bs-` class from another BricksSnap design), the site's class is kept and reported: the page shows the site's version. - **Undefined references.** Referenced IDs without a definition in the change are listed; remove them or add their definitions. +- **Layout is not a difference.** Bricks reformats custom CSS when it stores a class (line breaks, indentation, no spaces around `>`). Since 0.13 such differences no longer count as another definition. + +### Update outdated BricksSnap classes (0.13) + +When the site has `bs-` classes in another version (an older BricksSnap release or another design), the result of **Create missing global classes** offers **Update N BricksSnap classes** (`update-global-class` must be enabled). + +- **Only BricksSnap's classes.** Only `bs-` classes are changed; the server refuses other names. +- **Site-wide.** The classes change everywhere on the site, also on pages that already use them. +- **Guarded.** Each class is written with its item ownership and the class lock ownership from the read. If someone changed a class in between, Bricks refuses; earlier updates stay and can be undone. +- **Exact.** Bricks merges class settings, so keys the new definition drops are removed explicitly, nested ones too. +- **Verified and undoable.** The classes are read back and compared. **Undo the update** puts the previous definitions back the same way. - **Removing a class later.** Created classes stay when a page is restored. Remove unused ones in Bricks → Global classes. ## Local and staging sites diff --git a/package-lock.json b/package-lock.json index 1fc5550..4f2cd27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "brickssnap", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "brickssnap", - "version": "0.12.0", + "version": "0.13.0", "license": "PolyForm-Shield-1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.1", diff --git a/package.json b/package.json index 1394eba..8460740 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "brickssnap", - "version": "0.12.0", + "version": "0.13.0", "scripts": { "dev": "next dev --webpack", "dev:local": "node scripts/dev-local.mjs", diff --git a/src/app/api/wordpress/route.ts b/src/app/api/wordpress/route.ts index f3bf200..fd73123 100644 --- a/src/app/api/wordpress/route.ts +++ b/src/app/api/wordpress/route.ts @@ -13,7 +13,7 @@ export async function POST(request: Request) { const result = await handleWordPressRequest( body, // Writes read back afterwards; image imports download up to 30 files. - AbortSignal.any([request.signal, AbortSignal.timeout(body.action === "media" ? 180_000 : ["apply", "restore", "classes", "set-conditions", "create-template"].includes(body.action) ? 60_000 : 30_000)]) + AbortSignal.any([request.signal, AbortSignal.timeout(body.action === "media" ? 180_000 : ["apply", "restore", "classes", "update-classes", "set-conditions", "create-template"].includes(body.action) ? 60_000 : 30_000)]) ); return Response.json(result, { headers: { "Cache-Control": "no-store" } }); } catch (error) { diff --git a/src/components/WordPressPrepare.tsx b/src/components/WordPressPrepare.tsx index 8771992..204c9ad 100644 --- a/src/components/WordPressPrepare.tsx +++ b/src/components/WordPressPrepare.tsx @@ -2,9 +2,9 @@ import { useState } from "react"; import type { BricksTemplate } from "@/lib/bricks-engine"; -import type { WordPressClassesResult, WordPressCredentials, WordPressMediaResult } from "@/lib/wordpress-contract"; +import type { WordPressClassesResult, WordPressClassUpdateResult, WordPressCredentials, WordPressMediaResult } from "@/lib/wordpress-contract"; import { findExternalImages } from "@/lib/template-images"; -import { referencedClassIds } from "@/lib/template-classes"; +import { isBricksSnapClass, referencedClassIds } from "@/lib/template-classes"; import { postWordPress } from "@/lib/wordpress-request"; interface WordPressPrepareProps { @@ -25,6 +25,10 @@ const button = "rounded-lg px-4 py-2 text-sm font-medium border border-border di export default function WordPressPrepare({ credentials, proposal, onProposalChange, knownClassIds, classesMissing }: WordPressPrepareProps) { const [mediaResult, setMediaResult] = useState(null); const [classResult, setClassResult] = useState(null); + // The change's own definitions of BricksSnap classes the site has in another version. + const [outdated, setOutdated] = useState }>>([]); + const [updateResult, setUpdateResult] = useState(null); + const [undone, setUndone] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const siteHost = new URL(credentials.endpoint).hostname; @@ -45,12 +49,28 @@ export default function WordPressPrepare({ credentials, proposal, onProposalChan setMediaResult(result); if (result.imported.length) onProposalChange(result.template); }); - const createClasses = () => run("classes", result => { - setClassResult(result); - if (result.created.length || result.reused.length || result.remapped.length) onProposalChange(result.template); + const createClasses = () => { + const staged = proposal.globalClasses ?? []; + return run("classes", result => { + setClassResult(result); + setOutdated(result.mismatched.filter(c => isBricksSnapClass(c.name)).flatMap(c => staged.filter(d => d.name === c.name).map(d => ({ name: d.name, settings: d.settings ?? {} })))); + setUpdateResult(null); setUndone(false); + if (result.created.length || result.reused.length || result.remapped.length) onProposalChange(result.template); + }); + }; + async function updateClasses(classes: Array<{ name: string; settings: Record }>, onResult: (result: WordPressClassUpdateResult) => void) { + setBusy(true); setError(""); + try { onResult(await postWordPress({ action: "update-classes", credentials, classes, confirm: true })); } + catch (e) { setError(e instanceof Error ? e.message : "The request failed."); } + finally { setBusy(false); } + } + const applyUpdate = () => updateClasses(outdated, result => { + setUpdateResult(result); setUndone(false); + if (result.updated.length) onProposalChange({ ...proposal, globalClasses: (proposal.globalClasses ?? []).map(c => { const next = outdated.find(o => o.name === c.name); return next ? { ...c, settings: next.settings } : c; }) }); }); + const undoUpdate = () => updateResult && updateClasses(updateResult.previous, result => { if (!result.error) setUndone(true); else setError(result.error); }); - if (!externalImages.length && !classesOpen && !mediaResult && !classResult && !error) return null; + if (!externalImages.length && !classesOpen && !mediaResult && !classResult && !updateResult && !error) return null; return

Before saving to {siteHost}

{mediaResult &&
@@ -65,9 +85,22 @@ export default function WordPressPrepare({ credentials, proposal, onProposalChan

Global classes: {classResult.created.length} created{classResult.created.length ? ` (${classResult.created.map(c => c.name).join(", ")})` : ""}, {classResult.reused.length} identical site {classResult.reused.length === 1 ? "class" : "classes"} reused.

{classResult.conflicts.map(c =>

Not created: {c.name} {c.siteId ? "exists on the site with a different definition. Rename it in the staged JSON, or use the site's class." : "is defined twice in the change."}

)} {classResult.remapped.map(c =>

New ID for {c.name}: the site already used {c.id} for {c.siteName}, so it was created as {c.newId}.

)} - {classResult.mismatched.map(c =>

Kept the site's {c.name}: it has a different definition on the site (for example from another BricksSnap design). The page shows the site's version.

)} + {classResult.mismatched.filter(c => !isBricksSnapClass(c.name)).map(c =>

Kept the site's {c.name}: it has a different definition on the site. The page shows the site's version.

)} {classResult.undefinedIds.length > 0 &&

No definition in the change for class IDs {classResult.undefinedIds.join(", ")}. Remove them from the elements or add their definitions.

}
} + {outdated.length > 0 && !updateResult &&
+

{outdated.length} BricksSnap {outdated.length === 1 ? "class has" : "classes have"} another version on the site (an older BricksSnap release or another design): {outdated.map(c => c.name).join(", ")}. The page shows the site's version until you update {outdated.length === 1 ? "it" : "them"}.

+

Updating changes these classes everywhere on the site, also on pages that already use them. Only bs- classes are changed, each write is guarded, the result is read back, and you can undo it.

+ +
} + {updateResult &&
+ {undone + ?

Undone: {updateResult.previous.length} {updateResult.previous.length === 1 ? "class is" : "classes are"} back on the site's earlier version.

+ :

BricksSnap classes: {updateResult.updated.length} updated{updateResult.updated.length ? ` (${updateResult.updated.map(c => c.name).join(", ")})` : ""}{updateResult.unchanged.length ? `, ${updateResult.unchanged.length} already current` : ""}{updateResult.updated.length ? (updateResult.verified ? ", verified." : ", but the read-back differs. Check the classes in Bricks.") : "."}

} + {updateResult.missing.length > 0 &&

Not on the site: {updateResult.missing.join(", ")}.

} + {updateResult.error &&

{updateResult.error}

} + {!undone && updateResult.previous.length > 0 && } +
} {classesOpen &&

{newClasses.length > 0 ? `This change uses global classes that may not exist on the site yet: ${newClasses.join(", ")}.` diff --git a/src/components/kit/KitControls.tsx b/src/components/kit/KitControls.tsx index af29c91..0138a90 100644 --- a/src/components/kit/KitControls.tsx +++ b/src/components/kit/KitControls.tsx @@ -84,6 +84,7 @@ export default function KitControls({ kit, onKit, profile, onProfile, showBusine ({ id, label: RADIUS_LABELS[id] }))} onChange={radius => onKit({ ...kit, radius })}/> ({ id, label: SPACING_LABELS[id] }))} onChange={spacing => onKit({ ...kit, spacing })}/> onKit({ ...kit, mode })}/> + onKit({ ...kit, motion: motion === "subtle" ? "subtle" : undefined })}/>

{showBusiness &&
diff --git a/src/lib/bricks-validator.ts b/src/lib/bricks-validator.ts index ae3def2..c1e87b7 100644 --- a/src/lib/bricks-validator.ts +++ b/src/lib/bricks-validator.ts @@ -9,7 +9,9 @@ import schema from "../data/bricks-schema.json"; 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"]); -const controlSets = new Map(Object.entries(schema.elements).map(([name, element]) => [name, new Set([...schema.commonControls, ...element.controls, ...metaControls])])); +// Controls the published schema leaves out, verified against Bricks 2.4.2's rendered output. +const verifiedControls: Record = { "nav-nested": ["ariaLabel", "mobileMenu"] }; +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. const BRICKS_ID_RE = /^[a-z0-9]{6}$/; diff --git a/src/lib/kit/generate.ts b/src/lib/kit/generate.ts index aab312d..1af065f 100644 --- a/src/lib/kit/generate.ts +++ b/src/lib/kit/generate.ts @@ -4,6 +4,7 @@ import { flatten, globalClassesFor, classId } from "./build"; import { contentFor, INDUSTRIES, INDUSTRY_IDS, type BusinessProfile, type Content } from "./content"; import { findVariant, isSectionType, classLibrary, SECTION_TYPES, variantsFor, type SectionType } from "./sections"; import { DESIGNS } from "./library"; +import { addMotion } from "./motion"; import { sx } from "./styles"; import { COLOR_TOKENS, contrastChecks, FONT_PAIR_IDS, FONT_PAIRS, RADIUS_IDS, resolveKit, SPACING_IDS, STYLE_IDS, STYLES, type BrandKit, type ResolvedKit } from "./tokens"; @@ -91,7 +92,10 @@ export function generateKitTemplate(input: { kit: Partial; profile: Pa const picks = input.sections.map(s => (typeof s === "string" ? { type: s } : s)).filter(s => isSectionType(s.type)); if (!picks.length) throw new Error("Choose at least one section."); if (picks.length > 16) throw new Error("Choose at most 16 sections."); - const roots = picks.map(pick => findVariant(pick.type, pick.variant).build(ctx)); + const roots = picks.map(pick => { + const root = findVariant(pick.type, pick.variant).build(ctx); + return resolved.kit.motion === "subtle" ? addMotion(root, pick.type) : root; + }); const { elements, classNames } = flatten(roots); // Two sections of one kind (e.g. two service layouts) would share an anchor; keep IDs unique. const anchors = new Map(); diff --git a/src/lib/kit/motion.ts b/src/lib/kit/motion.ts new file mode 100644 index 0000000..0c438e3 --- /dev/null +++ b/src/lib/kit/motion.ts @@ -0,0 +1,48 @@ +import type { ClassLibrary, KitNode } from "./build"; +import type { SectionType } from "./sections"; + +/** Section types whose content fades in. Headers, heroes, footers and single-purpose pages stay still: they are on screen at once. */ +const ANIMATED: ReadonlySet = new Set(["services", "features", "steps", "stats", "pricing", "testimonials", "team", "portfolio", "timeline", "content", "faq", "blog", "logos", "gallery", "cta", "contact"]); + +/** Blocks that enter as one: intros, cards, list items, images. */ +const TARGETS = new Set([ + "bs-intro", "bs-intro--center", "bs-card", "bs-plan", "bs-plan-row", "bs-photo-card", "bs-post", "bs-post-row", "bs-post-line", "bs-work", "bs-work-row", "bs-work-tile", + "bs-member", "bs-member-row", "bs-team-card", "bs-quote-feature", "bs-icon-item", "bs-num-item", "bs-vstep", "bs-hstep", "bs-stat", "bs-htimeline__item", "bs-timeline__item", + "bs-faq__item", "bs-svc-row", "bs-row", "bs-menu__row", "bs-media", "bs-gallery__item", "bs-gbento__cell", "bs-masonry__img", "bs-offer", "bs-cta-box", "bs-cta-card", "bs-cta-lines", "bs-form-card", +]); + +/** Bricks' own widgets show and hide their children; their content is not animated. */ +const SKIP = new Set(["tabs-nested", "accordion-nested", "slider-nested", "nav-nested", "form"]); + +let counter = 0; +const interactionId = () => (++counter).toString(36).padStart(6, "0").slice(-6); + +/** + * "Subtle" motion: matching blocks fade up once as they scroll into view, siblings slightly + * staggered. Uses Bricks interactions (enterView → startAnimation), which hide the element until + * it animates; bs-motion keeps it visible and still for visitors who prefer reduced motion. + */ +export function addMotion(root: KitNode, type: SectionType): KitNode { + if (!ANIMATED.has(type)) return root; + const visit = (node: KitNode, index: number): KitNode => { + if (SKIP.has(node.name)) return node; + const target = (node.classes ?? []).some(c => TARGETS.has(c)); + const children = target ? node.children : node.children?.map((child, i) => visit(child, i)); + if (!target) return { ...node, children }; + return { + ...node, + classes: [...(node.classes ?? []), "bs-motion"], + settings: { + ...(node.settings ?? {}), + _interactions: [{ id: interactionId(), trigger: "enterView", action: "startAnimation", target: "self", animationType: "fadeInUp", animationDuration: "0.7s", animationDelay: `${Math.min(index, 3) * 0.08}s`, runOnce: true }], + }, + }; + }; + return visit(root, 0); +} + +export function motionClasses(): ClassLibrary { + return { + "bs-motion": { _cssCustom: "@media (prefers-reduced-motion: reduce) {\n .bs-motion { opacity: 1 !important; visibility: visible !important; animation: none !important; }\n}" }, + }; +} diff --git a/src/lib/kit/preview.ts b/src/lib/kit/preview.ts index 042bb56..7147d22 100644 --- a/src/lib/kit/preview.ts +++ b/src/lib/kit/preview.ts @@ -230,10 +230,29 @@ export function renderPreview(template: Pick c === "tab-title" || c === "tab-pane") ? ["brx-open"] : []; const classes = [`brxe-${el.name}`, ...hidden, ...open, ...(Array.isArray(s._cssGlobalClasses) ? s._cssGlobalClasses.map(id => classNames.get(String(id))).filter(Boolean) : [])]; const attrs = `id="${esc(idAttr)}" class="${esc(classes.join(" "))}"${Array.isArray(s._attributes) ? (s._attributes as Array<{ name?: string; value?: string }>).filter(a => a.name && /^(aria-[\w-]+|role|data-[\w-]+)$/.test(a.name)).map(a => ` ${a.name}="${esc(String(a.value ?? ""))}"`).join("") : ""}`; + // Bricks renders a nav list's items as ` : markup; }; const html = elements.filter(el => el.parent === 0).map(render).join(""); @@ -275,6 +295,21 @@ const BASE_CSS = `@layer bricks { .bsp .brxe-form .form-group { width: 100%; margin-bottom: 12px; } .bsp .brxe-form .bsp-check { display: flex; gap: 8px; align-items: baseline; width: 100%; margin-bottom: 12px; font-size: 14px; } .bsp .brxe-form .bsp-check input { width: auto; } +.bsp .brxe-nav-nested { display: inline-flex; align-items: center; } +.bsp .brx-nav-nested-items { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 30px; list-style-type: none; margin: 0; padding: 0; } +.bsp .brxe-nav-nested .brxe-toggle { display: none; } +.bsp .brxe-toggle { --brxe-toggle-scale: 1; --brxe-toggle-bar-height: 4px; --brxe-toggle-bar-width: 40px; --brxe-toggle-bar-radius: 4px; background: none; border: 0; padding: 0; color: inherit; cursor: pointer; transform: scale(var(--brxe-toggle-scale)); } +.bsp .brxa-wrap { display: inline-block; height: 24px; position: relative; width: 40px; } +.bsp .brxa-inner { display: block; margin-top: -2px; top: 50%; } +.bsp .brxa-inner, .bsp .brxa-inner::before, .bsp .brxa-inner::after { background-color: currentcolor; border-radius: var(--brxe-toggle-bar-radius); height: var(--brxe-toggle-bar-height); width: var(--brxe-toggle-bar-width); position: absolute; } +.bsp .brxa-inner::before, .bsp .brxa-inner::after { content: ""; display: block; } +.bsp .brxa-inner::before { top: -10px; } +.bsp .brxa-inner::after { bottom: -10px; } +.bsp .brxe-slider-nested { position: relative; width: 100%; min-width: 0; } +.bsp .bsp-slides > * { min-width: 0; } +.bsp .bsp-dots { position: absolute; left: 0; right: 0; bottom: 16px; display: flex; justify-content: center; gap: 10px; } +.bsp .bsp-dots span { width: 10px; height: 10px; border-radius: 50%; background: currentColor; opacity: .25; } +.bsp .bsp-dots span.is-active { opacity: 1; } .bsp .brxe-tabs-nested { display: flex; flex-direction: column; width: 100%; } .bsp .brxe-tabs-nested .tab-menu { display: flex; } .bsp .brxe-tabs-nested .tab-title { cursor: pointer; } diff --git a/src/lib/kit/sections/common.ts b/src/lib/kit/sections/common.ts index 854dab7..f0f6d45 100644 --- a/src/lib/kit/sections/common.ts +++ b/src/lib/kit/sections/common.ts @@ -98,6 +98,24 @@ export function accordion(items: Array<{ title: string; body: KitNode[] }>, labe ], item.title)), label); } +/** + * Bricks' nested slider (Splide). Splide options go in as custom JSON: Bricks refuses breakpoint + * keys on its slider controls, while Splide's own breakpoints work (verified on Bricks 2.4.2). + * Every child becomes a slide. Navigation is by swipe and the dots (buttons, also by keyboard): + * Splide's arrows sit on top of the slides' content at narrow widths. + */ +export function slider(ctx: Ctx, slides: KitNode[], o: { perPage: number; label: string }): KitNode { + const { x } = ctx; + const options = { + type: "loop", perPage: o.perPage, perMove: 1, gap: x.v("space-m"), autoHeight: true, arrows: false, pagination: true, + breakpoints: { 991: { perPage: Math.min(o.perPage, 2) }, 767: { perPage: 1 } }, + }; + return node("slider-nested", "bs-slider", { + optionsType: "custom", options: JSON.stringify(options), + paginationColor: x.color("border-strong"), paginationColorActive: x.color("primary"), + }, slides, o.label); +} + /** Classes used by the helpers above beyond the shared library. */ export function commonClasses(r: ResolvedKit): ClassLibrary { const x = sx(r); @@ -127,6 +145,9 @@ export function commonClasses(r: ResolvedKit): ClassLibrary { "bs-tabs__content": { _width: "100%" }, "bs-tabs__pane": { _width: "100%" }, + // Slider: room below the slides for Splide's dots. + "bs-slider": { _padding: { top: "0", right: "0", bottom: "48px", left: "0" }, _cssCustom: ".bs-slider .splide__slide > * { width: 100%; height: 100%; }" }, + // Accordion "bs-acc": { _width: "100%", _widthMax: "860px", _margin: { left: "auto", right: "auto" } }, "bs-acc__item": { _width: "100%", _border: { width: { top: "0", right: "0", bottom: "1px", left: "0" }, style: "solid", color: x.color("border") }, _cssCustom: `.bs-acc__item:first-child { border-top: 1px solid ${x.v("border")}; }` }, diff --git a/src/lib/kit/sections/conversion-plus.ts b/src/lib/kit/sections/conversion-plus.ts index 68bc5ba..68e90b0 100644 --- a/src/lib/kit/sections/conversion-plus.ts +++ b/src/lib/kit/sections/conversion-plus.ts @@ -2,7 +2,7 @@ import { button, div, heading, icon, image, node, text, textLink, type KitNode } import { PHOTOS } from "../images"; import { merge, sx } from "../styles"; import type { ResolvedKit } from "../tokens"; -import { accordion, actions, band, checklist, infoRow, intro, stars, tabs, type Ctx, type Variant } from "./common"; +import { accordion, actions, band, checklist, infoRow, intro, slider, stars, tabs, type Ctx, type Variant } from "./common"; import { planCard, quoteCard } from "./conversion"; import { telHref } from "./header-hero"; import { form } from "./more"; @@ -159,6 +159,14 @@ export const testimonialPlusVariants: Variant[] = [ }, ]; +testimonialPlusVariants.push({ + type: "testimonials", id: "carousel", name: { de: "Karussell", en: "Carousel" }, classes: convertPlusClasses, + build: ctx => band({ surface: "page", label: "Testimonials" }, [ + intro({ eyebrow: ctx.c.testimonials.eyebrow, title: ctx.c.testimonials.title, center: true }), + slider(ctx, ctx.c.testimonials.items.map(item => quoteCard(ctx, item)), { perPage: 2, label: "Quotes" }), + ]), +}); + export const faqPlusVariants: Variant[] = [ { type: "faq", id: "accordion", name: { de: "Aufklappbar", en: "Accordion" }, classes: convertPlusClasses, diff --git a/src/lib/kit/sections/header-hero-plus.ts b/src/lib/kit/sections/header-hero-plus.ts index e7a9dde..6299857 100644 --- a/src/lib/kit/sections/header-hero-plus.ts +++ b/src/lib/kit/sections/header-hero-plus.ts @@ -11,7 +11,7 @@ function headerPlusClasses(r: ResolvedKit) { const x = sx(r); return { // Centred logo: navigation and actions share the free space, so the brand sits in the middle. - "bs-nav--grow": { _flexGrow: "1", _flexBasis: "0" }, + "bs-mainnav--grow": { _flexGrow: "1", _flexBasis: "0", "_justifyContent:tablet_portrait": "flex-end" }, "bs-header__actions--grow": { _flexGrow: "1", _flexBasis: "0", _justifyContent: "flex-end" }, "bs-header__pill": merge(x.bg("surface"), x.line("1px", "border"), x.round("999px"), x.pad("10px", "10px", "10px", "24px"), x.shadow("10px", "30px", "-18px")), }; @@ -24,9 +24,9 @@ export const headerPlusVariants: Variant[] = [ { type: "navbar", id: "centered", name: { de: "Logo in der Mitte", en: "Centred logo" }, classes: headerPlusClasses, build: ctx => bar(ctx, [ - { ...navLinks(ctx), classes: ["bs-nav", "bs-nav--grow"] }, + navLinks(ctx, "bs-mainnav bs-mainnav--grow"), brand(ctx), - div("bs-header__actions bs-header__actions--grow", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), + div("bs-header__actions bs-header__actions--grow bs-header__actions--collapse", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), ], { classes: "bs-header" }), }, { @@ -34,7 +34,7 @@ export const headerPlusVariants: Variant[] = [ build: ctx => bar(ctx, [ brand(ctx), navLinks(ctx), - div("bs-header__actions", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), + div("bs-header__actions bs-header__actions--collapse", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), ], { surface: "alt", containerClasses: "bs-header__pill" }), }, { @@ -42,7 +42,7 @@ export const headerPlusVariants: Variant[] = [ build: ctx => bar(ctx, [ brand(ctx), navLinks(ctx), - div("bs-header__actions", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--on-dark")]), + div("bs-header__actions bs-header__actions--collapse", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--on-dark")]), ], { surface: "inverse" }), }, ]; diff --git a/src/lib/kit/sections/header-hero.ts b/src/lib/kit/sections/header-hero.ts index 966b21f..c33f18c 100644 --- a/src/lib/kit/sections/header-hero.ts +++ b/src/lib/kit/sections/header-hero.ts @@ -2,6 +2,7 @@ import { button, div, heading, icon, node, text, textLink, type KitNode } from " import { PHOTOS } from "../images"; import { merge, sx } from "../styles"; import type { ResolvedKit } from "../tokens"; +import type { Link } from "../content"; import { actions, band, checklist, photo, stars, type Ctx, type Variant } from "./common"; export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; @@ -9,8 +10,22 @@ export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; export function brand(ctx: Ctx): KitNode { return textLink(ctx.c.brand.name, "/", "bs-brand"); } -export function navLinks(ctx: Ctx): KitNode { - return node("div", "bs-nav", { tag: "nav", _attributes: [{ name: "aria-label", value: ctx.c.lang === "de" ? "Hauptnavigation" : "Main navigation" }] }, ctx.c.nav.map(link => textLink(link.label, link.href, "bs-nav__link")), "Navigation"); +/** + * Bricks' nestable nav with its mobile menu, in the structure Bricks 2.4 creates: a "Nav items" + * list (brx-nav-nested-items) with the links and a close toggle (brx-toggle-div), then the open + * toggle. Below the tablet breakpoint Bricks shows the toggle and opens the list as an overlay. + */ +export function navLinks(ctx: Ctx, classes = "bs-mainnav", cta: Link | null = ctx.c.navCta): KitNode { + const de = ctx.c.lang === "de"; + return node("nav-nested", classes, { ariaLabel: de ? "Hauptnavigation" : "Main navigation", mobileMenu: "tablet_portrait" }, [ + node("block", "bs-mainnav__items", { tag: "ul", _hidden: { _cssClasses: "brx-nav-nested-items" } }, [ + ...ctx.c.nav.map(link => textLink(link.label, link.href, "bs-nav__link")), + // On phones the header button moves into the open menu. + cta ? button(cta.label, cta.href, "bs-btn bs-btn-size--m bs-btn--primary bs-mainnav__cta") : null, + node("toggle", "bs-mainnav__toggle bs-mainnav__close", { ariaLabel: de ? "Menü schließen" : "Close menu", animation: "squeeze", _hidden: { _cssClasses: "brx-toggle-div" } }, [], "Toggle (close: mobile)"), + ], "Nav items"), + node("toggle", "bs-mainnav__toggle bs-mainnav__open", { ariaLabel: de ? "Menü öffnen" : "Open menu", animation: "squeeze" }, [], "Toggle (open: mobile)"), + ], "Navigation"); } function headerClasses(r: ResolvedKit) { @@ -25,9 +40,23 @@ function headerClasses(r: ResolvedKit) { x.font("bs-brand", "heading", " text-wrap: balance; overflow-wrap: break-word;"), { _flexShrink: "1", _widthMin: "0", "_typography:mobile_portrait": { "font-size": x.v("text-m") } }, ), - "bs-nav": { _display: "flex", _direction: "row", _columnGap: "clamp(18px, 2.4vw, 36px)", _alignItems: "center", "_display:tablet_portrait": "none" }, + // Mobile menu: Bricks shows the toggles and the overlay below the tablet breakpoint. These + // classes never set display, so Bricks' own show/hide rules stay in charge. + "bs-mainnav": { "_order:tablet_portrait": "3" }, + "bs-mainnav__items": { + _columnGap: "clamp(18px, 2.4vw, 36px)", + _cssCustom: `.bs-mainnav.brx-open .bs-mainnav__items { background-color: ${x.v("bg")}; row-gap: ${x.v("space-m")}; align-items: stretch; padding: 88px ${x.v("gutter")} ${x.v("space-l")}; }\n.bs-mainnav.brx-open .bs-nav__link { font-size: ${x.v("text-xl")}; color: ${x.v("heading")}; }`, + }, + // A compact hamburger: 26px wide bars instead of Bricks' 40px, so it leaves room on phones. + "bs-mainnav__toggle": { _cursor: "pointer", _cssCustom: ".bs-mainnav__toggle { --brxe-toggle-bar-width: 26px; --brxe-toggle-bar-height: 3px; }\n.bs-mainnav__toggle .brxa-wrap { width: 26px; }" }, + // Its list item stays hidden in the desktop bar and shows in the open menu. + "bs-mainnav__cta": { _cssCustom: `.bs-mainnav__items > li:has(> .bs-mainnav__cta) { display: none; }\n.bs-mainnav.brx-open .bs-mainnav__items > li:has(> .bs-mainnav__cta) { display: block; margin-top: ${x.v("space-s")}; }` }, + "bs-header__actions--collapse": { "_display:mobile_portrait": "none" }, + "bs-mainnav__open": x.type({ color: "inherit" }), + "bs-mainnav__close": merge(x.type({ color: "heading" }), { _position: "absolute", _top: "20px", _right: x.v("gutter") }), "bs-nav__link": merge(x.type({ size: "15px", weight: "500", color: "text", decoration: "none" }), { _cssTransition: "color .2s ease", "_typography:hover": { color: x.color("link") } }), - "bs-header__actions": { _display: "flex", _direction: "row", _columnGap: x.v("space-s"), _alignItems: "center", _flexShrink: "0" }, + // From tablets on, the button sits next to the menu toggle on the right. + "bs-header__actions": { _display: "flex", _direction: "row", _columnGap: x.v("space-s"), _alignItems: "center", _flexShrink: "0", "_margin:tablet_portrait": { left: "auto" } }, "bs-header__phone": merge(x.type({ size: "15px", weight: "600", color: "heading", decoration: "none" }), { "_display:mobile_landscape": "none" }), "bs-topbar": merge(x.type({ size: "text-xs", color: "on-inverse" }), { _display: "flex", _direction: "row", _justifyContent: "space-between", _columnGap: x.v("space-m"), _rowGap: "4px", _flexWrap: "wrap", _width: "100%", _widthMax: x.v("container"), _margin: { left: "auto", right: "auto" } }), "bs-topbar__item": merge(x.type({ size: "text-xs", color: "on-inverse", decoration: "none" }), { _margin: { top: "0", bottom: "0" } }), @@ -41,7 +70,7 @@ export const headerVariants: Variant[] = [ build: ctx => band({ surface: "page", space: "bar", label: "Header", classes: "bs-header", containerClasses: "bs-header__bar" }, [ brand(ctx), navLinks(ctx), - div("bs-header__actions", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), + div("bs-header__actions bs-header__actions--collapse", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), ]), }, { @@ -56,7 +85,7 @@ export const headerVariants: Variant[] = [ band({ surface: "page", space: "bar", label: "Header", classes: "bs-header", containerClasses: "bs-header__bar" }, [ brand(ctx), navLinks(ctx), - div("bs-header__actions", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), + div("bs-header__actions bs-header__actions--collapse", [button(ctx.c.navCta.label, ctx.c.navCta.href, "bs-btn bs-btn-size--s bs-btn--primary")]), ]), ], "Header with contact bar"), }, diff --git a/src/lib/kit/sections/index.ts b/src/lib/kit/sections/index.ts index e1f0b28..c62114f 100644 --- a/src/lib/kit/sections/index.ts +++ b/src/lib/kit/sections/index.ts @@ -1,5 +1,6 @@ import type { ClassLibrary } from "../build"; import { baseClasses } from "../styles"; +import { motionClasses } from "../motion"; import type { ResolvedKit } from "../tokens"; import { commonClasses, SECTION_TYPES, type SectionType, type Variant } from "./common"; import { headerVariants, heroVariants } from "./header-hero"; @@ -39,7 +40,7 @@ export function findVariant(type: SectionType, id?: string): Variant { /** Every class the kit can emit, for one brand kit. */ export function classLibrary(r: ResolvedKit): ClassLibrary { - const library: ClassLibrary = { ...baseClasses(r), ...commonClasses(r) }; + const library: ClassLibrary = { ...baseClasses(r), ...commonClasses(r), ...motionClasses() }; for (const factory of new Set(VARIANTS.map(v => v.classes).filter(Boolean))) Object.assign(library, factory!(r)); return library; } diff --git a/src/lib/kit/sections/services-plus.ts b/src/lib/kit/sections/services-plus.ts index c39dcbc..56e2fd1 100644 --- a/src/lib/kit/sections/services-plus.ts +++ b/src/lib/kit/sections/services-plus.ts @@ -2,7 +2,7 @@ import { div, heading, icon, image, text, textLink, type KitNode } from "../buil import { PHOTOS, type PhotoKey } from "../images"; import { merge, sx } from "../styles"; import type { ResolvedKit } from "../tokens"; -import { band, checklist, intro, photo, tabs, type Ctx, type Variant } from "./common"; +import { band, checklist, intro, photo, slider, tabs, type Ctx, type Variant } from "./common"; /** More layouts for services, benefits, process, numbers, about, history, team and work. */ function bodyPlusClasses(r: ResolvedKit) { @@ -313,3 +313,11 @@ export const portfolioPlusVariants: Variant[] = [ ]), }, ]; + +portfolioPlusVariants.push({ + type: "portfolio", id: "carousel", name: { de: "Karussell", en: "Carousel" }, classes: bodyPlusClasses, + build: ctx => band({ surface: "alt", label: "Portfolio", id: ctx.c.anchors.portfolio }, [ + intro({ eyebrow: ctx.c.portfolio.eyebrow, title: ctx.c.portfolio.title, lead: ctx.c.portfolio.lead }), + slider(ctx, ctx.c.portfolio.items.map(item => div("bs-work", [photo(ctx, item.image, "4x3"), text(item.category, "bs-badge"), heading("h3", item.title, "bs-title bs-size--s")], item.title)), { perPage: 3, label: "Projects" }), + ]), +}); diff --git a/src/lib/kit/tokens.ts b/src/lib/kit/tokens.ts index 430a815..b4df462 100644 --- a/src/lib/kit/tokens.ts +++ b/src/lib/kit/tokens.ts @@ -24,6 +24,8 @@ export type BrandKit = { radius: RadiusId; spacing: SpacingId; mode: Mode; + /** "subtle": content fades in as it scrolls into view (Bricks interactions). Off when absent. */ + motion?: "none" | "subtle"; }; type Font = { family: string; fallback: string }; @@ -157,6 +159,7 @@ export function normalizeKit(input: Partial = {}): BrandKit { radius: RADIUS_IDS.includes(input.radius as RadiusId) ? input.radius as RadiusId : base.radius, spacing: SPACING_IDS.includes(input.spacing as SpacingId) ? input.spacing as SpacingId : base.spacing, mode: input.mode === "dark" ? "dark" : "light", + ...(input.motion === "subtle" ? { motion: "subtle" as const } : {}), }; } diff --git a/src/lib/mcp-server.ts b/src/lib/mcp-server.ts index a1107e5..5f81339 100644 --- a/src/lib/mcp-server.ts +++ b/src/lib/mcp-server.ts @@ -83,7 +83,7 @@ export function createMcpServer() { description: "Build modern sections or a whole page from the template kit, without an AI call: pick section types and layout variants, a brand kit (style, primary color, fonts, radius, spacing, light/dark) and a business profile (industry, language de/en, name, city, phone, services). Returns a Bricks import object whose styling lives in bs- global classes with var(--bs-*, fallback) values, the design system (palette colors and global variables) for central editing in Bricks, and quality checks (contrast, headings, alt texts). Sample photos come from Unsplash; nothing is saved or published.", inputSchema: { sections: z.array(z.object({ type: z.enum(KIT_SECTION_TYPES), variant: z.string().max(40).optional() })).min(1).max(16), - kit: z.object({ style: z.enum(STYLE_IDS), primary: hex, accent: hex, fonts: z.enum(FONT_PAIR_IDS), radius: z.enum(RADIUS_IDS), spacing: z.enum(SPACING_IDS), mode: z.enum(["light", "dark"]) }).partial().strict().optional(), + kit: z.object({ style: z.enum(STYLE_IDS), primary: hex, accent: hex, fonts: z.enum(FONT_PAIR_IDS), radius: z.enum(RADIUS_IDS), spacing: z.enum(SPACING_IDS), mode: z.enum(["light", "dark"]), motion: z.enum(["none", "subtle"]) }).partial().strict().optional(), profile: z.object({ industry: z.enum(INDUSTRY_IDS), language: z.enum(["de", "en"]), name: z.string().max(120), city: z.string().max(60), phone: z.string().max(40), email: z.string().max(120), address: z.string().max(160), tagline: z.string().max(160), services: z.array(z.string().max(60)).max(6) }).partial().strict().optional(), title: z.string().max(120).default("BricksSnap Kit"), }, annotations, @@ -96,7 +96,7 @@ export function createMcpServer() { description: "Import & Modernize: rebuild any Bricks JSON (a copied section, a template export, a library component the user may use) on the BricksSnap design system. Colors become design tokens by role (brand colors → primary/accent), font sizes, spacing and radii land on the token scale (px, rem at 62.5%, Automatic CSS / Core Framework variables), each element's styles move into one deduplicated bs-- global class, and missing mobile rules are added. Optional brand kit restyles it. Returns a Bricks import object, the design system, a report of every change and quality checks. Pure local computation; nothing is fetched or saved.", inputSchema: { json: z.string().max(2_000_000).describe("Bricks JSON: an element array, copied elements ({content, globalClasses}) or a template export."), - kit: z.object({ style: z.enum(STYLE_IDS), primary: hex, accent: hex, fonts: z.enum(FONT_PAIR_IDS), radius: z.enum(RADIUS_IDS), spacing: z.enum(SPACING_IDS), mode: z.enum(["light", "dark"]) }).partial().strict().optional(), + kit: z.object({ style: z.enum(STYLE_IDS), primary: hex, accent: hex, fonts: z.enum(FONT_PAIR_IDS), radius: z.enum(RADIUS_IDS), spacing: z.enum(SPACING_IDS), mode: z.enum(["light", "dark"]), motion: z.enum(["none", "subtle"]) }).partial().strict().optional(), block: z.string().max(24).regex(/^[a-z0-9-]*$/).optional().describe("Class prefix after bs- (default: from the first heading)."), title: z.string().max(120).default("Modernized section"), }, annotations, diff --git a/src/lib/template-classes.ts b/src/lib/template-classes.ts index 422b26d..bf05098 100644 --- a/src/lib/template-classes.ts +++ b/src/lib/template-classes.ts @@ -30,8 +30,41 @@ export type ClassPlan = { mismatched: Array<{ id: string; name: string }>; }; +/** + * Bricks reformats custom CSS when it stores a class (line breaks, indentation, no spaces around + * ">" combinators); layout is not a difference. Both sides are normalized the same way. + */ +const normalizeCss = (css: string) => css.replace(/\s+/g, " ").replace(/\s*([{};>,])\s*/g, "$1").trim(); +const withoutCssLayout = (settings: Record) => + Object.fromEntries(Object.entries(settings).map(([key, value]) => [key, key.startsWith("_cssCustom") && typeof value === "string" ? normalizeCss(value) : value])); + const definition = (cls: Pick & { selectors?: unknown }) => - stableJson({ settings: cls.settings ?? {}, selectors: Array.isArray(cls.selectors) && cls.selectors.length ? cls.selectors : [] }); + stableJson({ settings: withoutCssLayout(cls.settings ?? {}), selectors: Array.isArray(cls.selectors) && cls.selectors.length ? cls.selectors : [] }); + +/** Same definition apart from how Bricks lays out custom CSS. */ +export const sameClassDefinition = (a: Pick & { selectors?: unknown }, b: Pick & { selectors?: unknown }) => definition(a) === definition(b); + +/** Classes BricksSnap owns and may update on a site: its `bs-` design system classes. */ +export const isBricksSnapClass = (name: string) => /^bs-[a-z0-9_-]+$/.test(name); + +const isPlainObject = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); + +/** + * Settings for Bricks' update-global-class, which merges recursively: keys the target lacks are set + * to null (Bricks then removes them), nested objects are patched the same way, the rest is replaced. + */ +export function classSettingsPatch(current: Record, target: Record): Record { + const patch: Record = {}; + for (const key of Object.keys(current)) if (!(key in target)) patch[key] = null; + for (const [key, value] of Object.entries(target)) { + const before = current[key]; + if (isPlainObject(before) && isPlainObject(value)) { + const nested = classSettingsPatch(before, value); + if (Object.keys(nested).length) patch[key] = nested; + } else if (stableJson(before) !== stableJson(value)) patch[key] = value; + } + return patch; +} /** A class ID that neither the site nor the change uses, derived from the colliding ID and name. */ function freshId(id: string, name: string, taken: Set): string { diff --git a/src/lib/wordpress-client.ts b/src/lib/wordpress-client.ts index 7eae870..8a0fcb8 100644 --- a/src/lib/wordpress-client.ts +++ b/src/lib/wordpress-client.ts @@ -8,6 +8,7 @@ import { WP_WRITE_ABILITIES, type WordPressApplyResult, type WordPressClassesResult, + type WordPressClassUpdateResult, type WordPressDesignSystemRevertResult, type WordPressDesignSystemUninstallResult, type WordPressConditionsResult, @@ -34,7 +35,7 @@ import { diffTemplates, readStagingTemplate, stableJson } from "./template-stagi import type { BricksGlobalClass, BricksTemplate, DesignTokens } from "./bricks-engine"; import packageJson from "../../package.json"; import { downloadImage, findExternalImages, MAX_IMAGES, replaceImages, uploadName, type SiteImage } from "./wordpress-media"; -import { foreignClassIds, planGlobalClasses, referencedClassIds, remapGlobalClasses } from "./template-classes"; +import { classSettingsPatch, foreignClassIds, isBricksSnapClass, planGlobalClasses, referencedClassIds, remapGlobalClasses, sameClassDefinition } from "./template-classes"; import { readTemplateConditions, type TemplateCondition, type TemplateType } from "./template-conditions"; type JsonRecord = Record; @@ -829,6 +830,75 @@ export async function createWordPressClasses( }); } +/** Every class row with its ownership envelopes, as list-global-classes returns them. */ +async function readClassRows(session: Session): Promise<{ rows: JsonRecord[]; lockOwnership: JsonRecord }> { + const rows: JsonRecord[] = []; + let ownership: JsonRecord | undefined, lockOwnership: JsonRecord | undefined; + for (let page = 1; page <= 20; page++) { + const result = await callAbility(session, "bricks/list-global-classes", [{ names: ["page"], value: page }, { names: ["perPage"], value: 100 }]); + if (!isRecord(result) || !isRecord(result.ownership) || !isRecord(result.lockOwnership)) throw new RequestError("This Bricks version does not report class ownership; classes cannot be updated safely.", 422); + if (ownership && stableJson(ownership) !== stableJson(result.ownership)) throw new RequestError("Global classes changed on the site while they were read. Try again.", 409); + ownership = result.ownership; lockOwnership = result.lockOwnership; + const items = Array.isArray(result.items) ? result.items.filter(isRecord) : []; + rows.push(...items); + if (result.hasMore !== true || !items.length) break; + } + return { rows, lockOwnership: lockOwnership ?? {} }; +} + +/** + * Put the site's BricksSnap classes on the given definitions: newer ones from a proposal, or the + * previous ones to undo. Only `bs-` classes are touched. Each write carries the class's item + * ownership and the lock ownership; the result is read back and compared. + */ +export async function updateWordPressClasses( + credentials: WordPressCredentials, + request: { classes: Array<{ name: string; settings: Record }> }, + signal: AbortSignal +): Promise { + const targets = request.classes.filter(c => isBricksSnapClass(c.name)); + return withSession(credentials, signal, async session => { + await requireWriteAbility(session, "bricks/update-global-class"); + const { rows, lockOwnership } = await readClassRows(session); + const result: WordPressClassUpdateResult = { updated: [], previous: [], unchanged: [], missing: [], verified: false }; + let lock = lockOwnership; + let resource: JsonRecord | undefined; + for (const target of targets) { + const row = rows.find(r => r.name === target.name); + if (!row) { result.missing.push(target.name); continue; } + const current = isRecord(row.settings) ? row.settings : {}; + if (sameClassDefinition({ settings: current }, { settings: target.settings })) { result.unchanged.push(target.name); continue; } + if (!isRecord(row.itemOwnership)) throw new RequestError("This Bricks version does not report class ownership; classes cannot be updated safely.", 422); + // After a write the resource version moves on; the other rows' item digests stay valid. + const expectedOwnership = resource ? { ...resource, itemDigest: row.itemOwnership.itemDigest } : row.itemOwnership; + try { + const saved = await callAbility(session, "bricks/update-global-class", [ + { names: ["classId"], value: String(row.id), required: true }, + { names: ["settings"], value: classSettingsPatch(current, target.settings), required: true }, + { names: ["expectedOwnership"], value: expectedOwnership, required: true }, + { names: ["lockOwnership"], value: lock, required: true }, + ]); + if (isRecord(saved) && isRecord(saved.ownership)) resource = saved.ownership; + if (isRecord(saved) && isRecord(saved.lockOwnership)) lock = saved.lockOwnership; + } catch (error) { + result.error = `${target.name} was not updated: ${error instanceof Error ? error.message : "the site refused the change"}. Earlier updates stay; undo them or check again.`; + break; + } + result.updated.push({ id: String(row.id), name: target.name }); + result.previous.push({ name: target.name, settings: current }); + } + if (result.updated.length) { + const after = (await readClassRows(session)).rows; + result.verified = result.updated.every(u => { + const row = after.find(r => r.id === u.id); + const target = targets.find(t => t.name === u.name)!; + return !!row && sameClassDefinition({ settings: isRecord(row.settings) ? row.settings : {} }, { settings: target.settings }); + }); + } else result.verified = !result.error; + return result; + }); +} + /** Site root for an MCP endpoint (/wp-json/mcp/… or /?rest_route=/mcp/…). */ export function siteRoot(endpoint: string): string { const url = new URL(endpoint); @@ -1182,7 +1252,7 @@ export async function uninstallWordPressDesignSystem(credentials: WordPressCrede export async function handleWordPressRequest( request: WordPressRequest, signal: AbortSignal -): Promise { +): Promise { switch (request.action) { case "connect": return connectWordPress(request.credentials, signal); @@ -1198,6 +1268,8 @@ export async function handleWordPressRequest( return importWordPressMedia(request.credentials, request, signal); case "classes": return createWordPressClasses(request.credentials, request, signal); + case "update-classes": + return updateWordPressClasses(request.credentials, request, signal); case "templates": return listWordPressTemplates(request.credentials, request.type, signal); case "template-conditions": diff --git a/src/lib/wordpress-contract.ts b/src/lib/wordpress-contract.ts index f124b5c..e109121 100644 --- a/src/lib/wordpress-contract.ts +++ b/src/lib/wordpress-contract.ts @@ -28,6 +28,7 @@ export const WP_WRITE_ABILITIES = [ "bricks/create-template", "bricks/set-template-conditions", "bricks/create-color-palette", "bricks/create-color", "bricks/update-color", "bricks/set-global-variable-categories", "bricks/set-global-variables", "bricks/delete-color-palette", "bricks/delete-color", "bricks/delete-global-variable", + "bricks/update-global-class", ] as const; const digestSchema = z.string().regex(/^[a-f0-9]{64}$/, "Reload the page into the baseline: Bricks' document digest is missing."); @@ -64,7 +65,7 @@ export type WordPressCredentials = z.infer; // The server derives every value from the kit's choices; no CSS reaches the site from the request. const kitSchema = z.object({ style: z.enum(STYLE_IDS), primary: z.string().max(20), accent: z.string().max(20).optional(), fonts: z.enum(FONT_PAIR_IDS), - radius: z.enum(RADIUS_IDS), spacing: z.enum(SPACING_IDS), mode: z.enum(["light", "dark"]), + radius: z.enum(RADIUS_IDS), spacing: z.enum(SPACING_IDS), mode: z.enum(["light", "dark"]), motion: z.enum(["none", "subtle"]), }).partial().strict(); export const wpRequestSchema = z.discriminatedUnion("action", [ @@ -79,6 +80,11 @@ export const wpRequestSchema = z.discriminatedUnion("action", [ z.object({ action: z.literal("media"), credentials: wpCredentialsSchema, template: z.unknown(), confirm: z.literal(true) }).strict(), // Create the global classes a proposal uses but the site lacks (additive; existing classes are never changed). z.object({ action: z.literal("classes"), credentials: wpCredentialsSchema, template: z.unknown(), confirm: z.literal(true) }).strict(), + // Bring the site's BricksSnap (bs-) classes to the proposal's definitions, or back to earlier ones (undo). + z.object({ + action: z.literal("update-classes"), credentials: wpCredentialsSchema, confirm: z.literal(true), + classes: z.array(z.object({ name: z.string().regex(/^bs-[a-z0-9_-]+$/).max(120), settings: z.record(z.string(), z.unknown()) }).strict()).min(1).max(200), + }).strict(), // The site's Bricks templates (header, footer, section, …), optionally of one type. z.object({ action: z.literal("templates"), credentials: wpCredentialsSchema, type: z.enum(TEMPLATE_TYPES).optional() }).strict(), z.object({ action: z.literal("template-conditions"), credentials: wpCredentialsSchema, templateId: z.number().int().positive() }).strict(), @@ -203,6 +209,21 @@ export type WordPressConditionsResult = { export type WordPressCreateTemplateResult = { templateId: number; status: string; editUrl?: string; warnings: string[] }; +export type WordPressClassUpdateResult = { + /** Classes now on the given definition. */ + updated: Array<{ id: string; name: string }>; + /** Their definitions before the update, for undo. */ + previous: Array<{ name: string; settings: Record }>; + /** Already on the given definition; not written. */ + unchanged: string[]; + /** Not on the site (create them first). */ + missing: string[]; + /** Every updated class read back with the given definition. */ + verified: boolean; + /** Set when a write was refused partway; earlier updates stay and can be undone. */ + error?: string; +}; + export type WordPressClassesResult = { /** The proposal with references to reused site classes switched and definitions as the site stores them. */ template: BricksTemplate; diff --git a/tests/kit.test.ts b/tests/kit.test.ts index 023cc8b..7d106d4 100644 --- a/tests/kit.test.ts +++ b/tests/kit.test.ts @@ -110,6 +110,59 @@ describe("generated templates", () => { expect(html).toContain("accordion-content-wrapper"); }); + it("gives every header with links Bricks' nestable nav with a mobile menu", () => { + for (const variant of variantsFor("navbar").filter(v => v.id !== "minimal")) { + const { template } = generateKitTemplate({ kit: {}, profile: { industry: "kfz", language: "de" }, sections: [{ type: "navbar", variant: variant.id }] }); + const byId = new Map(template.content.map(el => [el.id, el])); + const hidden = (id: string) => (byId.get(id)!.settings._hidden as { _cssClasses?: string } | undefined)?._cssClasses; + const nav = template.content.find(el => el.name === "nav-nested")!; + expect(nav.settings).toMatchObject({ mobileMenu: "tablet_portrait", ariaLabel: "Hauptnavigation" }); + // Bricks 2.4.2's own structure: the item list, then the open toggle; the close toggle inside the list. + const [list, open] = nav.children.map(id => byId.get(id)!); + expect([list.name, list.settings.tag, hidden(list.id), open.name, open.settings.animation]).toEqual(["block", "ul", "brx-nav-nested-items", "toggle", "squeeze"]); + const items = list.children.map(id => byId.get(id)!); + expect(items.filter(el => el.name === "text-link")).toHaveLength(4); + expect(items.at(-1)!.name).toBe("toggle"); + expect(hidden(items.at(-1)!.id)).toBe("brx-toggle-div"); + expect(validateBricksElements(template.content).violations.filter(v => /undocumented/.test(v))).toEqual([]); + const { html } = renderPreview(template); + expect(html).toMatch(/