Skip to main content

Documentation

No results found.
Features

Design Library

WebProCMS ships with a built-in design library — a catalogue of pre-designed rows organised by category that editors can drop into any page from the page editor's "Add row" drawer. Every row is built from a small set of editable B...

WebProCMS ships with a built-in design library — a catalogue of pre-designed rows organised by category that editors can drop into any page from the page editor's "Add row" drawer. Every row is built from a small set of editable Blade primitives (<x-dl.*> components), so the moment a row lands on a page, every text string, image, button label, padding token, and Tailwind class is editable through the standard sidebar UI. There is no "static" content in a design library row.


The problem

A CMS that ships only one or two starter templates leaves editors stuck — every new page is a manual layout job. A CMS that ships hundreds of static templates with hardcoded copy and styling leaves editors stuck in a different way: any deviation from the template means hand-editing the resulting HTML, and the template gallery becomes a graveyard of "almost what I want" options.

WebProCMS picks a third path. The design library is a curated catalogue — enough rows to cover every common section a marketing site needs (heroes, features grids, pricing tables, testimonials, FAQs, footers, etc.), but every single row is built from the same handful of editable primitives, so the gap between "library row" and "what the user actually wants" is always closable through the sidebar.

The fix

Two pickers, one shared component vocabulary:

  • The add-row picker opens a drawer of full sections grouped by category, with live preview thumbnails. Filtering by category narrows the list; a search box matches by row name.
  • The add-item picker is a smaller modal that opens from the + button between items inside a row. It lists insertable primitives — heading, subheadline, image, video, button, layout containers, repeater widgets — that drop in alongside whatever's already there.

Rows from the first picker are full pre-styled sections; items from the second picker are unstyled or minimally-styled snippets meant to be customised after insert.

Categories

Rows live in resources/design-library/rows/ under one directory per top-level category (RowCategory); three of those — Page Content, Page Layouts, and System — group further into sub-categories (RowSubCategory) that show up as a nested directory level and a secondary filter. The current set:

Category What's in it
hero Page heroes — headline + subhead + CTA + optional image/video
below-hero Rows designed to sit directly under a hero (stat bars, feature strips)
page-title Subpage banner titles with breadcrumbs
header Site headers — clean, dark, centered, transparent, pill, and ecommerce variants
footer Site footers — minimal, multi-column, with newsletter signup, with social links
page-content General content rows, grouped into sub-categories: content, CTA, countdown, features, FAQs, gallery, icon-list, logos, pricing, slider, social-proof, team
page-layouts Full-page list/detail layout variants shared across every record system (blog, content types)
system Auth + error pages: login, register, forgot-password, reset-password, not-found
contact Contact forms, contact info blocks, map embeds
popups Page-embedded modal/popup rows
campaign-popups / campaign-bars Popup and announcement-bar designs for the Popups & Announcement Bars feature
e-commerce Product grids, single product, cart-related rows
donations Donation form and campaign rows (Donations feature)
real-estate Listing search/detail rows (Real Estate feature)
restaurant Menu and ordering rows (Restaurant feature)
booking Appointment booking rows (Online Booking feature)
social Social feed rows (Social Feed feature)
events Events calendar rows (Events feature)
courses Course catalog/detail rows (Courses feature)

The list is data-driven: any new .blade.php file dropped into a category (or sub-category) folder is picked up by php artisan design-library:index and appears in the picker.

Filtering and previews

The add-row drawer surfaces every category as a chip filter at the top. The current page's context (header zone vs. body zone vs. footer zone) restricts which categories are offered — a header row picker won't show pricing tables, a body row picker won't show site headers.

Each row tile shows a live thumbnail rendered through Tailwind's browser CDN so previews always reflect the current brand colors and theme tokens. Hovering the tile shows the full row name; clicking it inserts the row at the position the user clicked from.

All classes are editable: the <x-dl.*> vocabulary

Every row in the design library is built from a small set of components in resources/views/components/dl/:

Component Purpose
<x-dl.section> The required outer wrapper — registers section_classes, section_container_classes, section_id, section_attrs. Every row starts with this.
<x-dl.heading> Toggleable heading with selectable h1–h4 tag
<x-dl.subheadline> Toggleable supporting text
<x-dl.buttons> Primary + secondary CTA pair with editable labels and classes
<x-dl.media>, <x-dl.image> Image with picker, alt, wrapper classes
<x-dl.video> Video with three source modes — self-hosted (media-library file), adaptive (HLS/DASH URL), or YouTube/Vimeo embed. Self-hosted/adaptive posters come from the video's media-library item. A Shape picker (Auto / Wide / Tall / Square) sizes the box; Auto gives a portrait or square video a box its own shape, read from its poster frame
<x-dl.link> Inline link with toggle, label, URL, new-tab
<x-dl.icon> Heroicon (outline or solid) with optional wrapper
<x-dl.wrapper>, <x-dl.loop-item>, <x-dl.group> Generic editable element wrappers
<x-dl.repeater>, <x-dl.gallery>, <x-dl.slider>, <x-dl.accordion> List-driven widgets with add/edit/remove items

Because every visible class is registered as an editable field on one of these components, there's no "this row's footer is hardcoded" or "you can't change the icon size on a features grid" — the sidebar exposes everything. A row template is just a particular arrangement of these components with a particular set of default classes.

Brand-aware colors

Design library rows never hardcode hex values or vendor color names like text-blue-500. Every color reference uses one of three semantic families — primary, secondary, or tone — each with a 19-shade palette. Branding settings then map those families to the install's actual brand colors.

The result: dropping a row into a fresh install picks up the correct brand colors automatically, and changing a brand color in settings updates every row on the site at once.

See the design-library skill's styling-rules reference and the "Branding Color System" section in CLAUDE.md for the rules.

The add-item picker

Once a row is on the page, the + button between any two items opens the add-item picker. It groups primitives into three sections:

Group Items
Basic Heading, Subheadline, Rich Text, Button, Buttons (pair), Link, Image, Video, Icon
Layout Container, Block, Div, Panel, Grid (with column-layout chooser), Grid Item, Card
Widgets Accordion, Gallery, Slider, Form

Items are context-aware. Inserting inside a Grid restricts the picker to grid-compatible items (Grid Item, Card); inserting outside a grid hides those and shows the rest. The mapping lives in RowItemLibrary::itemsForContainer.

The Grid item triggers a follow-up modal — a column-layout chooser with ten preset shapes (2/3/4/5/6 even columns, plus 1/2, 2/1, 1/3, 3/1, and 1/2/1 splits). The picked layout determines the grid's responsive Tailwind classes and the per-item column spans.

Replace template ("browse mode")

Any row already on the page can be swapped for a different template without losing its section-level styling. From the row's design panel, clicking "Browse alternates" opens an inline carousel that cycles through every row in the same category. Each step replaces the row's blade with the new template, eager-wraps it in editor markers, and refreshes the preview.

What carries over: section background, padding, container width, custom IDs, sticky-section toggle — everything keyed under section_* plus toggle_sticky.

What gets dropped: component-level overrides (the old headline text, the old button label, the old card images). The new template's defaults take their place — the user is asking for a different design, not a different copy of the same content.

The user can also change category mid-browse and the carousel re-populates with rows from the new category. Implementation lives in EditorLibraryActions::applyBrowseRow.

Refresh from design library

A row's blade is copied into the page file at insert time, so subsequent updates to the source design library template don't automatically propagate. The discard menu's Refresh from design library action solves this:

  • Per-page — re-pull every non-shared row's blade from its source template, re-bake saved overrides on top
  • Per-row — same, but for one row only
  • Include shared rows — opt-in checkbox in the page-wide modal that also refreshes shared rows (which propagates to every page that includes them)

Refresh is non-destructive for saved overrides. The new template replaces the blade structure, then every active ContentOverride for the row is re-baked into the new file via BladeClassSyncer. Customisations survive the structural change. Implementation lives in EditorDiscardActions::refreshRowsFromDesignLibrary.

Push back to library

Designers iterating on a row often want their customisations to become the new default for future inserts of that template. The "Push to design library" button on the row's design panel writes the current class value of a field back to the source .blade.php file, so the next time someone adds the same row, they get the new defaults.

This only applies to classes-type fields — it's a way to refine library defaults from the editor, not to overwrite content. Shared rows and rows whose source template no longer exists hide the button. Implementation lives in EditorLibraryActions::pushClassesToLibrary.

Save your own rows (custom library)

The shipped library is a starting point — an install can build its own rows and reuse them across the site, no code required. There are two ways in, and both produce the same thing: a custom row that lives only on this install.

  • Save a section from the page editor. Build a section you like in the editor, open its ⋯ menu, and choose Save to library. A modal asks for a name and category (plus a sub-category and a "full-page design" option). A Keep content toggle decides what the saved row remembers: on, it captures the section exactly as built (text, images, colors); off, it saves only the layout and colors with placeholder copy, so the design is reusable without dragging your specific words along.
  • Save a whole page. The editor toolbar's ⋯ menu has Save whole page to library — it saves every section as a custom row and a reusable page bundle filed under a website type (SaaS, Service, Law, eCommerce, …) that shows up in the Page Library tab, where Use this page can scaffold a fresh page from it. (Shared rows like the site header are skipped — they're managed globally.)
  • New Row in the Design Library. Admins can also author a row directly from Dashboard → Design Library → New Row by writing the Blade markup, for cases where you want to start from scratch rather than from a live section.

Once saved, custom rows appear in the page editor's Insert Section drawer under a My rows filter, with Rename and Remove on each one. (The shipped rows stay read-only — you can't rename or delete what came with the CMS, only your own.) A custom full-page design tagged that way also feeds the Detail Layout dropdown for matching records.

Rows the AI site build designs are custom rows too. When the owner lets an AI site build design sections (rows: author on its plan), each one it designs lands here, in the custom tree, as an is_custom row with an ai-{category}-… template name, the name and description the build gave it, and nothing else in its header — and is then placed, edited and reused like any custom row. Because a model wrote it, it is saved only after RowAuthor's gates: the row linter (RowAuthoringLinter — shipped x-dl primitives only, static markup, no PHP or method calls, read from the source and from Blade's compiled output), every class renders on a node-free install, a syntax check and a sandbox render; a refused row leaves no file and no index entry. Item markers are added when it is placed, like every template.

Custom rows survive CMS updates. They're stored outside the files that ship with the CMS — in a separate, git-ignored tree — so a git pull update can never collide with them and never overwrites them. They're also captured in backups, which is their portability path between installs. Under the hood a custom row is just a normal library row with an is_custom flag; the editor serializer (LibraryRowExporter) turns a live section back into a reusable template, and EditorLibraryExportActions drives the save/rename flows. Before it swaps the section's slug for __SLUG__, the serializer points every reference inside the section — slug= attrs, @dlItems / @dlLoop arguments, content() reads — at the section itself, so a saved row can never read another row (why that matters).

The Page Library: whole-page designs by website type

Alongside single rows, the library catalogues page bundles — complete page designs described as an ordered list of library rows. The Page Library tab (Dashboard → Design Library → Page Library) shows them as preview cards filtered by website type (SaaS, Service, eCommerce, Law, Nonprofit, Healthcare, Custom), so a law firm browses law-firm pages, not everything.

  • Use this page. Every card has a Use this page button (admin): pick a page name and URL, and the bundle is materialized as a real, live page on your site — each section inserted exactly as if you'd added it from the drawer, route registered, ready in the editor. Your edits never touch the library copy.
  • Where bundles come from. The CMS ships a set (including every shipped theme's pages — see below), and Save whole page to library in the editor adds your own, filed under the website type you pick.
  • Bundles feed themes. A shipped theme's pages are compiled from Page Library bundles by themes:build — the bundle is the canonical source of that page design, so one fix there reaches the theme, the Page Library card, and every future "Use this page" insert. (Details in themes.md under "Compose-at-build".)

A bundle itself is a tiny frontmatter-only file — @name, @categories, and an ordered @rows list of row template names — under resources/design-library/pages/{website-type}/ (shipped) or the git-ignored custom tree (yours).

A bundle can carry its section backgrounds. An @rows entry may name the surface its section opens on — @rows role:page_title, content-two-column:light-alt, features-grid-repeater, faqs-accordion:light-alt, role:cta. The surface is a section preset id (light, light-alt, dark, dark-alt, primary, primary-surface, secondary …). Use this page (and the AI site build) applies it as that section's Section preset setting — the paintbrush's own setting, stored with the page's content, never baked into the page file — so the page opens with the house rhythm already alternating and the owner can change any background later. A surface the site has no preset for is skipped; a shared row (role:page_title, role:cta) never takes one, since it shows on every page. A bundle without surfaces behaves exactly as before. Indexing keeps the surfaces in their own row_surfaces list beside row_names (PageDesigns holds the grammar), and saving a bundle from the Page Library form keeps the surface of every section it keeps.

Recipes for the AI site build. Four shipped designs under pages/service/ are the recipes the AI site build lays new pages out from — Service Detail, Services Landing, FAQ and Interior Page — each with the title band first, a light-alt first content section, looped (repeater) or record-bound card sections, and the shared call to action last. Services Landing lists services with the Service Cards row, and a parent service's own page can carry Sub-Service Cards: both read the services content type's records live (top-level services, or the services filed under the page's own record), so a new service appears without editing a page, and both are offered only once the site has a Services type.

More layouts for the services listing. Two further rows list the same top-level services, two cards across instead of three, and are offered under the same condition. Service Cards - Side Heading puts the heading beside the cards instead of above them, and the heading stays in view while the cards scroll past, which suits a listing of four or more. Service Cards - Wide keeps the heading on top and gives each service a wide card with its photo on the left. Both show a service's category above its name when the Services type has one, and nothing when it does not.

Plan Finder (a quiz that recommends a plan)

Page content → Pricing → Plan Finder asks a visitor a few plain questions, one at a time, and recommends one of your plans, with the reasons, the next plan up, any tips their answers call for, and a button straight to sign-up. It answers "which plan is right for me?" without a call. Give the plans a monthly cost and it also shows a running price while the visitor answers, says what each choice changed it by, and adds extra update tasks to the plan instead of moving the visitor up a plan for changes alone.

Set it up in the sidebar. The row holds three lists:

List Each item Notes
Plans name, price, summary, button (label + link), monthly cost, tasks included, extra task cost, extra tasks max Order is rank: the first plan is the smallest. Point the button at the subscribe link with the plan chosen, e.g. /members/subscribe?plan=core-monthly. A new visitor goes to sign-up and a signed-in one to the plan picker (memberships.md). A plan with no button link shows no button. The four numbers are optional (see Running price and extra tasks); leave them all blank and the finder works exactly as before.
Questions title, help text, multiple multiple 1 = checkboxes (pick any), 0 = pick one. A question with no answers yet is skipped, and the editor flags it.
Answers label, question number, plan number, note, call weight, move up, move up to, tasks question is the question's position (1 = first). plan is the plan's position; 0 means the answer doesn't push toward any plan. A note shows under "Good to know" when the answer is picked. call is 0, 1 or 2 (see below). move up / move up to and tasks are explained below; 0 means off.

How it decides. The recommendation is the highest plan number among the picked answers, so one "connect to software we already use" answer outranks three answers that point at the first plan. Nothing pointing anywhere → the first plan. A number past the last plan counts as the last plan. "Why this plan" lists the picked answers that point at the winner, and "Also consider" names the next plan up. The call box appears when the picked answers' call weights add up to 2: one answer weighted 2 triggers it alone, and two answers weighted 1 trigger it together. Every box (call, tips, also consider) has its own Show on page switch.

Move-up answers. An answer with move up 1 lifts the result one plan above wherever the other answers put it, instead of pointing at a fixed plan — for a question like "Do you have the budget for extra ranking work?", where "yes" should mean one size bigger for everyone, not a jump to the same plan for everyone. Move up to caps how far it can reach (3 = never past the third plan; 0 = the last plan), and a cap never shrinks a bigger plan the other answers already need. Give a move-up answer plan 0. Two move-up answers don't stack: the one that reaches highest wins. A move-up that lifted the result is listed under "Why this plan".

Running price and extra tasks. Fill in each plan's monthly cost (a number: 200, $1,000 or 29,99 — the first number is read, a mark followed by one or two digits is the decimal point) and the finder shows Your plan so far with its price while the visitor answers, plus a line like That choice moved you from $200/mo to $300/mo whenever the current question's answer changed it, so they can judge whether a choice is worth the money. The price is written in each plan's own price wording with the number swapped, so $200/mo becomes $540/mo and a Spanish $200/mes becomes $540/mes; the number beside the currency sign is the one swapped (2 sites, $29/mo).

To price changes as extra update tasks rather than a bigger plan: give each plan its tasks included a month (blank = unlimited), its extra task cost, and its extra tasks max (the most it sells; blank = no limit), and give each "how often will you need changes?" answer its tasks a month (e.g. few = 3, weekly = 5, daily = 20). Answers' plan numbers stay where your needs put them — the finder works out the plan first, then adds whatever tasks it doesn't include: Core Plan + 17 extra update tasks · $540/mo. When the winning plan can't sell enough extra tasks (it sells none, or the count passes its max), the call box shows instead of a sign-up that would buy less than the visitor said they need. The extra-tasks line shows on the result whenever there are any, even with no monthly cost to total, because the button buys them.

The sign-up link carries the tasks. Add the extra-task plan to each plan's button link as &addon={its plan code}, e.g. /members/subscribe?plan=core-monthly&addon=monthly-updates. The finder adds &addon_qty=17 when extra tasks are needed and removes the add-on when none are, and sign-up selects both plans with the full plan list open so the visitor sees every line they'll pay for, and can skip the add-on there (see memberships.md). A signed-in visitor gets the plan alone on the plan picker, which adds one plan at a time. A link without addon= is left exactly as written.

The texts the price line uses — + {count} extra update tasks and That choice moved you from {from} to {to}. — are edited in the running price box (the editor shows it; visitors see it filled in). Keep {count}, {from} and {to} in every translation: a text that loses one is replaced by a plain fallback (+ 17, $200/mo → $300/mo) rather than shown half-filled.

In the editor, every question and every result card shows at once. Each answer carries a badge with the plan number it points to (→ 2, plus ↑1 ≤3 for a move-up and its cap, 20 tasks for a task count, and call 1 for a call weight), and the result cards are numbered, so a question or plan moved in the sidebar shows its new mapping immediately. The "why this plan", "also consider" and "good to know" lines fill in on the live page from the visitor's answers; in the editor those boxes show their headings only. Answers are edited in the Answers list. Warnings name any answer whose question number doesn't exist (visitors never see it), any answer pointing past the last plan, any move-up capped past the last plan, any question with no answers, and an empty question or plan list.

Translations. Question titles and help, answer labels and notes, and plan names, prices, summaries and button labels translate per item like any repeater. The numbers (question, plan, call, multiple, move_up, move_up_to, tasks, and the plans' cost and task numbers) are structural and never translate, so a translated page keeps the same mapping and prices. The sidebar hides the language box for a field whose values are all numbers.

As a popup. The finder can open as a popup instead of sitting in the page: paintbrush → Advanced → Show As → Popup, give the row a Section ID such as plan-finder, and link to #plan-finder from wherever visitors decide (an intro line, an FAQ answer). See popup-rows.md.

Under the hood. Everything a visitor reads is server-rendered. resources/js/plan-finder.js only tracks the picks and scores them: recommend() (the plan, move-ups included, and the call box), estimate() (tasks, extra tasks and the monthly total for a plan), isReasonFor(), priceLike(), fillTemplate() and signupHref() are pure and tested through tests/scripts/run-plan-finder.mjs. A finder materialized before these fields existed has none of their data-* attributes and behaves exactly as it did. The Alpine component planFinder is registered in both public.js and app.js, because the page editor's preview iframe loads only app.js. Radio group names are assigned per finder in the browser (groupName()), not baked from the row slug, because duplicating a row rekeys only its slug attributes. Accessibility: each question is a fieldset with a legend and real radio/checkbox inputs, Next stays disabled until the question is answered, focus moves to the new question, and the recommendation is read out from an always-present screen-reader live region. Tests: tests/Unit/PlanFinderScoringTest.php, tests/Feature/PlanFinderRowTest.php, and for the sign-up side tests/Feature/MembersRegisterAddonPreselectTest.php.

Pricing Estimator (Tiered) — a build-your-own-estimate calculator

Page content → Pricing → Pricing Estimator (Tiered) lets a visitor price their own plan: a quantity slider (users, endpoints, seats) and optional add-ons on the left; the estimated monthly price, a line-by-line breakdown and a consultation button on the right; a "how pricing works" table showing what each tier adds at the chosen size; and an example-pricing table below. The price updates as the slider moves. On a phone the estimate sits straight under the controls.

Set it up in the sidebar. The row holds four lists:

List Each item Notes
Pricing tiers up to, flat price, price each Tiers are marginal: each prices only the units inside it, never every unit. up to is the tier's last unit; the next tier starts one above. A tier can charge a flat price once it is reached, a price per unit inside it, or both. Leave up to blank on the last tier only to make it open-ended.
Add-ons name, description, price each, flat price, minimum Each add-on is a checkbox, priced on the whole quantity on its own — flat + each × quantity, never below its minimum — and added to the base, never folded into it. An add-on with no name is left out.
Examples quantity One row of the example table per quantity: the base price and the price with each add-on. Empty the list to hide the table.
Price texts one item: money, quantity, flat, each, minimum, with addon The wording every price is written in (see below). Only the first item is used.

Money is typed US style (1,000, $95, 12.50); everything is computed in whole cents, so decimal prices stay exact. Example: tiers 10 / flat 1,000, 20 / each 100, 50 / each 95, 1000 / each 90 price 25 units at 1,000 + 10 × 100 + 5 × 95 = $2,475, and an add-on at each 30, minimum 300 adds $750 to that.

The slider. It runs from the end of a flat-only first tier (that tier is a minimum, so every smaller quantity costs the same — "$1,000 covers the first 10") or from 1, up to the last tier's up to (an open last tier stops it at twice where that tier starts, and the editor says so). It moves through each decade in the same length of track — single units below 100, tens below 1,000, hundreds below 10,000 — so small sizes are easy to pick; the number box beside it takes any exact quantity and holds it to the range when the visitor leaves the field.

The consultation button carries the estimate. When the visitor clicks it, the script adds quantity, addons (the ticked add-ons' names, comma-separated) and estimate (the formatted total, e.g. $6,350) to the button's link, before any #fragment and in place of any parameter of the same name the link already has: /contact?quantity=50&addons=24%2F7%20Support&estimate=%246%2C350. Until then the link stays exactly as written, so the page never advertises one URL per price to a crawler. Anchors (#…), tel: and mailto: links are left alone. To receive the values, add three Hidden fields to the form on that page and turn on Prefill → URL parameter with the names quantity, addons and estimate (forms-and-submissions.md); they land in the submission and its notification email. Prefill is baked into the page's HTML, so on a response-cached contact page each distinct estimate a visitor arrives with is its own cache entry (expiring with the normal cache lifetime) — one per click-through, not one per slider position. The estimate is the visitor's own figure: treat it as what they saw, not as a quote.

Price texts. The one item in Price texts holds the wording: money (${amount}), quantity ({count} users — change the noun here, e.g. {count} endpoints), flat ({price} flat, a tier's flat price — {price} monthly minimum reads better for a first-tier minimum), each ({price} each), minimum ({price} minimum, an add-on's floor) and with addon (With {name}, the example table's add-on column). Edit or translate them like any list item, keeping each {placeholder}; a text that loses its placeholder falls back to the bare number rather than showing half-filled. Numbers are always written with US separators (1,000.50). The texts resolve like the other lists, so wording saved in the editor, translated, or written into the row's own blade (a hand-built or pasted row) all reach both the printed prices and the live ones. A row placed on 1.9.28 kept its texts in six separate fields instead; it still renders, reading saved edits only.

In the editor every add-on line of the breakdown shows and no tier is dimmed. A warnings box names a tier that ends at or below the one before it (it is skipped), a tier with no price, an open tier before the last one (the tiers after it are never reached), an open last tier, an add-on with no name, an example with no quantity or one past a closed last tier (left out, since units past the last tier are never priced), and an empty tier list.

Translations. Add-on names and descriptions and every label translate like any other text. The numbers (up_to, flat_price, price_each, minimum, quantity) are structural and never translate.

Under the hood. App\Support\DesignLibrary\TieredEstimate normalises the lists, prices the first paint, the tier table and the example table, and writes each tier's range and cents (data-est-from/to/flat/each), each add-on's cents (data-est-flat/each/minimum) and the resolved price texts (data-est-money-text, -count-text, -minimum-text, on the estimator's root) into the page, so the live figures are written exactly like the printed ones. Its TEMPLATE_DEFAULTS (the fallback for a blank text) must match the row's default Price texts item (guarded by the row test). resources/js/tiered-estimator.js (Alpine tieredEstimator, registered in both public.js and app.js because the editor's preview iframe loads only app.js) recomputes from those attributes, so the bracket rules live in PHP once and the browser only adds up. The two must agree: tests/Unit/TieredEstimateTest.php and tests/Unit/TieredEstimatorScriptTest.php (via tests/scripts/run-tiered-estimator.mjs) run the same price table, and tests/Feature/TieredEstimatorRowTest.php renders the row and checks the script against the PHP class at a spread of quantities.

Adding a built-in row (developers)

To add a row to the shipped library — one that ships to every install and updates with the CMS — drop a file in via the file system:

  1. Drop a new {name}.blade.php file into a category folder under resources/design-library/rows/
  2. Add a frontmatter comment with @name, @description, and @sort — and, for a hero, closing banner (page-content/cta), page-title, header or footer row, its three design tags (see Design tags below)
  3. Build the row using only <x-dl.*> components (every visible class must go through one). Every slug the row names — each slug= attr, each @dlItems / @dlLoop first argument, each content() read — must be __SLUG__; RowSlugConsistencyGuardTest fails on anything else (why)
  4. Run php artisan design-library:index to register it in the database

The row appears in the picker on the next editor load. No manual database insert is required. (For an install-specific row that should not ship upstream, use Save your own rows above instead — the dashboard/editor flows write to the git-ignored custom tree.)

A rename or an edit keeps the rest of the frontmatter. Renaming a row in the editor, or editing it on the Design Library page, rewrites its file in place with the name, description and order the form holds (and whether it is a whole-page design). Every other line of its frontmatter is carried over as it was, in its order — @requiresFeature, @requiresContentType, @bundleOnly, @pattern, the design tags, a line only another reader uses (a popup's @panelWidth) and one nothing reads yet — so a rename never ungates a row on the next index (DesignLibraryService::writeTemplateFile(); a write that sets one of those lines itself replaces the file's own).

The full author guide — frontmatter format, field-key naming conventions, the complete <x-dl.*> vocabulary, image performance rules, and color usage rules — lives in the "Design Library" section of CLAUDE.md.

Design tags

The AI site build chooses heroes, closing banners, page-title bands, headers and footers by design tags — what leads the row, how loud it is, and what it needs from the site — so it can tell two rows apart by more than their names (see AI Site Build → How the build tells rows apart). Every shipped row in those five folders carries three lines in its frontmatter:

{{--
@name Hero Photo Background
@description Full-bleed photo hero …
@sort 11
@leads photo
@volume credible
@needs photo
--}}
Line Values
@leads — what carries the row (one value) hero: photo, person, type, device, collage, video, product, records · closing banner: type, photo, device, list · page-title: photo, type, color · header: nav, action, search, shop, wordmark, contact · footer: minimal, brand, facts, links
@volume — how loud credible (what a good site in the trade looks like) or expressive (motion, poster-scale type, collage, hand-lettering, colour fields) — the design direction's own scale
@needs — what the site must supply for it to look right (a comma list, or none) photo, photos, portrait, video, screenshot, logos, records (the site's own posts or job openings), form (somewhere for a visitor's answers to go), offer (a real promotion or notice — an announcement bar), login (it shows a Log In link), shop (it shows a cart), overlay (it sits over the first section of every page), backend (its form sends nothing anywhere until someone wires it up by hand — a site build never places such a row)

The meaning of each value is in App\Support\DesignLibrary\RowDesignTags (LEADS_MEANING, NEEDS_MEANING), which is also what the assistant is shown.

  • A shipped row without them fails a test. tests/Unit/DesignLibraryRowTagsGuardTest.php reads every file in rows/hero, rows/page-content/cta, rows/page-title, rows/header and rows/footer and fails on a missing line, a misspelled value or a value from another kind of row. The only exemption is a theme page's own copy of a row (@bundleOnly), which no picker offers and themes:extract-bundles rewrites.
  • Whole or untagged. design-library:index stores them in design_rows.design_leads / design_volume / design_needs. A row with any mistake stores none at all (and the index logs why), and a row with none — including every row in your own library unless you add the lines yourself — is still listed to the assistant, marked untagged, never hidden.
  • They survive a rewrite. Renaming or editing a row from the dashboard on an authoring install rewrites its frontmatter; the tags it had are carried over, with every other line the form does not set (below).

Every FAQ row's answer is rich text: the sidebar gives it the rich-text editor, with a link button, bold, lists and paragraphs, and the page shows the link as a real link. Questions stay plain text.

  • Links work in every language. A link typed as /book in an answer opens /es/book for a Spanish visitor — internal links inside any list item's text get the visitor's language prefix the same way the rest of the page's copy does. Never type /es/ into a translated answer; an external link, a #anchor or an already-prefixed link is left as written.
  • Search engines get plain text. The FAQ schema (FAQPage) reads each answer as plain text: tags removed, &amp; decoded, a space kept between paragraphs.
  • Only formatting gets through. An answer can hold what the editor makes — links, bold, italics, underline, strikethrough, paragraphs, line breaks, lists, small headings (h2–h4), quotes, code and horizontal rules — and nothing else. A FAQ row can be bound to a data source whose text a visitor or a third party wrote (reviews, social posts), so any other tag is dropped (its text kept), every attribute but a link's address, new-tab and rel is removed, a link to anything but a web page, an email address, a phone number or text message, a path or an #anchor is refused, and tags are balanced so an unclosed link or bold can't spill over the rest of the page.
  • Older pages keep what they have. A FAQ row already on a page keeps its plain-text answers until it is refreshed from the design library. After a refresh, an answer that is plain text — even one that mentions a tag, like "add <meta> to your <head>" — still shows exactly as before.
  • Answers are edited in the sidebar. In the three list-style FAQ designs (Simple List, Two Column, Categories) the answer is no longer click-to-edit on the canvas: an in-place edit saves plain text and would delete an answer's links.

Under the hood. Rows echo the answer as {!! safe_rich_text($faq['answer']) !!} (SafeRichText): a value with no allowed tag and no HTML entity is escaped exactly as {{ }} escapes it; anything else is split into tags and text, the text escaped (keeping existing entities, so a typed <p> stays text) and each tag rebuilt from the allowlist or dropped. DesignLibraryService::stampRepeaterHtmlKeys() reads that echo as a rich sub-field and tells the editor. The value never passes through content(), so Resolver::expand() (@dlItems) runs every item string — nested ones and data-source records included — through LinkLocalizer::localizeHtml() on its way out. The FAQ schema uses PlainText::fromRichText(), which spaces block boundaries only. The answer sits in a div with dl-rich-text (never a <p>, which can't contain paragraphs). Tests: tests/Feature/FaqAnswerLinksTest.php (including a guard that every shipped row and theme page uses safe_rich_text) and tests/Unit/SafeRichTextTest.php.

Card copy works the same way (2026-10-29). A repeater's prose keys — a card desc, a step text, a band body, a team bio — render through safe_rich_text() too, so an owner can put a link in a feature card. RichEchoUpgrader is the one converter for both the library templates and the copies already in pages: it upgrades an escaped echo only when the key names prose, the loop variable isn't visitor copy ($review, $quote, $testimonial), the echo is the whole of a block element, and no ancestor is a link (a whole-card tag="a" / :link-url card keeps plain text, since a link can't sit inside a link). A p wrapper becomes a div with dl-rich-text. Rows already on an install's pages are upgraded by php artisan rows:upgrade-rich-echoes --fix (dry run without --fix); the update that shipped it schedules one run from the pages:reconcile-index sweep (ScheduledRichEchoUpgrade). Saved plain text renders exactly as before. Tests: tests/Unit/RichEchoUpgraderTest.php (including a guard that no shipped row or theme page has an echo left to upgrade) and tests/Feature/UpgradeRichEchoesCommandTest.php.

Countdown rows end on the site's clock

The five countdown rows (Page Content → Countdown) tick down to the date and time picked in their Countdown Target field, read in the site's time zone (Settings → Business). A sale that ends "Dec 31, 11:59 PM" ends at the same moment for every visitor, and at the same moment as anything the server enforces, like a discount code's expiry. The sidebar names the zone under the picker.

  • Under the hood. The countdown wrapper emits data-target="{{ \App\Support\DateValue::instant(content(…, 'countdown_target', …))?->toIso8601String() }}": the target as an instant with its offset, worked out for the target date (a December end on a Central site is -06:00 even in October). The browser's new Date() reads that the same everywhere. A value that already carries an offset or Z keeps it. The page is response-cached, and changing the site time zone clears that cache, so cached pages pick up the new offset.
  • Pages built before this are healed. A row's markup is copied into the page when it is placed, so an update schedules pages:heal-site-clock (SiteClockPageHeal), which rewrites the old data-target in every page, shared row, header/footer, popup, parked theme, custom row and custom theme — along with the shop product page's gift-card delivery-date bounds and review dates, which had the same problem. It runs once, from lazy cron, after the update. A countdown whose markup was edited by hand is left alone and logged. Run php artisan pages:heal-site-clock --dry-run to see what it would change.

Accessible interactive components

The interactive components in the library — sliders/carousels and accordions (FAQ rows and beyond) — ship with WCAG 2.1 AA-friendly keyboard support and ARIA semantics built in. Sliders keep inactive slides out of the keyboard tab order; accordions follow the WAI-ARIA accordion pattern (Up/Down/Home/End focus navigation, aria-expanded, role="region" panels). Because the behavior lives in the shared <x-dl.*> components rather than in each row template, every row built on them inherits it automatically — and any new slider or accordion row gets it for free.

See accessible-interactive-components.md for the full breakdown.

What lives where

Path Purpose
resources/design-library/rows/ Shipped row templates organised by category (update with the CMS).
resources/design-library/pages/ Shipped page bundles organised by website type (@rows manifests — the Page Library tab; theme pages compile from these).
resources/design-library-custom/ Install-authored custom rows + page bundles (git-ignored, merge-safe, is_custom).
app/Support/Pages/PageBundleMaterializer.php "Use this page" — materializes a bundle into a live page with fresh row slugs and its sections' surfaces; layOut() is the AI site build's lenient path into an existing empty page.
app/Support/DesignLibrary/PageDesigns.php The @rows grammar (template:surface, role:key) and design lookups by key.
app/Support/Themes/ThemePageComposer.php themes:build — compiles bundles into a theme's frozen stub pages.
resources/design-library/items/ Insertable item snippets shown in the in-row + picker.
resources/views/components/dl/ The shared <x-dl.*> Blade components every row is built from.
app/View/Components/Dl/ The PHP classes for those components, including schemaFields() declarations.
app/Support/Rows/RowItemLibrary.php Catalogue of items + grid-layout presets for the add-item picker.
app/Concerns/EditorLibraryActions.php Add-row drawer, browse-mode (replace template), insert blank starter, push classes to library.
app/Concerns/EditorLibraryExportActions.php Save section / save whole page / rename custom rows from the editor.
app/Support/LibraryRowExporter.php Serializes a live section back into a reusable custom row template.
app/Models/DesignRow.php Row entry — name, category, source file, schema fields, is_custom.
app/Jobs/IndexDesignLibraryJob.php The job that scans both the shipped and custom trees and writes rows to the database.

For the broader page editor that hosts the library drawer, see page-editor-tour.md.