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 install and composer update copies Deck's files into public_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') — writes lang and dir on <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-brand and a [hidden] rule here.
  • app.components — reusable pieces, and corrections to Deck components.
  • app.pages — one page or one screen. Keel's .settings-page and .stage live 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:

  • container centres and bounds the page; settings-page is Keel's own width and padding, from app.pages.
  • stack stack-6 spaces children with gap, not margins. The number is the gap step.
  • bar is a flex row with space between; push pushes an item to the far end.
  • breadcrumb on an <ol> draws the trail and its separators. Mark the current page with aria-current="page" rather than a link.
  • <h1 class="h2"> is deliberate, not a typo. The heading levels are the document outline; h1–h6 are also type-scale classes you can put on any element. Deck's h1 is display-sized, which suits a landing page and overpowers an app screen, so app pages keep the <h1> element and take the h2 size.
  • split is a main column plus a rail. --rail sets the rail width.
  • card › card-header › card-title, then card-body. A form can be the card body directly — card-body supplies the gap between fields.
  • field › label + input/select is the form triple. Always pair for and id.
  • table-wrap scrolls a wide table; table-stack restacks each row into labelled lines below 40rem. Every <td> needs a data-label — that attribute is where the stacked label comes from, and a cell without one stacks with a blank label. Leave table-stack off long tables; it makes them very tall.
  • empty › empty-art + empty-title is the empty state. table-empty is Keel's, so the cell padding does not double up.
  • badge-good and badge-warn are semantic tones, not colours. So are alert-good, alert-warn and alert-bad.
  • btn btn-primary btn-block — one primary button per screen. btn-block makes 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. .modal is styled; nothing calls showModal(). Keel opens a <dialog class="modal"> from a form carrying data-confirm, and posts it from a button carrying data-confirm-submit. With scripts off, the form posts straight through.
  • Tabs. .tabs is 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

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.