You are writing CFML in a Wheels 4 application that has the wheels-basecoat package installed. This document tells you everything you need to know to write idiomatic, correct code that uses the package's helpers.
- This file — package architecture, helper inventory, the rules of the road.
.ai/PATTERNS.md— when to use which helper. Decision trees..ai/PITFALLS.md— known footguns. Skim before writing CFML..ai/EXAMPLES.md— scenario-driven recipes. Find the closest match before composing your own..ai/SCAFFOLDS.md— copy-paste templates for index / show / new / edit / login / signup / dashboard pages..ai/HELPERS.md— formal signature reference for every helper.
A Wheels 4.0+ package that ships:
- Bundled
basecoat-css0.3.11 — the upstream CSS framework, pinned to a known-good version, copied intoassets/basecoat/basecoat.min.css. - Bundled
basecoat-js— the upstream interactive component scripts (tabs.js,dropdown-menu.js,popover.js,select.js,command.js,sidebar.js,toast.js) plus anall.min.jsaggregate. wheels-basecoat-ui.js— a tiny CSP-safe shim for dialog open/close + theme toggle + sidebar toggle + slider live mirror, all delegated viadata-ui-*attributes (no inline event handlers).wheels-basecoat-extras.min.css— visual defaults for components basecoat-css doesn't ship CSS for (breadcrumb, pagination, steps, rating, radio).- ~80 CFML helpers that emit basecoat-styled HTML, in a single
Basecoat.cfc. - Argument validation that throws
WheelsBasecoat.InvalidArgumentwith helpful detail for unsupported variant/size/type values.
The whole point: a Wheels developer never has to think about basecoat-css's class names or basecoat-js's contracts. They write #uiBoundField(objectName="post", property="title")# and get a fully styled, model-bound, error-aware form field.
Standard Wheels package. The main CFC (Basecoat.cfc) contains init() and all public helpers. Wheels' PackageLoader injects every public method into the controller scope via the provides.mixins: "controller" declaration in package.json. Because Wheels views execute in the controller's variables scope, the helpers surface transitively in views — call them as #functionName()# in any view file.
wheels-basecoat/
├── CLAUDE.md # This file (AI tools read first)
├── README.md # Human-facing docs
├── CHANGELOG.md
├── Basecoat.cfc # Main package CFC — every helper lives here
├── package.json # Wheels package manifest
├── box.json # CommandBox metadata + install hooks
├── index.cfm # Doc page rendered at /wheels/packages/wheels-basecoat
├── .ai/
│ ├── HELPERS.md # Formal signature reference (every helper)
│ ├── EXAMPLES.md # Scenario-driven recipes
│ ├── SCAFFOLDS.md # Copy-paste page templates
│ ├── PATTERNS.md # When to use which helper
│ ├── PITFALLS.md # Known footguns
│ └── ARCHITECTURE.md # Long-form design context
├── assets/basecoat/
│ ├── basecoat.min.css # Bundled basecoat-css 0.3.11
│ ├── wheels-basecoat-extras.min.css # Our additions
│ └── js/
│ ├── all.min.js # All basecoat-js modules in one file
│ ├── basecoat.min.js # Just the registration kernel
│ ├── tabs.min.js / dropdown-menu.min.js / popover.min.js / select.min.js
│ ├── command.min.js / sidebar.min.js / toast.min.js
│ └── wheels-basecoat-ui.min.js # Our CSP-safe shim
├── examples/showcase/ # Mountable live-render showcase (see "Showcase" below)
├── scripts/install.cfm # CommandBox install hook (publishes assets to public/)
└── tests/
├── BasecoatSimpleSpec.cfc
├── BasecoatComplexSpec.cfc
├── BasecoatV2Spec.cfc / V21Spec / V22Spec / V23Spec / V24Spec
└── BasecoatV30Spec.cfc
The first thing every Wheels app needs in its layout:
<head>
<cfoutput>
##basecoatThemeScript()## <!-- pre-paint theme to avoid FOUC -->
##csrfMetaTags()##
##basecoatIncludes()## <!-- loads CSS + JS + ui shim -->
</cfoutput>
</head>
<body>
...
<cfoutput>##basecoatFlashToasts()##</cfoutput> <!-- renders flash() as toasts -->
</body>That's it. Every helper is now in scope.
If the form references a Wheels model, always prefer the uiBound* family over the unbound primitives. The bound helpers auto-resolve value, name (<obj>[<prop>]), validation error, and humanized label from the controller-scoped model object.
| Input type | Unbound | Bound |
|---|---|---|
| Text / textarea / select / checkbox / switch / date / etc. | uiField |
uiBoundField |
| Rich combobox (search, multi-select) | uiSelect |
uiBoundSelect |
| Range slider | uiSlider |
uiBoundSlider |
| Single boolean | uiField(type=checkbox) |
uiBoundCheckbox |
| Multi-checkbox collection | uiCheckboxGroup |
uiBoundCheckboxGroup |
| Single-choice radio | uiRadioGroup |
uiBoundRadioGroup |
| Model-level error rollup | n/a | uiErrorSummary(model) |
Idiomatic Wheels-bound form:
<cfoutput>
##uiErrorSummary(post)##
##startFormTag(action=isEdit ? "update" : "create", key=post.id ?: "")##
##uiBoundField(objectName="post", property="title", required=true)##
##uiBoundField(objectName="post", property="body", type="textarea", rows=10)##
##uiBoundField(objectName="post", property="status", type="select",
options="draft:Draft,published:Published,archived:Archived")##
##uiBoundCheckbox(objectName="post", property="featured", asSwitch=true)##
<div class="flex justify-end gap-2 pt-2">
<a href="##urlFor(route='posts')##" class="btn-ghost" data-turbo-frame="_top">Cancel</a>
##uiButton(text=isEdit ? "Save" : "Publish", type="submit", icon="check")##
</div>
##endFormTag()##
</cfoutput>The package is Hotwire-aware but Hotwire-independent:
- Form submissions inside
<turbo-frame>— frame-scope is automatic; the controller justrenderPartial("form", post=post, layout=false)on validation failure and Turbo swaps the frame. turboStream(action=, target=)+turboStreamEnd()+turboStreamHeader()— compose Turbo Stream responses from CFML.turboStreamHeader()is mandatory for theContent-Type: text/vnd.turbo-stream.htmlheader that Turbo 8 strictly requires.uiButton(turboConfirm=, turboMethod=)— emitsdata-turbo-confirm/data-turbo-methodso destructive actions get a confirm and Turbo treats the link as the right verb.buttonTo(method="delete", inputClass="btn-destructive", data_turbo_confirm="...", data_turbo_stream="true")— Wheels'buttonToconvertsdata_turbo_*underscore-args to hyphen-attributes, which is how you wire CSRF + Turbo Stream + method-spoofing in one call.
<head>
<cfoutput>##basecoatThemeScript()##</cfoutput> <!-- runs synchronously, before paint -->
<cfoutput>##basecoatIncludes()##</cfoutput>
</head>
<body>
<cfoutput>##uiThemeToggle()##</cfoutput>
</body>basecoatThemeScript() reads localStorage["basecoat:theme"] (or falls back to prefers-color-scheme: dark) and applies .dark to <html> BEFORE first paint. uiThemeToggle() renders a sun/moon button that flips the class and persists. The toggle is delegated via data-ui-theme-toggle (handled by wheels-basecoat-ui.js) — no inline JS, CSP-safe.
The shortest path from "I have a model" to "rendered UI":
<!-- Auto-build a form from the model's properties -->
##uiResourceForm(post)##
<!-- Auto-build a table from a Wheels query result -->
##uiResourceTable(posts, columns="title,status,publishedAt")##
<!-- Pagination UI from a paginated query -->
##uiPaginationFor(posts, baseUrl=urlFor(route="posts"))##These read property metadata from the model (enum(), validations, types) and render the matching helpers. Use them for admin scaffolds and quick prototypes. For polished public-facing pages, hand-author with the bound helpers above.
| Category | Helpers |
|---|---|
| Setup | basecoatIncludes, basecoatThemeScript, basecoatFlashToasts |
| Buttons & display | uiButton, uiButtonGroup (+sep + end), uiBadge, uiAvatar, uiKbd, uiIcon, uiSpinner, uiSkeleton, uiProgress, uiSeparator |
| Feedback | uiAlert, uiCallout, uiToast, uiToaster, uiTooltip, uiEmptyState |
| Containers | uiCard (+ header/content/footer + ends), uiFieldset (+end), uiAccordion (+ item + ends), uiTimeline (+ item + end) |
| Overlays | uiDialog (+footer + end), uiPopover (+ trigger + content + ends), uiCommand family + uiCommandDialog |
| Forms (unbound) | uiField, uiSelect, uiSlider, uiCheckboxGroup, uiRadioGroup, uiTagInput, uiFileUpload, uiDatePicker, uiRating |
| Forms (Wheels-bound) | uiBoundField, uiBoundSelect, uiBoundSlider, uiBoundCheckbox, uiBoundCheckboxGroup, uiBoundRadioGroup, uiBoundFile, uiErrorSummary |
| Tables | uiTable family + uiResourceTable |
| Navigation | uiTabs, uiDropdown, uiBreadcrumb, uiPagination, uiPaginationFor, uiSidebar family + uiSidebarToggle, uiSteps family |
| Code display | uiCodeBlock |
| Theme | uiThemeToggle |
| Hotwire | turboStream, turboStreamEnd, turboStreamHeader |
| Wheels conventions | uiResourceForm, uiResourceTable, uiPaginationFor |
For the formal signature of any helper, see .ai/HELPERS.md.
These are non-negotiable when generating CFML for this codebase:
-
CFML's null-coalescing operator is
?:not??. Lucee 7 silently fails to compile the whole component on??and every helper goes undefined. (Pitfall #1) -
Inside
<cfoutput>,##is the escape for a literal#. Three or five hashes are unbalanced and will throw "Syntax Error, Invalid Construct". When showing source code in a<pre><code>block, either close</cfoutput>around it or use exactly four hashes (##...##). (Pitfall #2) -
A
_form.cfmpartial that's wrapped in<turbo-frame>must keep that frame as the OUTERMOST element. Don't add chrome (breadcrumbs, page header, outer card) inside the partial — those go innew.cfm/edit.cfmaround the partial. On validation failure the controller returns the partial alone for Turbo to swap, and any chrome inside would render nested on the existing page. (Pitfall #3) -
Links inside a
<turbo-frame>are scoped to that frame. Cancel links and breadcrumb back-links inside a form frame must carrydata-turbo-frame="_top"to break out, or Turbo will look for the same frame in the navigation target and surface "Content missing". (Pitfall #4) -
Wheels'
buttonToputs kwargs on the FORM by default. To put a class on the inner<button>, useinputClass="...". Hyphenated attributes likedata-turbo-confirmmust be written asdata_turbo_confirm(underscore form) — Wheels converts to hyphens. (Pitfall #5) -
turboStreamHeader()is mandatory at the top of any partial that emits<turbo-stream>elements. Turbo 8 won't process the stream withoutContent-Type: text/vnd.turbo-stream.html. (Pitfall #6) -
Posts.cfc::config()must callsuper.config()if it overrides config. OtherwiseprotectsFromForgery()from the base controller never runs — non-GET endpoints still validate CSRF (Comments etc.), but Wheels'startFormTagwon't emit theauthenticityTokenhidden field, and the form submission 500s. (Pitfall #7) -
Always prefer the bound helpers (
uiBoundField,uiBoundSelect, etc.) when there's a model object in scope. They handle name-namespacing, value pre-population, error rendering, and label humanization in one call. -
uiCommandanduiCommandDialogitems use<button role="menuitem">(or<a>withhref). Usedata-keep-command-openon items inside a command-dialog that should keep the modal open after a click (e.g. theme toggles). -
Argument validation throws on typos.
uiButton(variant="primay")raisesWheelsBasecoat.InvalidArgumentwith the allowed list. If you see this error in a stack trace, fix the call site — don't catch and continue.
- basecoat-css — the upstream CSS framework (https://basecoatui.com). Pinned to 0.3.11.
- basecoat-js — the upstream component-interaction scripts (tabs, dropdowns, popovers, etc.).
- wheels-basecoat-ui.js — our small CSP-safe shim that handles dialog/theme/sidebar/slider via
data-ui-*delegation. - extras CSS —
wheels-basecoat-extras.min.css, our companion stylesheet for components basecoat-css doesn't ship CSS for. - showcase — the
examples/showcase/mountable controller. Run it in your Wheels app to get a live/basecoat-showcaseURL with every helper rendered alongside its source. Doubles as a visual regression target. - PackageLoader — Wheels' system for mixing package helpers into controller scope.
- frame submission — the Turbo pattern where a form inside
<turbo-frame id="X">swaps just that frame on response.
- Read
.ai/EXAMPLES.md. It has a recipe for almost every common scenario. - Read
.ai/PITFALLS.md. The bugs that bite hardest are listed there with fixes. - The package version is in
package.json::version. Helpers added in a specific version are tagged inindex.cfmwithvX.Ypills. - Run the test suite (
tests/Basecoat*Spec.cfc) — it's the executable spec for every helper's expected output.