Theming HiLMS
Every Blade file a student sees belongs to a theme. The core ships data, routes, block
classes, Livewire classes and translations; the markup and the styling live in
themes/<name>. The default look is simply the first theme, themes/hilms, and it has no
privileges the next one does not.
HILMS_THEME names the active theme. It defaults to hilms.
What a theme is
Section titled “What a theme is”themes/acme/ theme.json the manifest: name, version, parent, build, entries, fonts, dark, options, variants tokens.json the design tokens, in the Design Tokens Format styles/ built-in styles, in the same format views/ every Blade file the core renders, in the paths the contract names lang/{pl,en}.json strings this theme adds, underneath the host's public/ what the browser downloads, linked at public/themes/acmetheme.json keys:
| Key | Meaning |
|---|---|
name |
Must equal the directory name |
version |
The theme’s own version |
hilms |
The HiLMS version it was written against |
parent |
The theme it inherits every view and token from, or null |
build |
bundled (built by the repository’s Vite), standalone (its own Vite) or none (plain stylesheets) |
entries |
Vite entry points, relative to the theme directory |
preview |
The entry the admin panel loads for block previews |
stylesheets |
Plain stylesheets under public/, served from /themes/<name>/… |
fonts |
Family name to the weights the theme uses; self-hosted at build time |
dark |
Whether the theme carries dark values; without it the colour-scheme switch stays out |
options |
The choices of markup a style may make for the parts every page shares, each <part>.<choice> with its values and a default ("header.layout": {"values": ["start", "split"], "default": "start"}); a child adds values or names another default, never takes one away |
variants |
The variants a block offers, by block type, in the same shape ("callout": {"values": ["soft", "bold"], "default": "soft"}); hilms:make-block --variants= declares them |
A view that honours an option or a variant reads it through theme()->option('header.layout')
or theme()->variant('callout') (a block view through $look->variant()), switches on the value
with whole class strings per value, writes data-layout or data-variant on its root, draws
any value it does not know as the default, and is declared with ContractView::honouring() so
the catalogue and the export draw every value. A child theme that adds a value must draw it in
views of its own; until it does, its parent’s views draw the default.
Making one
Section titled “Making one”php artisan hilms:make-theme acme --parent=hilmsThat scaffolds a child theme with a tokens.json that declares nothing yet, so it inherits
every token of its parent, and the public/tokens.css generated from it. Give a token the
path it has in the parent’s tokens.json and a value of its own, then generate the sheet
again; the rest keep the parent’s values. Such a theme restyles through its tokens and
public/theme.css, the rules the tokens cannot say, and needs no build, because every
utility its parent’s build emits already reads the variables. Markup it adds, though, can
only use the utilities its parent’s build already emits: a class nobody in the parent wrote
generates nothing. --standalone gives it a build of its own, which compiles whatever its
views use.
A theme with no parent starts from a copy of the default theme’s tokens.json, so it draws
a whole page from the first render and changes whatever it wants from there. It must hold
every foundation and component token the contract names; hilms:theme:check lists any it
lacks. It is also given the mail theme, views/vendor/mail/html/themes/acme.blade.php, so
its mail follows the active style (see Mail below).
To start it in your own colours, give it a few seeds:
php artisan hilms:make-theme acme --parent=hilms --brand="#0b5fff" --neutral=cool --fonts=serif --shape=round --density=comfortableEach is optional: --brand takes a #hex value, rgb() or oklch(); --neutral is
plain, warm, cool or brand; --fonts names the text face and, after a comma, the
heading face — a family the theme builds, or sans, serif, rounded or mono; --shape
is sharp, soft or round; --density is compact, comfortable or airy. Run without
a name, the command asks for each. What they generate — colours meeting every contrast pair
in both schemes, faces, corners and room — goes into the theme’s built-in style,
styles/acme.json, and never into its tokens.json; php artisan hilms:theme:styles makes
it a style an administrator can activate.
With a build of its own:
php artisan hilms:make-theme acme --parent=hilms --standalonecd themes/acme && npm install && npm run buildA standalone theme writes into themes/acme/public/build and is served through
public/themes/acme, which hilms:install links.
A theme in the repository may also set "build": "bundled": the root vite.config.js reads
every bundled theme.json and compiles its entries and fonts into public/build beside the
core script. That is what themes/hilms does.
The contract
Section titled “The contract”The core renders a fixed set of views with a fixed set of variables. Both are written down:
php artisan hilms:theme:list every theme this installation can render fromphp artisan hilms:theme:check acme what the theme answers for and what it is missingphp artisan hilms:theme:check acme --jsonA theme without a parent must answer for every view; a child only overrides what it wants. The error pages and the notification mail are the exception: the framework has its own, so a theme that offers neither still renders.
Some of those views are page templates: a page in the panel picks the one it is drawn
with, and each is an ordinary contract view receiving $page and $preview (pages::show
is the plain page every installation has; a module or a theme may register more). A theme
overrides one like any other view,
and the menu one place draws is <x-navigation::menu location="header|footer" variant="bar|list|footer">, whose own view is navigation::components.menu.
With HILMS_DESIGN_CATALOGUE=true outside production, /design draws the whole contract
with sample data, light and dark, together with the token sheet and every block.
php artisan hilms:design:export writes the same thing as one self-contained HTML file.
Styling
Section titled “Styling”A theme’s tokens live in tokens.json, a file in the W3C Design Tokens Format (2025.10), so
Figma through Tokens Studio, Penpot or Style Dictionary can read and write it too:
{ "color": { "$type": "color", "accent": { "$value": {"colorSpace": "oklch", "components": [0.45, 0.15, 248]}, "$extensions": {"hilms": {"dark": {"colorSpace": "oklch", "components": [0.77, 0.115, 248]}}} } }, "radius": {"$type": "dimension", "box": {"$value": {"value": 0.75, "unit": "rem"}}}, "font": {"$type": "fontFamily", "display": {"$value": "{font.sans}"}}}A token becomes the custom property named by its path (color.accent is --color-accent,
a group’s $root token takes the group’s own name), a colour carries its dark value under
$extensions.hilms.dark, min and max there bound a value, steps counts a length in steps
of the spacing unit so it follows the density, tier says whether a group holds foundations or
component tokens — one part’s decision, such as --button-radius, following a foundation until a
style changes it — and {font.sans} is an alias that stays var(--font-sans) all the way to the
browser. A token in one of Tailwind’s
namespaces must hold the type Tailwind reads it as: a --color-* is a colour, a
--radius-* a length.
php artisan hilms:theme:tokens acme generate the theme's token sheet from tokens.jsonphp artisan hilms:theme:tokens --check fail when any theme's sheet is out of dateThe sheet is generated and committed, never edited by hand. A theme with a build gets
css/tokens.css with its tokens in a @theme static block, so every Tailwind utility emits
var(--…) — static is what emits a token no utility happens to use, so whatever is laid
over the theme later still has all of them. The dark set is written once under
[data-theme="dark"] and once under @media (prefers-color-scheme: dark) [data-theme="system"].
A theme without a build gets public/tokens.css with :root instead, which its manifest
serves first. Beside either, tokens.preview.css holds the same values on a block preview’s
wrapper alone: it is all of the theme’s tokens the admin panel ever receives, so Filament’s own
--spacing and --text-* are never replaced. A theme’s preview.css imports tokens.css as a
reference and tokens.preview.css as itself, as the scaffold writes it. hilms:theme:check and the health check both report a token file that cannot
be read or a sheet that no longer says what its file says.
The server writes data-theme on <html> from the hilms_theme_mode cookie, so the page
never flashes; resources/js/theme-mode.js flips it. The admin panel writes no data-theme
of its own, so a block preview sets one on its own wrapper from the panel’s dark class and
follows whichever scheme the panel is in.
Never reach past a token: the default theme is checked by a test that fails on any raw
palette utility (bg-slate-200, text-indigo-600) or dark: variant — and on any view
that formats a date for itself instead of going through the theme’s own date atom, because
a stored datetime is UTC and UTC is nobody’s wall clock (languages.md).
Styles
Section titled “Styles”What an administrator changes lives beside the theme, not in it. A style is a named set of
token values laid over the active theme, made in the panel under Settings → Site → Styles: every
token of tokens.json is a field there, grouped as the file groups them on the Foundations and
Components tabs, and an empty field is the theme’s own value. On the Layout tab the same style
picks the theme’s options and variants. An administrator can also start one from five answers
(“New style from scratch”) or fill one section from a few seeds (“Generate…”); generated colours
meet every contrast pair by construction. A style is compiled once, when it is saved, into a stylesheet served at
/theme/styles/<hash>.css and linked after the theme’s own CSS; its selectors outweigh the
theme’s whatever order the sheets load in, and a person’s prefers-contrast and
prefers-reduced-motion still outweigh the style. With no style active the site is exactly the
theme.
A theme ships styles of its own as files in the same token format, styles/<slug>.json, named
under $extensions.hilms.name:
{ "$description": "Warm and round.", "$extensions": {"hilms": {"name": "Autumn"}}, "color": {"$type": "color", "accent": {"$value": {"colorSpace": "oklch", "components": [0.55, 0.16, 45]}}}, "radius": {"$type": "dimension", "box": {"$value": {"value": 1.25, "unit": "rem"}}}}hilms:install and hilms:upgrade sync them into read-only styles an administrator can
activate or duplicate, activate a theme’s first built-in while the theme has no active style (the first that meets
it, where accessibility is enforced),
and compile every style again, because a deploy may change the tokens under them. The default
theme ships hilms-wcag.json, “HiLMS WCAG”, which changes nothing.
php artisan hilms:theme:styles sync the built-in style files and compile every style againphp artisan hilms:theme:styles --check fail on a file that cannot be used, or a value a style can no longer useA style goes to a designer and comes back as a token file: Export writes the theme’s whole token
set as the style resolves it, with every option and variant it draws, and Import (the JSON pasted
into the panel) keeps what differs from the theme and ignores tokens and picks the theme does not
have. A built-in style file may pick options and variants too, under $extensions.hilms.options
and .variants.
Contrast and the accessibility floors are shown in the editor as advice and never stop a save. An operator who must promise a client WCAG turns enforcement on, and then a style that falls short cannot be activated, nor the active style saved into falling short:
HILMS_STYLES_ENFORCE_ACCESSIBILITY=trueIt is an environment setting on purpose: a switch in the panel could be turned off by the same administrator it is meant to hold to the promise.
After changing anything that decides which declaration wins — a token sheet, a11y.css, the
style compiler — prove the precedence in a real browser, on the host:
php artisan hilms:design:cascade a fixed style drawn three ways, with what each property must compute tonpm run cascade every colour scheme, load order and preference, in ChromiumA theme may build fonts into its own CSS (fonts in theme.json, self-hosted at build time). An
installation can also keep fonts of its own, which any style may set text in, on Settings →
Site → Fonts:
- Add from the catalogue searches Bunny Fonts, an EU-hosted mirror
of Google’s open-licensed families. HiLMS downloads the chosen weights, styles and character
sets once, from the server, and serves its own copy: no visitor ever reaches the catalogue.
The server must be able to open
https://fonts.bunny.netwhile a font is added; it asks that host and no other, follows no redirect away from it, and keeps only real woff2 files. - Upload your own takes one woff2 file per weight and style. Upload only a font whose licence allows self-hosting it on the web.
The files are public media, on whatever disk public media uses. When that is a bucket, the
browser fetches each face from the bucket’s address in CORS mode, so the bucket needs the CORS
rule storage.md describes (the same one a video needs); without it the text falls back to the
system face behind the font. The content security policy already names the public bucket’s
address in font-src. php artisan hilms:media:relocate moves fonts with the rest of public
media, and every style using a moved font is compiled again on its own.
A font a style uses cannot be deleted; the panel names the styles to change first.
Mail is drawn in the active style’s colours: views/vendor/mail/html/themes/<name>.blade.php
prints a palette HiLMS computes from the style’s tokens (the page, the card, headings, text,
lines and the buttons), written as #rrggbb because a mail client reads no CSS variable. A
style being previewed never reaches mail, and when the database cannot answer, mail is drawn
in the theme’s own colours. A child theme inherits its parent’s mail theme; a theme with no
parent is scaffolded with one. A theme that ships views/vendor/mail/html/themes/<name>.css
instead keeps a fixed mail theme of its own.
Checking a theme
Section titled “Checking a theme”A theme answers for its accessibility the way it answers for its views. Three commands say whether it does:
php artisan hilms:theme:a11y acme the colour pairs and the markup rulesphp artisan hilms:design:export --split every view and mode as its own documentnpm run a11y axe-core over those documentshilms:design:export draws in the theme’s own values, whatever style the installation is in;
--style=<slug> (or --style=active) draws every document in that style instead, which is how a
style made in the panel is checked with npm run a11y.
hilms:theme:a11y audits every pair in Hilms\Theme\Contract\ContrastPairs over the
chain’s tokens, in the light scheme and the dark one, and reads the theme’s own Blade
for an image with no alt, a frame with no title, a removed focus outline, a positive
tabindex, a disclosure button that forgets aria-expanded or aria-controls, and a link or
a button with nothing to say. It exits 1 on any finding.
npm run a11y runs on your own machine, not in CI: it needs Node and a Chromium, and it
only moves when the theme does. The workflow is written out in .github/workflows/ci.yml
and commented out, for whoever wants it on every push.
The rules themselves — the eighteen a theme is held to, checked and documented alike — are
Hilms\Theme\Contract\Accessibility, and the baseline every theme inherits is
themes/hilms/css/a11y.css. docs/dev-book/17-accessibility.md is the long form.
Running a client theme in production
Section titled “Running a client theme in production”Keep the theme outside the image and bind it as a sub-directory, so the themes the image carries stay visible:
services: app: volumes: - './themes/acme:/var/www/html/themes/acme:ro'Then set HILMS_THEME=acme in .env and run docker compose up -d. The recreated app
container runs hilms:deploy as it starts, which finds the installation and upgrades it:
that links the theme and warms the caches. To do the same without recreating anything:
docker compose exec -u www-data app php artisan hilms:upgrade --no-interactionhilms:install and hilms:upgrade both link every theme that publishes a public/
directory, and the health check refuses to pass when the active chain does not resolve, when
a standalone theme has no build, or when a theme is not linked.
A production container caches compiled views. After editing a mounted theme, run:
docker compose exec -u www-data app php artisan view:clearHalf-way houses
Section titled “Half-way houses”- A theme may override a single view: create the file at the path
hilms:theme:checknames and leave everything else to the parent. - A theme may override a package’s views through
views/vendor/<namespace>, which is howthemes/hilmsownsvendor/mail/html/themes/hilms.blade.php, the mail theme that draws mail in the active style’s colours. views/design/kit.blade.phpis optional: when it exists,/design/kitrenders it, which is the quickest way to see every atom of a theme in one page.views/components/ui/person.blade.phpis the card that introduces the people who teach a course: the avatar, the name, and whatever profile fields the installation defined for their roles. A theme that wants a different shape for a teacher overrides that one file.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.