Skip to content

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.

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/acme

theme.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.

php artisan hilms:make-theme acme --parent=hilms

That 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=comfortable

Each 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 --standalone
cd themes/acme && npm install && npm run build

A 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 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 from
php artisan hilms:theme:check acme what the theme answers for and what it is missing
php artisan hilms:theme:check acme --json

A 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.

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.json
php artisan hilms:theme:tokens --check fail when any theme's sheet is out of date

The 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).

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 again
php artisan hilms:theme:styles --check fail on a file that cannot be used, or a value a style can no longer use

A 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=true

It 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 to
npm run cascade every colour scheme, load order and preference, in Chromium

A 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.net while 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.

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 rules
php artisan hilms:design:export --split every view and mode as its own document
npm run a11y axe-core over those documents

hilms: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.

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-interaction

hilms: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:clear
  • A theme may override a single view: create the file at the path hilms:theme:check names and leave everything else to the parent.
  • A theme may override a package’s views through views/vendor/<namespace>, which is how themes/hilms owns vendor/mail/html/themes/hilms.blade.php, the mail theme that draws mail in the active style’s colours.
  • views/design/kit.blade.php is optional: when it exists, /design/kit renders it, which is the quickest way to see every atom of a theme in one page.
  • views/components/ui/person.blade.php is 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.