Building the Interface
Keel's interface is built entirely in Deck — every view, form, table and error page. There is no npm, no bundler and no build step: composer install publishes a stylesheet, and you write HTML against it.
This page is about building a page in Keel. It is not a class reference. Deck's own docs cover every component in more detail than is worth duplicating here; this page covers the wiring, then points you there.
Written against Deck 0.1.3. Installed here: 0.1.3.
Where Deck lives
Deck is a Composer package. It is not vendored, not copied by hand, and not committed.
composer require echodial/deck
Keel's composer.json opts in to publishing:
"extra": {
"deck": {
"publish-to": "public_html/deck",
"auto-publish": true
}
}
- Every
composer installandcomposer updatecopies Deck's files intopublic_html/deck/. - That directory is in
.gitignore. It is build output, restored by Composer on a fresh clone. - Never edit anything in
public_html/deck/. The next install overwrites it. - To republish without a full install:
composer deck-publish.
Keel publishes to public_html/deck rather than Deck's default public/assets/deck, because Keel's web root is public_html/.
What the head partial emits
Every view includes views/partials/head.php, which configures Deck and calls Deck::head():
\EchoDial\Deck\Deck::configure([
'base' => '/deck',
'root' => dirname(__DIR__, 2) . '/public_html',
'extras' => $deckExtras ?? false,
]);
base is the public URL prefix; root is the filesystem path it maps to, which Deck uses to fingerprint the files. Deck::head() then emits:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="stylesheet" href="/deck/deck.min.css?v=1789159982">
<script src="/deck/deck.js?v=1789159982" defer></script>
<script>/* points Deck at /deck/deck-icons.svg */</script>
That is the whole stylesheet — one file, every component. Keel's own CSS and JS load immediately after it, and the order matters:
<?= \EchoDial\Deck\Deck::head() ?>
<link rel="stylesheet" href="/css/keel.css">
<script src="/js/keel.js" defer></script>
You do not call any of this yourself. Requiring the head partial is enough.
The PHP helpers
Deck ships four helpers Keel uses. All are static on EchoDial\Deck\Deck.
Deck::head()— the tags above. Called once, in the head partial.Deck::htmlAttributes(lang: 'en')— writeslanganddiron<html>.Deck::theme(hue: null, mode: null, chroma: null)— writes the theme attributes on<html>. See Theming.Deck::icon(name, class, label)— renders an SVG sprite reference.
Every Keel view opens the same way:
<html <?= Deck::htmlAttributes(lang: 'en') ?> <?= Deck::theme(mode: Theme::serverPreference()) ?>>
Icons come from a sprite of 75 symbols, each with a smaller -sm variant:
<?= Deck::icon('trash') ?>
<?= Deck::icon('copy', 'icon icon-sm') ?>
<?= Deck::icon('shield', 'icon', 'Security') ?>
Without a third argument the icon is aria-hidden, which is what you want beside a text label. Pass a label only when the icon is the only content. Names include check, x, plus, trash, edit, copy, user, users, mail, lock, shield, clock, credit-card, alert-circle, alert-triangle, check-circle, settings and sparkle.
Your CSS goes in the app layers
Deck declares its cascade layers in a fixed order and leaves four at the end for the application:
@layer
deck.reset, deck.tokens, deck.type, deck.layout, deck.components,
deck.mobile, deck.motion, deck.effects, deck.utilities, deck.rtl, deck.print,
app.base, /* your resets and element styles */
app.components, /* your components and deck overrides */
app.pages, /* per-page tweaks */
app.overrides; /* the escape hatch */
A rule in any app.* layer beats any Deck rule regardless of specificity, because later layers win. That is the whole mechanism: you never need !important, and never need to out-specify Deck.
A real example. Deck's .table-wrap brings its own border and radius, so inside a .card it draws a second frame one pixel in from the first. Keel flattens it in public_html/css/keel.css:
@layer app.components {
.card > .table-wrap {
border: 0;
border-radius: 0;
}
}
A single class selector overriding a Deck component, with no !important. It works because app.components is declared after deck.components.
This only holds if keel.css loads after deck.css, which the head partial guarantees. Loaded first, the @layer blocks in keel.css would declare the order themselves, and Deck would win everything.
Which layer to use:
app.base— element defaults and tokens. Keel sets--hue-brandand a[hidden]rule here.app.components— reusable pieces, and corrections to Deck components.app.pages— one page or one screen. Keel's.settings-pageand.stagelive here.app.overrides— last resort. Empty in Keel.
A complete view
A projects list: a header, a table, an empty state, and a form beside it. Controller, route, view.
1. The controller
<?php
namespace Keel\App\Controllers;
use Keel\Core\Controller;
use Keel\Core\Request;
class ProjectController extends Controller
{
public function index(Request $request): void
{
$this->view('projects.index', [
'title' => 'Projects',
'projects' => [
['name' => 'Onboarding revamp', 'status' => 'active'],
['name' => 'Billing migration', 'status' => 'paused'],
],
]);
}
}
2. The route
use Keel\App\Controllers\ProjectController;
$router->get('/projects', [ProjectController::class, 'index']);
3. The view
views/projects/index.php:
<?php
use EchoDial\Deck\Deck;
use Keel\Core\Csrf;
use Keel\Core\Theme;
$projects = $projects ?? [];
?>
<!DOCTYPE html>
<html <?= Deck::htmlAttributes(lang: 'en') ?> <?= Deck::theme(mode: Theme::serverPreference()) ?>>
<head>
<?php require __DIR__ . '/../partials/head.php'; ?>
</head>
<body>
<span id="top" tabindex="-1"></span>
<main class="container settings-page stack stack-6">
<header class="bar">
<div class="stack stack-2">
<nav aria-label="Breadcrumb">
<ol class="breadcrumb">
<li><a href="/dashboard">Dashboard</a></li>
<li><span aria-current="page">Projects</span></li>
</ol>
</nav>
<h1 class="h2">Projects</h1>
</div>
<?php $themeToggleClass = 'push'; require __DIR__ . '/../partials/theme-toggle.php'; ?>
</header>
<div class="split">
<section class="card" aria-labelledby="list-title">
<div class="card-header">
<h2 id="list-title" class="card-title">All projects</h2>
</div>
<div class="table-wrap">
<table class="table table-stack">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<?php if ($projects === []): ?>
<tr>
<td colspan="2" class="table-empty">
<div class="empty">
<span class="empty-art"><?= Deck::icon('folder') ?></span>
<p class="empty-title">No projects yet</p>
<p>Create one with the form beside this table.</p>
</div>
</td>
</tr>
<?php endif; ?>
<?php foreach ($projects as $project): ?>
<tr>
<td data-label="Name" class="fw-medium"><?= htmlspecialchars((string) $project['name']) ?></td>
<td data-label="Status">
<span class="badge <?= $project['status'] === 'active' ? 'badge-good' : 'badge-warn' ?>">
<?= htmlspecialchars((string) $project['status']) ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section class="card" aria-labelledby="new-title">
<div class="card-header">
<h2 id="new-title" class="card-title">New project</h2>
</div>
<form method="POST" action="/projects" class="card-body">
<?= Csrf::field() ?>
<div class="field">
<label class="label" for="name">Name</label>
<input class="input" type="text" id="name" name="name" required>
</div>
<div class="field">
<label class="label" for="status">Status</label>
<select class="select" id="status" name="status">
<option value="active">Active</option>
<option value="paused">Paused</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-block">Create project</button>
</form>
</section>
</div>
</main>
<?php require __DIR__ . '/../partials/back-to-top.php'; ?>
</body>
</html>
What each piece is doing:
containercentres and bounds the page;settings-pageis Keel's own width and padding, fromapp.pages.stack stack-6spaces children withgap, not margins. The number is the gap step.baris a flex row with space between;pushpushes an item to the far end.breadcrumbon an<ol>draws the trail and its separators. Mark the current page witharia-current="page"rather than a link.<h1 class="h2">is deliberate, not a typo. The heading levels are the document outline;h1–h6are also type-scale classes you can put on any element. Deck'sh1is display-sized, which suits a landing page and overpowers an app screen, so app pages keep the<h1>element and take theh2size.splitis a main column plus a rail.--railsets the rail width.card›card-header›card-title, thencard-body. A form can be the card body directly —card-bodysupplies the gap between fields.field›label+input/selectis the form triple. Always pairforandid.table-wrapscrolls a wide table;table-stackrestacks each row into labelled lines below 40rem. Every<td>needs adata-label— that attribute is where the stacked label comes from, and a cell without one stacks with a blank label. Leavetable-stackoff long tables; it makes them very tall.empty›empty-art+empty-titleis the empty state.table-emptyis Keel's, so the cell padding does not double up.badge-goodandbadge-warnare semantic tones, not colours. So arealert-good,alert-warnandalert-bad.btn btn-primary btn-block— one primary button per screen.btn-blockmakes it full width.
No widths, no colours, no spacing values in the markup. If you find yourself stacking utilities to build something, there is usually a component for it.
This view uses split because it is a main column plus a rail. The other layout primitive you will reach for constantly is grid, which wraps equal-width cards without a column count:
<div class="grid" style="--min: 20rem">
<article class="card">...</article>
<article class="card">...</article>
</div>
--min is the narrowest a column may get before the grid drops to fewer columns; set it per grid. The named variants grid-tight and grid-wide are presets for the same knob, and grid-wide's 24rem floor fits only two columns in a 76rem container — so when three cards across is what you want, set --min yourself. Keel's landing page does exactly that in three places.
The spacing steps behind stack-2, stack-6 and the --space-* tokens are listed in Deck's spacing reference.
JavaScript
deck.js loads on every page and drives:
[data-deck-theme]— the theme switch.[data-deck-btt]— back to top.- The combobox, date picker, sortable data grid, and icon sprite injection.
deck-extras.js is a second, larger bundle and is off by default. Set $deckExtras = true before requiring the head partial to load it:
$deckExtras = true; // this page uses the copy button
It drives data-deck-copy, data-deck-drawer, data-deck-carousel, data-deck-mega and data-deck-qr. Only two Keel views ask for it: the landing page, and the API tokens page when a new token is on screen.
Two things Deck styles but does not wire, which Keel supplies in public_html/js/keel.js:
- Modals.
.modalis styled; nothing callsshowModal(). Keel opens a<dialog class="modal">from a form carryingdata-confirm, and posts it from a button carryingdata-confirm-submit. With scripts off, the form posts straight through. - Tabs.
.tabsis styled; the roving tabindex and arrow keys are Keel's, on the login page.
Keel's JS also persists a theme change to the server. Everything else on the page works with JavaScript off.
Where to look things up
- Deck docs — the full reference.
- Class reference — every public class, by layer.
- PHP helper —
Deck::head(),icon(),theme(),htmlAttributes(). - Cascade layers — why
app.*beatsdeck.*. - Layout —
container,stack,grid,split,bar,cluster. - Components — one page each.
- JavaScript API —
Deck.toast(),Deck.copy(),Deck.theme().
The fastest local reference is the stylesheet itself. Every component in public_html/deck/deck.css carries a comment above it explaining what it is for and how it is meant to be used.