Skip to content

Start Here

Bonus: Style with wheels-basecoat

You’ll install the wheels-basecoat package, swap simple.css for proper component CSS, and rewrite the post show view using uiCard, uiField, and uiButton helpers. By the end, the blog renders as a shadcn/ui-quality interface — without React, without a build step.

You’ll learn:

  • How to install a Wheels package via wheels packages add
  • What “activation” means in the Wheels package model — files in vendor/ + manifest = the integration
  • How package.json’s provides.mixins injects helpers into controller scope (and therefore views)
  • Three core basecoat components: uiCard, uiField, uiButton
  • How to serve an external CSS asset alongside the framework’s own static files

Estimated time: 30 minutes.

simple.css is a 6 KB classless stylesheet — it styles raw <input>, <button>, <form> tags via type and tag selectors. That’s perfect for the main tutorial because zero markup changes are needed: every form helper Wheels emits gets styled automatically.

basecoat goes further. It ships CFML view helpers (uiCard, uiField, uiButton, etc.) that emit semantic HTML pre-tagged with Basecoat UI Tailwind classes. The output is shadcn/ui-quality components: real buttons with variants (primary/secondary/destructive/outline), cards with header/content/footer, form fields with label/input/error wrapping, alerts, badges, dropdowns. No React. No build step. Just helpers in your views and a CSS file in your <head>.

simple.css (default)wheels-basecoat
DistributionCDN <link>Wheels package + CSS asset
Markup changesNone — works on plain HTMLHelper-driven (#uiButton(...)#)
Component vocabularyBrowser defaults (h1, button, form, table)Buttons, cards, fields, alerts, dropdowns, badges, navs
Visual registerModern editorialShadcn/ui dashboard-grade
Best forTutorials, docs, content-heavy sitesApp UIs, dashboards, forms-heavy sites

For a tutorial blog, simple.css is the right default. For a real product, you’ll want basecoat or something like it. This chapter is the bridge.

You finished Part 7 with the full blog working: posts, comments, authentication, three test specs. The layout uses simple.css from a CDN. Every form renders with the framework’s built-in helpers (#textField()#, #textArea()#, #submitTag()#).

You’ll undo the simple.css link, add the basecoat asset, and rewrite the post show view in this chapter. The other views stay on simple.css until you decide to convert them — that’s a follow-on exercise, not part of this chapter’s scope.

The canonical way to install a Wheels package is wheels packages add <name>. The CLI talks to the wheels-dev/wheels-packages registry, downloads a verified tarball, checks the SHA-256, and extracts to vendor/<name>/. From that moment on, the package is active — the framework’s PackageLoader auto-discovers it on the next app start.

  1. From your blog directory, install basecoat:

    your shell
    wheels packages add wheels-basecoat

    Expected output (version may differ — the registry serves the latest compatible version):

    expected output
    Installed wheels-basecoat@<version> → /your/path/blog/vendor/wheels-basecoat
    Run `wheels reload` (or restart) to activate it.
  2. Verify the install:

    your shell
    ls vendor/wheels-basecoat

    You should see Basecoat.cfc, package.json, README.md, and a tests/ directory.

  3. Restart the application so onApplicationStart re-fires and PackageLoader discovers the new package:

    your shell
    wheels reload

    wheels reload works because an authorized reload stops and restarts the application, re-running onApplicationStart — and with it the package loader. A full wheels stop && wheels start (as the install message also hints) does the same thing, just more heavily. (CLI versions predating the #3110 fix claimed only a stop/start activates a package; that claim was wrong.)

Look at vendor/wheels-basecoat/package.json:

vendor/wheels-basecoat/package.json (version may differ)
{
"name": "wheels-basecoat",
"version": "3.0.0",
"description": "Basecoat UI component helpers for Wheels...",
"wheelsVersion": ">=4.0",
"provides": {
"mixins": "controller",
"services": [],
"middleware": []
},
"dependencies": {}
}

Three fields matter for activation:

  • wheelsVersion — declares which Wheels versions this package is compatible with. The framework refuses to load a package whose constraint doesn’t match the running runtime version.
  • provides.mixins — comma-separated mixin targets. controller means “inject this package’s public methods into every controller’s variables scope”. Because Wheels views render in their controller’s variables scope, controller-scoped mixins are also automatically available in views.
  • dependencies — other packages this one needs. Empty for basecoat.

The Basecoat.cfc file alongside the manifest is the package’s entry point. PackageLoader instantiates it once at app start and harvests all its public methods as mixins.

The package ships its CSS+JS bundle at vendor/wheels-basecoat/assets/basecoat/. PackageLoader activates the CFML helpers automatically, but it doesn’t expose vendor/ at any URL — so you publish the static assets to public/ once and the dev server takes care of the rest. This is the same copy-or-symlink pattern Rails, Django, and Phoenix use for npm-distributed frontend packages.

  1. Copy the bundled assets into public/:

    your shell
    cp -r vendor/wheels-basecoat/assets/basecoat public/assets/basecoat

    This is a one-shot operation. If you upgrade the package later (wheels packages update wheels-basecoat), re-run the copy to refresh the bundled CSS+JS.

  2. Confirm the copy:

    your shell
    ls public/assets/basecoat/basecoat.min.css

public/ is served by the dev server at the root URL, so the CSS is now available at http://localhost:8080/assets/basecoat/basecoat.min.css.

app/views/layout.cfm currently links simple.css. Replace that with the basecoat helper, which emits the CSS link and Alpine.js script tags (Alpine.js powers basecoat’s interactive components — dropdowns, dialogs, tabs).

  1. Open app/views/layout.cfm. Find the simple.css <link> (added by wheels new in Part 1):

    app/views/layout.cfm — current state
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>blog</title>
    <cfoutput>#csrfMetaTags()#</cfoutput>
    <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
    </head>
  2. Replace the simple.css link with #basecoatIncludes()#:

    app/views/layout.cfm — after
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>blog</title>
    <cfoutput>
    #csrfMetaTags()#
    #basecoatIncludes()#
    </cfoutput>
    </head>

    basecoatIncludes() reads bundled defaults and emits the <link> for the CSS you just published, plus the <script> tags that power basecoat’s interactive components (dropdowns, dialogs, tabs) and the Turbo cache-control hint that prevents stale previews of those widgets. If you publish to a non-default path, pass basecoatCSSPath="/your/path/basecoat.min.css" to override.

  3. Reload the server (full restart isn’t needed for a view-only change):

    your shell
    wheels reload
  4. Visit http://localhost:8080 — the appearance changes from simple.css editorial to basecoat dashboard styling.

app/views/posts/show.cfm currently uses plain HTML for the post body and a hand-rolled comment form. Convert it to basecoat’s component helpers.

  1. Replace app/views/posts/show.cfm entirely:

    app/views/posts/show.cfm
    <cfparam name="post" default="">
    <cfoutput>
    #uiCard()#
    #uiCardHeader(
    title=post.title,
    description="status: " & post.status & " · published " & DateFormat(post.publishedAt, "mmm d, yyyy")
    )#
    #uiCardContent()#
    <p>#post.body#</p>
    #uiCardContentEnd()#
    #uiCardFooter()#
    #linkTo(route="editPost", key=post.id, text="Edit", class="btn btn-secondary")#
    #buttonTo(route="post", key=post.id, text="Delete", method="delete", class="btn btn-destructive")#
    #linkTo(route="posts", text="← all posts", class="btn btn-ghost")#
    #uiCardFooterEnd()#
    #uiCardEnd()#
    #uiCard()#
    #uiCardHeader(title="Comments")#
    #uiCardContent()#
    <section id="comments">
    <cfset comments = post.comments()>
    <cfloop query="comments">
    <article style="margin-bottom: 1em;">
    <strong>#comments.author#</strong>
    <span style="color: var(--bc-muted-foreground);">· #DateFormat(comments.createdAt, "mmm d")#</span>
    <p>#comments.body#</p>
    </article>
    </cfloop>
    </section>
    #uiCardContentEnd()#
    #uiCardEnd()#
    <turbo-frame id="new_comment">
    #startFormTag(route="postComments", postKey=post.id)#
    #uiField(label="Your name", name="comment[author]", type="text", required=true)#
    #uiField(label="Your comment", name="comment[body]", type="textarea", required=true, rows=4)#
    #uiButton(text="Post comment", variant="primary", type="submit")#
    #endFormTag()#
    </turbo-frame>
    </cfoutput>
  2. Visit a post: http://localhost:8080/posts/1. You should see the post wrapped in a card, the comment form using basecoat field helpers, and a primary-styled “Post comment” button.

What changed:

  • uiCard / uiCardHeader / uiCardContent / uiCardFooter wrap content into a styled card with proper border, padding, and section spacing. The *End() helpers close the corresponding open tags — basecoat’s block components are explicit about their closing markers (no implicit close-on-newline).

  • uiField wraps label + input + error display in one helper call. Pass type="textarea" for multi-line, type="select" with an options arg for dropdowns. The helper handles all the basecoat-specific class names you’d otherwise type by hand.

  • uiButton emits a styled button. Variants: primary, secondary, destructive, outline, ghost, link. Sizes: sm, md, lg.

The comment form keeps the <turbo-frame id="new_comment"> wrapper from Part 5 so Turbo Stream replies still work — basecoat is purely presentational, it doesn’t replace Turbo’s behaviour.

Three things to verify:

  1. The show page renders as a basecoat card:

    your shell
    curl -s http://localhost:8080/posts/1 | grep -oE 'class="card[^"]*"' | head -3

    Expected: at least one class="card" match in the output.

  2. The comment field renders with basecoat’s input class:

    your shell
    curl -s http://localhost:8080/posts/1 | grep -oE 'class="input[^"]*"' | head -3

    Expected: matches for the basecoat input class on the comment author + body fields.

  3. The “Post comment” button is the primary variant:

    your shell
    curl -s http://localhost:8080/posts/1 | grep -oE '<button[^>]*class="btn[^"]*"[^>]*>Post comment</button>'

    Expected: a button tag with class="btn btn-primary" (or similar) wrapping Post comment.

No matching function [UIBUTTON] found — the package didn’t activate. Both wheels reload and wheels stop && wheels start re-run PackageLoader (an authorized reload restarts the application, re-firing onApplicationStart), so the usual cause is a reload that silently no-op’d — a wrong or missing reload password — or the package landing somewhere other than vendor/. Run wheels stop && wheels start to rule out the password variable, and ls vendor/wheels-basecoat to confirm the files are there.

No matching function [$UIBUILDID] found (or [$UILUCIDEICON]) — the package activated but its internal helpers can’t be found. Fixed in wheels-basecoat 1.0.3. Older versions declared the $-prefixed helpers private, but Wheels’ PackageLoader only carries PUBLIC methods across the mixin boundary, so public callers like uiField couldn’t reach them. Run wheels packages update wheels-basecoat --yes then wheels reload (or restart).

No version of 'wheels-basecoat' satisfies runtime '0.0.0-dev' — the framework can’t read its own version. Likely on a Wheels release older than 4.0.0-SNAPSHOT+1670 (the snapshot that includes the runtime-detection fix). Upgrade with brew upgrade wheels (macOS/Linux) or scoop update wheels (Windows) and re-run.

Page renders unstyled — the bundled CSS isn’t being served. Check public/assets/basecoat/basecoat.min.css exists (re-run the cp -r vendor/wheels-basecoat/assets/basecoat public/assets/basecoat step if not) and curl -I http://localhost:8080/assets/basecoat/basecoat.min.css returns 200.

Card content is wrapped in a frame but no styling — basecoat’s JS bundle is failing to load. Check public/assets/basecoat/basecoat.min.js exists and look in the browser console for a network error on /assets/basecoat/.

This chapter converted one view. The other views (index.cfm, new.cfm, edit.cfm, the comment partials, the auth screens from Part 6) are still on simple.css. Converting them is a mechanical exercise — the helpers are documented in the wheels-basecoat README.

You’ve now seen the full Wheels package model end-to-end:

  • Install via wheels packages add (talks to the registry, verifies SHA-256, extracts to vendor/)
  • Activate automatically on app boot via PackageLoader (no registration step, no config edit)
  • Use the package’s helpers as if they were core framework methods
  • Update via wheels packages update <name> --yes
  • Remove via wheels packages remove <name> (deletes vendor/<name>/)

The same pattern applies to every other package in the registry: wheels-hotwire, wheels-i18n, wheels-sentry, wheels-seo-suite, wheels-legacy-adapter. Browse them with wheels packages list.

Reference docs:

  • Packages — the full lifecycle, the manifest schema, writing your own package, publishing to the registry.
  • wheels-basecoat README — the complete component reference (alerts, badges, dropdowns, dialogs, tables, navs).
  • Basecoat UI — the underlying CSS library, including the design tokens you can override.