Skip to content

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.

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)
Mail 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

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, in dvh throughout, 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-height tall. 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 auto in 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 a width prop (page, prose, wide, narrow, full); the lesson player asks for prose, because a lesson is reading material. <main> carries min-w-0, because a grid item is min-width: auto and 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 rail slot: <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 a label attribute (“About this page” by default) and may name an icon for 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:

  1. The rail comes after <main> in the document and first on screen. The skip link stays honest, the page’s h1 comes before the rail’s headings, and nobody’s tab order changes. Which column each one takes is grid-row/grid-column — every piece names its row, because grid auto-placement never goes backwards.
  2. 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, is calc(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 is visibility: hidden, out of the tab order and out of the accessibility tree rather than merely out of sight. Around the sticky panel the column is overflow: clip and never hidden, which would make a scroll container for the panel to stick to.
  3. 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 else inert — 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.
  4. The switch is a media query and never a container query. container-type makes 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 lets resources/js/rail.js ask matchMedia the 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.

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.

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): array

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

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

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.

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 dev serves the theme with hot reload; npm run build on the host compiles themes/hilms/css/app.css, preview.css and the core script into public/build, because themes/hilms is a bundled theme and the root vite.config.js reads every bundled theme.json.
  • Colours only through the token names (bg-surface, text-ink, bg-accent). A raw palette utility or a dark: variant fails ThemeStyleGuardTest.
  • A value changes in themes/hilms/tokens.json, never in css/tokens.css: run bin/artisan hilms:theme:tokens and build. A change that must not move a pixel is proven with npm run visual against a baseline taken before it (Design tokens).
  • New atom, new part: put it under themes/hilms/views/components/ui or beside the views that use it. An atom that a block view calls must be scanned by preview.css as well, which is why that file lists components/ui; the panel loads preview.css for block previews and PreviewStylesheetTest checks 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 --spacing and --text-* (Design tokens).
  • A new block: bin/artisan hilms:make-block Poll --module=x writes the class and the test into the module and the view into themes/hilms/views/<module>/blocks/poll.blade.php.
  • See the whole thing: with HILMS_DESIGN_CATALOGUE=true in .env, /design renders 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 draw head(). bin/artisan hilms:design:export writes the same as one file under storage/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 with npm run a11y.
  • Anything that decides which declaration wins — a token sheet’s selectors, a11y.css, the style compiler — is proven with hilms:design:cascade and npm run cascade on 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.

  1. On the server, beside compose.yaml and .env, create themes/acme and put the theme there (copy it with scp or rsync, 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.

  2. In compose.override.yaml (copy compose.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.

  3. Set HILMS_THEME=acme in .env, then docker compose up -d (a changed .env needs up -d, not restart).

  4. Run the installer once: docker compose exec -u www-data app php artisan hilms:install --no-interaction. It links public/themes/acme to the theme’s public/ directory and rebuilds the caches. The container does this on every boot anyway when HILMS_AUTO_INSTALL=true.

  5. Check: php artisan hilms:theme:check acme (nothing missing, or the list of what the parent covers) and php 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.

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

  • 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 in styles/*.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 through views/vendor/<namespace>, add strings in lang/{pl,en}.json, and register a page template of its own on PageTemplates from its service provider.
  • Must: draw the language switch and the hreflang links 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, header and footer, with <x-navigation::menu> (Navigation) — a theme that draws neither leaves a visitor nothing to navigate by. Accept the layout’s rail slot and width prop, because a course page and a lesson player both fill them. Format every date through one atom of its own over ViewerDate, never in a view: a column holds UTC, which is nobody’s wall clock, and ThemeStyleGuardTest fails a view that calls Carbon for itself. Write data-theme on <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-theme without --parent scaffolds 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 — plus errors/layout, ui/live, ui/rail, ui/rail-group and a shell.css built from tokens alone. Its a11y.css keeps 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 no x-ui atoms, 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, and hilms:theme:check names any it lacks — so it starts from a copy of the default theme’s tokens.json: with no --sidebar-width the 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 its tokens.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 --standalone is 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.