Theming
HiLMS is a headless core. The modules under app-modules/ ship data, logic, routes, block classes, Livewire classes, class components and translations; every Blade file a student sees lives in a theme under themes/<name>. The default look is just the first theme, themes/hilms, and it holds no privilege the next one does not. HILMS_THEME names the active theme and defaults to hilms.
This chapter walks through the situations you will actually be in. The reference for the mechanics (manifest keys, the contract, tokens, the commands) is docs/install/theming.md, and the terse specification is SPECS.md §20.
Where the pieces are
Section titled “Where the pieces are”| Piece | Where |
|---|---|
| The theme kernel | app-modules/theme: manifests, resolution, per-theme assets, the view contract, Branding, styles, the generators, the design catalogue |
| The default theme | themes/hilms: theme.json, tokens.json, css/tokens.css (generated from it), css/{prose,a11y,shell,player,app,preview}.css, views/…, lang/{pl,en}.json |
| Configuration | config/theme.php: HILMS_THEME, the theme paths, HILMS_DESIGN_CATALOGUE, HILMS_STYLES_ENFORCE_ACCESSIBILITY |
| The contract declarations | src/Theme/ContractViews.php in each module; the error pages, the mail and the access views in app-modules/theme/src/Contract |
| The page templates | Hilms\Theme\Templates\PageTemplates, the registry a module registers its own page views with (Pages) |
| The tokens | themes/<name>/tokens.json, read by Hilms\Theme\Tokens and generated into css/tokens.css and css/tokens.preview.css; the names every root theme answers for are Hilms\Theme\Contract\FoundationTokens and ComponentTokens (Design tokens, Components, looks, options and generators) |
| Options and variants | options and variants in theme.json, merged along the chain by Hilms\Theme\Options\ThemeOptions, read through theme()->option() and theme()->variant() (chapter 26) |
| Styles | What an administrator lays over the theme’s tokens and options — the Style model, the compiled sheet head() links, the Styles section in Settings, built-in styles in themes/<name>/styles/*.json, the generators (Styles, chapter 26) |
| Fonts | Hilms\Theme\Models\Font, kept on Settings → Site → Fonts from the Bunny Fonts catalogue or uploaded, and drawn by a style’s sheet (Styles) |
themes/hilms/views/vendor/mail/html/themes/hilms.blade.php, Laravel’s mail theme in the active style’s colours (Hilms\Theme\Styles\MailPalette) |
|
| The shell | components/layouts/app.blade.php, components/site-footer.blade.php, themes/hilms/css/shell.css, resources/js/rail.js, Hilms\Theme\Support\RailCookie and ActiveTheme::railOpen(); the verdict on it is Hilms\Theme\Contract\Shell |
| The uploaded video’s player | themes/hilms/css/player.css and resources/js/media.js: the watermark and the full-screen button |
| Dates on a page | <x-ui.date> over Hilms\Theme\Support\ViewerDate (Time and clocks) |
| Branding | Hilms\Theme\Models\Branding, edited under Settings → Branding (site name, tagline, footer text, logo, favicon; the two lines get one input per installation language) |
| Commands | hilms:make-theme, hilms:theme:list, hilms:theme:check, hilms:theme:tokens, hilms:theme:styles, hilms:theme:a11y, hilms:design:export [--style=], hilms:design:cascade, hilms:make-block --theme |
| Tests that guard it | app-modules/theme/tests/Feature: the contract, the shell (ShellContractTest, LayoutTest, RailTest), the tokens (TokenModelTest, TokenDocumentTest, ThemeTokensTest, ThemeTokensCommandTest, TokenSheetTest), styles (StyleModelTest, StyleCompilerTest, ActiveStyleTest, BuiltInStylesTest, StyleResourceTest and their siblings), fonts (FontCatalogueTest, DownloadFontTest, UploadFontFaceTest, FontResourceTest, StyleFontsTest), the mail theme (MailThemeTest), the style guard, the preview stylesheet, the catalogue, the generators, branding |
| Accessibility | Hilms\Theme\Contract\Accessibility and ContrastPairs, the baseline themes/hilms/css/a11y.css, the command hilms:theme:a11y; chapter 17 explains them |
The shell
Section titled “The shell”Every page a visitor reads is drawn by <x-layouts.app>, and every one of them is drawn in one shell. It is worth knowing its shape before changing anything inside it.
- The document box is declared once, in
<x-layouts.base>:<body>is a flex column at least the window tall, indvhthroughout, and no layout below it restates a height of its own. - The bar spans the whole viewport, sticks to the top and is exactly
--header-heighttall. The measured, bordered box is the<header>itself, because the rail sticks under the bar and sizes itself against the same token, and both are out by the difference the moment the bar is taller than it says. - The shell is one
[data-shell]grid below the bar, taking whatever the bar and the impersonation banner leave it (flex: 1 0 autoin that flex column) and measuring neither. Its content column has two rows: the arena, then the footer. A short page still pushes the footer to the bottom of the window, and no page scrolls by the footer’s height for having claimed a whole viewport it did not need. - The arena is
<main id="main" tabindex="-1">with the page inside an<x-ui.container>of the width the page asked for.<x-layouts.app>takes awidthprop (page,prose,wide,narrow,full); the lesson player asks forprose, because a lesson is reading material.<main>carriesmin-w-0, because a grid item ismin-width: autoand a code block would otherwise push the page wider than the window. - The footer is
<x-site-footer>— the footer menu and the branding line — and it is the shell’s last row, never a sibling of it: a footer drawn outside the grid runs under the whole of it, so the rail’s surface stops short and the pinned panel lifts off as the last of the page scrolls by. It is drawn at the arena’s width with the hairline on its container, so the rule is as wide as the measure above it — prose-wide on a lesson. It stays inside a plain<div>and never inside<main>or another sectioning element, where a<footer>is that section’s foot and no landmark at all. - The rail is the optional
railslot:<x-ui.rail>, a named<aside>holding one<x-ui.rail-group>per widget, with no card and no rounding — the language of the rail is a hairline and the space around it. The slot names itself through alabelattribute (“About this page” by default) and may name aniconfor the toggle. It sits in a[data-rail-column]that spans both rows, so its surface and its hairline run the whole height of the shell and the panel stays pinned beside the footer.
data-rail is what asks for the second column, not the element. A page that fills the rail slot carries the attribute on [data-shell], and only then is the grid two columns; a page without one is never drawn beside an empty column. pages::show fills no rail; the course page and the player are the two that do.
<x-layouts.app :title="$course->title"> …the page…
<x-slot:rail :label="__('catalog::catalog.course.panel')" icon="o-list-bullet"> <x-ui.rail-group>…</x-ui.rail-group> </x-slot:rail></x-layouts.app>Four rules hold the rail together, and themes/hilms/css/shell.css says all four out loud:
- The rail comes after
<main>in the document and first on screen. The skip link stays honest, the page’sh1comes before the rail’s headings, and nobody’s tab order changes. Which column each one takes isgrid-row/grid-column— every piece names its row, because grid auto-placement never goes backwards. - Above 64 rem the rail is a column
min(var(--sidebar-width), 30vw)wide:--sidebar-width(23 rem) is its most, and a narrow desktop gives it less.[data-shell][data-rail]puts that sum in--rail-inline-size, which the grid column and the panel both read, so they never disagree. The panel sticks under the bar, iscalc(100dvh - var(--header-height))tall and scrolls by itself with a thin scrollbar and no gutter kept for it, so a rail whose content fits shows no strip of padding either. The toggle in the bar collapses the column and the arena takes its room back; a closed panel isvisibility: hidden, out of the tab order and out of the accessibility tree rather than merely out of sight. Around the sticky panel the column isoverflow: clipand neverhidden, which would make a scroll container for the panel to stick to. - Below 64 rem it is a drawer
min(var(--sidebar-width), 86vw)wide over the dimmed page, with a close row of its own, everything elseinert— no focus trap of ours — and Escape, the close button or a press on the dim shuts it. The dim is the shell’s own::before, so nothing has to remember to leave an element out of the inert sweep. - The switch is a media query and never a container query.
container-typemakes an element the containing block of every fixed descendant, so a drawer inside one would be positioned against the shell and scroll away with the page. The container query belongs on the rail itself (@container/rail), where its widgets read it, and a media query is also what letsresources/js/rail.jsaskmatchMediathe same question in the same words.
The panel’s height is the one sum left: while an impersonation banner is on screen the panel is the banner’s height too tall, and it corrects itself the moment the banner scrolls away. Measuring the chrome in JavaScript would cost more than the symptom.
The two states are two different things. data-rail on [data-shell] is what the server wrote from the hilms_rail cookie — a year, SameSite=Lax, excluded from cookie encryption because the browser is what writes it, read by Hilms\Theme\Support\RailCookie and answered by theme()->railOpen(), which opens the column for a visitor who never chose — and only the wide rules read it. data-drawer is rail.js’s own and only the narrow rules read it, so a remembered “open” never flashes a drawer open on a phone. Every positioning rule is scoped to [data-shell], so a rail drawn anywhere else (the design kit’s sample) is an ordinary block that scrolls with the page.
The guest layout is bare on purpose: a centred card inside its own <main id="main">, no shell and no footer behind a form somebody is part-way through. The error layout has the same skeleton and the same units and no footer at all, because the branding line one would carry reads the database and a 500 during an outage still has to draw. ErrorPagesTest holds that three ways: warm, no page runs a query; cold, with the database out of reach and nothing remembered, a page asks the table once for the corrected phrases, is refused and draws from the files — a run with the reader made to rethrow proved the test really goes to the database; and every one of the seven pages is an accessible document in both languages. The 429 page reads the wait from the Retry-After the limiter sent and says it in the reader’s plural (“Spróbuj ponownie za 58 sekund”), and “Wait a moment and try again” when there is none.
What goes in the rails
Section titled “What goes in the rails”The course page’s rail, “About this course”, and the player’s, “Course navigation”, both open with <x-catalog::course-head> — the course title, the people who teach it, the lesson count and the minutes, then the enrol button or the student’s progress — and go on with the programme, <x-catalog::curriculum>, which folds by section on native <details> and needs no script. Catalog describes both, including the marks at the end of each row; <x-ui.free-mark> is the theme’s atom for a free lesson, a struck-out dollar with its word for a screen reader, because Heroicons has none.
The shell contract
Section titled “The shell contract”Nothing used to look at a Blade slot, so a layout that quietly stopped drawing the rail, or hung its footer outside the shell, rendered perfectly well and passed every check. Hilms\Theme\Contract\Shell is the shape of a page read off the rendered document, the way Tokens, ViewContract and Accessibility are read off their own material:
Shell::problems(string $html, bool $withRail): arrayIt asks for one main#main carrying tabindex="-1", a skip link reaching it, exactly one page footer inside [data-shell] and, when the layout was handed a rail, one named [data-rail-panel] <aside> outside the content with a [data-rail-toggle] whose aria-controls names it. hilms:theme:check reports it as a Shell line (a shell key under --json), the ops ThemeCheck makes the same call for the active theme, and both render through the contract view’s own sampling closure, so what they judge is the page the design catalogue draws. ShellContractTest holds the default theme to it.
A Blade component whose view is missing does not throw — it renders its own name as text — which is why a theme with nothing but a manifest reports a shell that is not a page rather than a crash. And Illuminate\View\Component remembers per process which view a component name resolved to, so the check flushes that cache after rendering under another theme.
The reading width
Section titled “The reading width”--container-prose (48 rem) is the one token no utility of its own name reads. Tailwind keeps prose in the max-w-* namespace for a 65ch of its own, which is font-relative and ignores the token, so the reading width is always asked for by variable: max-w-(--container-prose), in <x-ui.container>, the hero, a centred page header, a normal-width image and the error page. ThemeStyleGuardTest fails a theme view, a theme stylesheet or a generator stub that writes the bare max-w-prose. Because nothing in the chain is font-relative any more, the footer’s smaller type cannot shrink it, and a lesson’s footer lines up with the text above it. The one measure no token controls is the course page’s description, which keeps the typography plugin’s own prose measure inside a page-wide arena.
The uploaded video’s player
Section titled “The uploaded video’s player”When the installation asks for it (“Watermark uploaded videos” on the “Media and storage” page) and somebody is signed in, the uploaded video is drawn inside a [data-video-frame] carrying the viewer’s e-mail address in an aria-hidden, pointer-less [data-watermark] layer that drifts corner to corner over 48 seconds and stays still in the top corner under prefers-reduced-motion. themes/hilms/css/player.css builds it from the tokens: the letters in --color-surface-raised and their halo in --color-ink, both translucent, so they read over a light frame and a dark one.
The mark has to survive full screen, so the frame’s own button[data-fullscreen-toggle] takes the frame — video and mark together — full screen, while the video’s own control is taken away (controlsList="nodownload nofullscreen"). The button keeps one name, “Full screen”, and says its state through aria-pressed. resources/js/media.js drives it, hides it where document.fullscreenEnabled is false (an iPhone), brings back a marked video that went full screen by itself and asks for its frame instead, and takes the context menu away from a marked player only — an unmarked one keeps it, because it carries Firefox’s playback speed. A theme that draws its own video block keeps these hooks or answers for them itself; Security and privacy says what the mark does and does not achieve.
Scenario 1: working on HiLMS itself
Section titled “Scenario 1: working on HiLMS itself”You change a student page, a block view or an atom. All of that is in themes/hilms/views; the core PHP is untouched unless the view needs a variable it does not get, in which case the contract changes (see scenario 5).
lando devserves the theme with hot reload;npm run buildon the host compilesthemes/hilms/css/app.css,preview.cssand the core script intopublic/build, becausethemes/hilmsis a bundled theme and the rootvite.config.jsreads every bundledtheme.json.- Colours only through the token names (
bg-surface,text-ink,bg-accent). A raw palette utility or adark:variant failsThemeStyleGuardTest. - A value changes in
themes/hilms/tokens.json, never incss/tokens.css: runbin/artisan hilms:theme:tokensand build. A change that must not move a pixel is proven withnpm run visualagainst a baseline taken before it (Design tokens). - New atom, new part: put it under
themes/hilms/views/components/uior beside the views that use it. An atom that a block view calls must be scanned bypreview.cssas well, which is why that file listscomponents/ui; the panel loadspreview.cssfor block previews andPreviewStylesheetTestchecks the built bundle carries the atoms’ utilities. That build imports the tokens as a reference and declares their values on the preview’s wrapper alone (tokens.preview.css), so nothing of the theme lands on the panel’s:root, where Filament keeps its own--spacingand--text-*(Design tokens). - A new block:
bin/artisan hilms:make-block Poll --module=xwrites the class and the test into the module and the view intothemes/hilms/views/<module>/blocks/poll.blade.php. - See the whole thing: with
HILMS_DESIGN_CATALOGUE=truein.env,/designrenders every contract view with sample data, light and dark, every token with its value in each scheme, and every block — in the active style, because its frames drawhead().bin/artisan hilms:design:exportwrites the same as one file understorage/app/design/, in the theme’s own values whatever style the installation is in: the export inlines the theme’s CSS and strips every external reference.--style=<slug|active>draws it in a style instead — its sheet after the theme’s, its options and variants as the picks — which is how a style made in the panel is checked withnpm run a11y. - Anything that decides which declaration wins — a token sheet’s selectors,
a11y.css, the style compiler — is proven withhilms:design:cascadeandnpm run cascadeon the host (Styles).
Scenario 2: a client theme for a Docker installation
Section titled “Scenario 2: a client theme for a Docker installation”The image carries themes/hilms. A client theme is a directory on the server, bound into the container as a sub-directory of themes/, so the bundled themes stay visible.
-
On the server, beside
compose.yamland.env, createthemes/acmeand put the theme there (copy it withscporrsync, or clone its repository). There is no upload in the panel: the directory is the interface, and it is mounted read-only because nothing in a theme is written at runtime. -
In
compose.override.yaml(copycompose.override.example.yaml), bind it:services:app:volumes:- './themes/acme:/var/www/html/themes/acme:ro'The bind is per theme; do not mount the whole
themes/directory, or the image’s own themes disappear. -
Set
HILMS_THEME=acmein.env, thendocker compose up -d(a changed.envneedsup -d, notrestart). -
Run the installer once:
docker compose exec -u www-data app php artisan hilms:install --no-interaction. It linkspublic/themes/acmeto the theme’spublic/directory and rebuilds the caches. The container does this on every boot anyway whenHILMS_AUTO_INSTALL=true. -
Check:
php artisan hilms:theme:check acme(nothing missing, or the list of what the parent covers) andphp artisan health:check(the theme check must be Ok).
After editing the mounted theme, run php artisan view:clear in the container: a production container caches compiled views. A theme with its own build ("build": "standalone") is built on the developer’s machine (npm install && npm run build inside the theme) and its public/build travels with the directory; the server never runs Node.
Scenario 3: a client theme for a manual installation
Section titled “Scenario 3: a client theme for a manual installation”Same theme, same commands, no container. Put the theme under themes/<name> in the application root, set HILMS_THEME in .env, run php artisan hilms:install --no-interaction as the web user. The web server serves public/themes/<name> through the symlink the installer creates, so nothing in the nginx or Apache configuration changes. view:clear after edits, as above.
Scenario 4: a second theme in the repository
Section titled “Scenario 4: a second theme in the repository”A theme HiLMS ships lives in themes/<name> with "build": "bundled". The root Vite build compiles it beside hilms, the image carries it, and an installation chooses it with HILMS_THEME=<name> and nothing else. Its views are checked by the same contract test as the default theme, and it is bound by the same style guard once it is added to that test’s paths.
Scenario 5: the contract changes
Section titled “Scenario 5: the contract changes”The contract is the list of views the core renders and the variables it passes. It changes when:
- a controller, a Livewire component, a class component or a block passes a new variable or renders a new view;
- a block is added (every block view is a contract view, declared automatically from the registry).
Declare the change in the module’s src/Theme/ContractViews.php (the name, the variables with a one-line meaning, a sample for the catalogue). ViewContractTest fails until it is declared and until themes/hilms has the file. Then tell every theme owner: hilms:theme:check <name> on their installation lists what their theme is now missing. A child theme inherits the new view from its parent, so most client themes need nothing.
Scenario 6: upgrading an installation that runs a theme
Section titled “Scenario 6: upgrading an installation that runs a theme”hilms:upgrade relinks every theme that publishes a public/ directory, syncs every theme’s built-in styles and compiles every style again against the theme as it now is, rebuilds the caches and runs the health checks, so a theme that stopped resolving fails the upgrade visibly. Before upgrading a client installation, read the release notes for contract changes and run hilms:theme:check <name> afterwards. Details in docs/install/upgrade.md, section “A mounted theme”.
What a theme may and may not do
Section titled “What a theme may and may not do”- May: replace any view of the contract, add its own atoms, parts and layouts, change every token and add tokens of its own in its
tokens.json, keep a token from administrators with"editable": false, ship built-in styles instyles/*.json(Styles), declare options and variants a style picks and draw each value (chapter 26), ship its own fonts (self-hosted at build) or leave fonts to the installation’s Fonts page, override package views throughviews/vendor/<namespace>, add strings inlang/{pl,en}.json, and register a page template of its own onPageTemplatesfrom its service provider. - Must: draw the language switch and the
hreflanglinks where a visitor can reach them, if the installation speaks more than one language (see Languages); the default theme puts<x-ui.language-switch>in the header and inside the phone menu, and<x-layouts.base>writes the links. Draw the two menu places,headerandfooter, with<x-navigation::menu>(Navigation) — a theme that draws neither leaves a visitor nothing to navigate by. Accept the layout’srailslot andwidthprop, because a course page and a lesson player both fill them. Format every date through one atom of its own overViewerDate, never in a view: a column holds UTC, which is nobody’s wall clock, andThemeStyleGuardTestfails a view that calls Carbon for itself. Writedata-themeon<html>in every document it draws — the colour scheme reads it, and a style’s sheet applies to nothing without it. - Should: let a block that must adapt answer to the room it has rather than to the window. The default theme’s grid and its cells are
@containers, so a listing inside a narrow cell reads as a narrow listing (Blocks). - Must, for a theme with no parent: answer for the shell itself.
hilms:make-themewithout--parentscaffolds it — the layouts with the document box, the skip link, the one<main id="main">, a bar that stays in view, the rail slot and its toggle,[data-shell],[data-rail-column], the footer as the last row, the arena and the footer both at the width the page asked for — pluserrors/layout,ui/live,ui/rail,ui/rail-groupand ashell.cssbuilt from tokens alone. Itsa11y.csskeeps the contrast and motion preferences!important, so no style outranks them (Styles); the scaffold also writes the impersonation banner and the style-preview banner, in plain markup on the tokens because such a theme has nox-uiatoms, and its app layout draws both above the bar. It also writes the mail theme,views/vendor/mail/html/themes/<name>.blade.php, which draws mail in the active style’s colours; without it the theme’s mail would fall back to the framework’s own and never follow a style. Such a theme answers for every token too — the foundations and the component tokens alike, andhilms:theme:checknames any it lacks — so it starts from a copy of the default theme’stokens.json: with no--sidebar-widththe column beside the page would not exist.hilms:make-theme --brand= --neutral= --fonts= --shape= --density=starts it in a client’s colours, written into its built-in style rather than itstokens.json(chapter 26). A child theme inherits all of it from its parent, and a second copy would be the drift the generator is there to stop. - Must, for a child: state what it changes. A colour whose dark value the parent changes needs a dark value of the child’s own, and a token keeps the kind its parent gave it. A child without a build of its own cannot use a utility its parent’s build never emitted, and has no cascade or visual proof of its own; chapter 26 says when
--standaloneis worth it. - May not: rename a contract view or ask for a variable the core does not pass (that is a core change), load anything from a third-party origin (the content security policy blocks it), use inline scripts or styles on the front-end, or reach past the tokens for colours.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.