Skip to content

Styles

A style is a named set of token values an administrator lays over the active theme: a warmer accent, square cards, a serif for the text, a wider page. The theme stays the code package — its Blade, its build, its tokens.json — and a style is data in the database, compiled into a stylesheet of its own and linked after the theme’s CSS. Nothing is rebuilt when a style changes, and a warm page costs no query to draw in one (a person previewing a style costs that one style). Beside the tokens a style picks the theme’s options and block variants, sets text in the fonts kept on the Fonts page, and gives mail its colours. This chapter is how that works, what holds it in place, and how to extend it. Design tokens is the ground it stands on, and Components, looks, options and generators covers what is built on top of it; SPECS.md §20, under “Styles”, is the terse reference; the administrator’s side is the user book’s Styles.

Piece Where
The model Hilms\Theme\Models\Style over the styles table; StylePolicy; Style permissions seeded by ThemePermissionSeeder
Options and variants Options\ThemeOptions, Options\Choices, Style::choose(), theme()->option()/variant() (chapter 26)
Generators Styles\Generators\*, Styles\StyleOverrides, Actions\GenerateStyle, Support\ContrastSolver (chapter 26)
Fonts Models\Font, Fonts\* (BunnyCatalogue, FaceProperties, FontFace, FontUsage, ScriptSubsets, FaceObserver), Actions\DownloadFont, Actions\UploadFontFace, Styles\FontChoices, Filament\Resources\Fonts\**
Mail Styles\MailPalette, themes/hilms/views/vendor/mail/html/themes/hilms.blade.php, ActiveTheme::mailStyle()
Tokens in and out Hilms\Theme\Styles\StyleTokens (a style’s document ⇄ tokens ⇄ the form), Tokens\TokenDocument (the DTCG writer)
The compiler Styles\StyleCompiler → Styles\CompiledStyle
What every page reads Styles\ActiveStyle (the cached snapshot), ActiveTheme::style(), head(), previewStylesheets()
The sheet’s address GET /theme/styles/{hash}.css → Http\Controllers\StyleSheetController
Activating and advice Actions\ActivateStyle, Styles\StyleAudit over Support\ContrastAudit and Tokens\Bounds, Exceptions\StyleRefused, config('theme.styles.enforce_accessibility')
Previewing Styles\StylePreview, Http\Controllers\StylePreviewController, themes/hilms/views/components/style-preview-banner.blade.php
Built-in styles themes/<name>/styles/*.json, Actions\SyncBuiltInStyles, Actions\RecompileStyles, hilms:theme:styles, the ops step SyncStyles
In and out as a file Styles\StyleExport, Actions\ImportStyle → Styles\ImportedStyle, Exceptions\StyleImportRefused
The panel Filament\Resources\Styles\**, Filament\Components\{OklchColor,ContrastAdvice}, Filament\Support\TokenLabels, Styles\FontStacks, the module’s resources/{css,js}/panel.*
Proof in a browser hilms:design:cascade and bin/cascade.mjs (npm run cascade)
Tests app-modules/theme/tests/Feature: StyleModelTest, TokenDocumentTest, StyleCompilerTest, DesignCascadeCommandTest, ActiveStyleTest, BuiltInStylesTest, StyleFormValuesTest, StyleResourceTest, StyleComponentsTest, StyleImportExportTest, StylePreviewTest, StyleChoicesTest, StyleGeneratorsTest, StyleFromScratchTest, the font tests (FontCatalogueTest, DownloadFontTest, UploadFontFaceTest, FontResourceTest, StyleFontsTest), MailThemeTest, DesignExportStyleTest

The code lives in the theme module — a style is the theme’s values, and head(), which links it, is the theme’s — apart from the install step and the health check in ops, the banner in themes/hilms, and the two-column rule for the sidebar in the host’s resources/css/panel.css.

A row of styles belongs to one theme (theme), has a name, a slug unique within its theme (spatie/laravel-sluggable, kept on rename), an optional description, and in tokens only the values it changes, as a document in the Design Tokens Format:

{
"color": {
"accent": {
"$type": "color",
"$value": {"colorSpace": "oklch", "components": [0.48, 0.16, 39]},
"$extensions": {"hilms": {"dark": {"colorSpace": "oklch", "components": [0.77, 0.115, 248]}}}
}
},
"radius": {"box": {"$type": "dimension", "$value": {"value": 0, "unit": "px"}}}
}

Overrides only, for two reasons. A token the style leaves alone follows the theme across deploys, so a fix to the theme’s --color-border reaches every style that never touched it. And it is the same format as a theme’s file, an export and an import, so one parser reads all four: StyleTokens::of($style) is TokenFile::parse($style->tokens, "style {$id}"), and StyleTokens::document($tokens) is TokenDocument::write($tokens).

One rule keeps the stored document a valid token file: a colour is stored as a whole pair. A colour changed in light only is saved with the theme’s dark value beside it, so $value is always there and the dark value never has to be guessed later.

options and variants hold the theme’s choices of markup the style picks, only where a pick differs from the theme’s default (chapter 26). builtin marks a style a theme ships as a file (below); active_theme names the theme a style is active in, or nothing. The column is unique — both engines let NULL repeat — and a CHECK (active_theme IS NULL OR active_theme = theme) holds it to the style’s own theme, so “one active style per theme” is a constraint rather than something every writer must remember. css, hash, font_families, preload and mail_palette are what the compiler wrote.

A style is audited (LogsActivity on name, description, tokens, options, variants and the active mark) and in the morph map as style, so its history is the activity log — there is no table of versions.

StyleCompiler is the one place a style becomes CSS, and the model calls it itself: on saving, whenever tokens changed or css is missing, so whatever writes the tokens — the form, an import, the sync, a duplicate — gets a sheet that says what its tokens say. recompile() compiles a style again against the theme as it is now and saves only a sheet that came out different, which is what an upgrade runs.

$compiled = app(StyleCompiler::class)->compile($style); // CompiledStyle
$compiled->css; // the sheet, '' for a style that changes nothing
$compiled->hash; // first 16 hex digits of its sha256, null for an empty sheet
$compiled->fontFamilies; // the theme's built families the style's font tokens name
$compiled->problems; // every override left out, and why
$compiled->preload; // the address of the stored face the body text is set in, or null
$compiled->mail; // the colours of its mail, slot by slot, as #rrggbb

It is lenient on a stored style. An override is kept only where the theme has that token, with the same type, editable, and with aliases that resolve (an alias to nothing, in a circle or to another type is left out). Anything else is dropped and named in problems, because a deploy that renamed a token must never take a site down over a value somebody chose months ago; the health check and hilms:theme:styles --check report what was dropped. The strict doors are the form and the import, which refuse the value in front of the person who typed it.

A kept override becomes the theme’s token holding the style’s values (Token::withValues()): its type, its description, its editable flag and its bounds stay the theme’s. That is what lets the audit judge a style against the theme’s floors, and what keeps a colour changed in light alone on the theme’s dark.

resolve($theme, $overrides) answers the whole set a page drawn in the style ends up with — the theme’s tokens and every override it takes — and tokensOf($style) does the same for a stored style, reading an unreadable document as no overrides at all. Contrast is judged on that set, and the cascade check computes its expectations from it.

No text an administrator typed reaches the sheet: every value is written by its value object (toCss()), and the name is not even in a comment. A family the style names that this installation keeps on its Fonts page is declared at the top of the sheet, one @font-face per face (below, under Fonts), and the mail palette is computed from the same resolved tokens (below, under Mail).

The compiler writes mail_palette back only when it differs as pairs from the stored one: MySQL returns a JSON object’s keys in an order of its own, and an array compared with === after that is never the same, so every recompile would otherwise save the row again.

A style’s sheet has three blocks, every selector weighing (0,2,0):

/* A style laid over the theme, generated by HiLMS from its tokens. Edit it in the panel, never here. */
:root[data-theme],
.hilms-block-preview[data-theme] {
--color-accent: oklch(48% 0.16 39);
--radius-box: 0px;
--font-sans: ui-serif, Charter, 'Bitstream Charter', 'Sitka Text', Cambria, Georgia, serif;
--font-display: var(--font-sans);
}
:root[data-theme='dark'],
.hilms-block-preview[data-theme='dark'] {
--color-accent: oklch(77% 0.115 248);
}
@media (prefers-color-scheme: dark) {
:root[data-theme='system'] {
--color-accent: oklch(77% 0.115 248);
}
}

Why these selectors and not source order or cascade layers:

  • The theme writes its light values inside Tailwind’s @layer theme, which any unlayered rule beats, and its dark set — and a build-less child’s :root overrides — unlayered at (0,1,0). (0,2,0) beats all of them whatever order the sheets load in. That matters: under lando dev Vite injects the theme’s CSS after the style’s <link>, and a style that relied on coming last would lose in development and win in production. Layers were the other candidate, and they lose to the same order problem — a layer’s rank is fixed by where it is first named.
  • Dark after light, at the same weight, in the same file, so dark wins in dark. That is also why every overridden colour that has a dark value in the theme is written in the dark blocks even when the style changed only its light value: without it, the light block’s (0,2,0) would beat the theme’s dark (0,1,0) and a light accent would show in dark mode. The stored-pair rule means the value is there; the compiler fills it from the theme defensively anyway.
  • An alias is resolved where it is declared. --font-display: var(--font-sans) is computed at the theme’s :root, so overriding --font-sans inside a block preview’s wrapper would leave the heading font where it was. Every theme alias whose chain reaches an overridden token is therefore written again in each block, beside the override.
  • One sheet, two places. :root[data-theme] matches only on the site: every document the theme draws writes data-theme on <html> (the base, error and design layouts do). .hilms-block-preview[data-theme] matches only in the panel, whose <html> carries no data-theme and whose block previews carry one of their own. So the panel’s block previews follow the style while Filament’s own --spacing, --text-* and --radius-* — the same names — are never touched.
  • A person’s preference outranks every style. themes/hilms/css/a11y.css marks the prefers-contrast: more promotions (--color-border, --color-ink-faint) and the reduced-motion zeroing (--motion-fast, --motion-base) !important, and so does app-modules/theme/stubs/a11y.css.stub, which hilms:make-theme writes into a theme with no parent (a child inherits its parent’s). That is exactly what importance is for in the cascade, and a guard in StyleCompilerTest reads both files.

A style that changes nothing compiles to an empty sheet with no hash, and nothing is linked for it.

No PHP test can say which declaration a browser picks, so the precedence is proven where it happens:

bin/artisan hilms:design:cascade writes storage/app/design/cascade/
npm run cascade checks it in Chromium

hilms:design:cascade compiles a fixed style over the active theme — a colour changed in light only, one in both schemes, the border the contrast preference promotes, a duration the motion preference zeroes, a length, the spacing unit Filament shares, and the body font the heading font aliases — and draws it three ways: after the theme’s compiled CSS, before it (Vite’s order under lando dev), and inside a panel-like .hilms-block-preview. Beside them, expect.json lists what every probed property must compute to in each case, taken from StyleCompiler::resolve(). bin/cascade.mjs opens the two site documents in light, dark and both halves of system, each with no preference, contrast: 'more' and reducedMotion: 'reduce' (24 cases), and the panel document in light and dark (2 more) — and compares each property through a probe: one element given var(--x), one given the expected literal, compared as the browser computes them, so the minified build (.004) and the style’s own sheet (0.004) are normalised alike. The panel document declares a spacing of its own in Tailwind’s theme layer, as Filament does, and checks that the root outside the preview keeps it: a preview build that wrote the theme’s tokens on :root again would hand it the theme’s 4 px and fail. Two probes sit inside a block given a look, written as Looks\Look writes one, and prove a look outranks no preference (chapter 26).

It has teeth: take the !importants out of a11y.css and sixteen cases fail; write the light block as plain :root and the style-first dark cases and the panel’s --spacing fail. Run it on the host before merging anything that decides which declaration wins — a token sheet’s selectors, a11y.css, the compiler. Like npm run a11y and npm run visual, it never runs in CI.

npm run visual judges the theme, not a style: the split export inlines the theme’s compiled CSS, strips every external reference and draws in the theme’s own values whatever style the installation is in (ActiveTheme::forceStyle(false)), so its baseline never depends on the database. hilms:design:export --split --style=<slug|active> draws every document in a style instead — its sheet after the theme’s, its options and variants as the picks each document is drawn with, the style named on every manifest entry — which is how npm run a11y checks a style made in the panel, a generated one above all.

theme()->style(); // ?ActiveStyle — the style this request is drawn in
ActiveStyle::current('hilms'); // the active style of a theme, from the cache

ActiveStyle is a readonly snapshot — id, name, hash, fontFamilies, the choices (options and variants) it picks, the preload and the mail palette — never the model. current($theme) reads one cached array under styles.active, holding each theme’s snapshot and the absence of one (['id' => null]), so a site drawn in its theme’s own values costs no query either; a cache store unserialises no class it was not told about, which is why it is scalars. Style forgets it on saved and deleted, and ActivateStyle and the install step forget it too. ActiveStyle::of($style) builds the snapshot of any style, which is what a preview reads.

ActiveTheme::style() answers a preview for the person previewing (below) and the active style for everybody else, and it never throws: a cache or database that cannot answer means the theme’s own values, because an error page draws head() during an outage.

head() links the sheet after the chain’s entries and before the core script, with the request’s nonce:

<link rel="stylesheet" href="https://example.test/theme/styles/3eb0fde5a12614ad.css" nonce="…">

Before it, a style that sets its body text in a stored font gets one <link rel="preload" as="font" type="font/woff2" crossorigin> for that face (below). It also preloads only the built font families the style draws with (ThemeAssets::head($families)): a style that sets the text in a system serif does not download Instrument Sans, and one that uses no built family writes no font tags at all — Vite::fonts([]) is never called, because to Vite an empty list is not “all of them”. previewStylesheets(), which the panel loads through BlocksPlugin, links the active style’s sheet too (never a preview), and keeps every font.

GET /theme/styles/{hash}.css (theme.styles.show, sixteen hex digits) answers the sheet by its content’s hash with Cache-Control: public, max-age=31536000, immutable, Content-Type: text/css; charset=UTF-8 and nosniff, and 404 for a hash nobody compiled. It sits outside the web group — no session, no cookie on a response browsers and proxies keep for a year — and outside Route::localize(), because a sheet has no language. Each browser asks once; the next version has another name.

app(ActivateStyle::class)->handle($style); // throws StyleRefused

ActivateStyle refuses a style of another theme than the one the installation draws, and otherwise, in one transaction, clears the theme’s active style (one model at a time, so the activity log records who stepped down) and marks this one. The unique index is the last word on “one per theme”: two activations at the same moment end with the second refused in a StyleRefused that says so in the reader’s language, never an error page.

StyleAudit::of($tokens) judges a set of tokens against the accessibility contract:

  • contrast — every Contract\ContrastPairs entry in light and dark, as Support\ContrastVerdicts (pair, scheme, ratio, or why none could be computed). Support\ContrastAudit is the same audit hilms:theme:a11y reports on a theme.
  • bounds — every token held to a min or max whose value breaks it, in a sentence (Tokens\Bounds::breach(), which TokenFile uses to refuse a theme’s own default).

It is advice everywhere it is shown. Only an installation that sets HILMS_STYLES_ENFORCE_ACCESSIBILITY=true (theme.styles.enforce_accessibility, off by default) refuses: ActivateStyle throws StyleRefused::inaccessible() carrying the audit, the editor refuses to save the active style into falling short (drafts always save), and the built-in sync passes over a built-in that falls short when it activates one, because that activation does not go through ActivateStyle. It is an environment switch on purpose. The owner’s decision was that enforcement is a promise an operator makes on a client’s behalf — under the European Accessibility Act, say — and a toggle in the panel could be turned off by the very administrator it is meant to hold to it. The shipped default theme is held to the contract regardless, by TokenContrastTest and hilms:theme:a11y.

“Preview” in the panel opens GET /theme/style-preview/{style} in a new tab (theme.style-preview.start, in the web group with auth and can:view,style; a style of another theme answers 404). It puts the style’s id in the session under StylePreview::SESSION_KEY (styles.preview) and redirects to the home destination, or / when none is chosen.

StylePreview::current($theme) answers the previewed style for this request: only while the key is in the session does it read the person and query the one style, only a person who may view it gets it, the answer — null included — is remembered on the request so head() and the banner ask once, and any failure means no preview. ActiveTheme::previewedStyle() exposes it, and style() prefers it. A student whose session somehow carried the key sees the active style: can('view') says no.

themes/hilms/views/components/style-preview-banner.blade.php, drawn by the app layout under the impersonation banner, is a role="status" bar on the accent’s soft tint naming the style, with End preview as a real POST form (theme.style-preview.end, @csrf, no script — the front-end policy runs none inline), which forgets the key and returns to the style’s page in the panel. The guest layout draws no banner — the person previewing is signed in — though any page drawn with head() is in the previewed style. A theme with no parent draws its own layouts, and the scaffold hilms:make-theme writes for one carries both this banner and the impersonation one, in plain markup on the tokens, drawn by its app layout above the bar.

A theme ships styles as files, themes/<name>/styles/<slug>.json, in the same format, named under $extensions.hilms.name:

{
"$description": "The theme's own values, every contrast pair and floor met in both colour schemes. It changes nothing, so it is the file to duplicate or export when starting a style of your own.",
"$extensions": {
"hilms": {"name": "HiLMS WCAG"}
}
}

That is the whole of themes/hilms/styles/hilms-wcag.json: no tokens, so “HiLMS WCAG” draws the theme exactly as it is. A designed look ships the same way, as a file with tokens in it, and may pick the theme’s options and variants under $extensions.hilms.options and .variants; a file picking one its theme does not offer is refused by its path. hilms:make-theme --brand=… writes a new theme’s built-in file from seeds (chapter 26).

SyncBuiltInStyles walks every theme ThemeRepository finds. It removes the built-in rows whose file is gone first (clearing an active mark on the way), then makes each file a read-only row by (theme, slug), follows a file whose name or tokens changed, and activates a theme’s first built-in while that theme has no active style — so a fresh installation lists HiLMS WCAG as the style it draws, and a theme whose active built-in vanished gets another. Where the installation enforces accessibility, a built-in that falls short is passed over for the next one, and with none left the theme draws its own values. A file it cannot read is refused by its path and keeps the row it wrote before, because a typo in a deploy must not change the site; a style made on the installation that already holds the slug is refused too. It returns a SyncedStyles (created, updated, removed, refused, activated) and runs outside the activity log, because it provisions rather than edits. RecompileStyles compiles every style of every installed theme again.

bin/artisan hilms:theme:styles sync the built-in files, compile every style again; exit 1 if a file was refused
bin/artisan hilms:theme:styles --check write nothing; list every refused file and every value a style can no longer use; exit 1 on any

The ops step SyncStyles runs both, right after SeedBase, in hilms:install and hilms:upgrade — a deploy may change a theme’s tokens under every style — and forgets the snapshot. It reports and never stops the upgrade: a refused file is counted, and the step points at hilms:theme:styles --check, which names it and says why. DatabaseSeeder syncs the built-ins, and hilms:make-theme writes styles/<name>.json for a new theme, changing nothing until tokens are written into it. ThemeCheck warns — it does not fail — when the active style holds values the theme no longer takes: the site still draws, in the theme’s own values where the style’s cannot be used.

StyleResource is a resource in the Settings cluster (InSettings, SettingsGroup::Site, sort 15, /admin/settings/styles) over Style::forTheme(theme()->name()) — the active theme’s styles and nothing else. Pages: the list, create, edit, and a view page for a built-in. Above the list sit Import, New style from scratch (StyleFromScratchAction, the five-step wizard of chapter 26) and New style. Globally searchable it is not, so a search can never link to a page someone may not open.

The form has three tabs. Foundations and Components hold one collapsed section per top-level group of the active chain’s token set, sorted onto the tab of the group’s tier, in the file’s order and labelled from theme::styles.groups.* (a child’s own group falls back to its headline). A group whose only foundations are kept under Advanced (below) — the header’s height, the sidebar’s and the menu’s widths, the line width, the smallest control — has no section of its own on Foundations, which is why Header appears on Components alone. Each editable token gets one field, labelled from theme::styles.tokens.* — a child’s own token falls back to its $description, then its variable (TokenLabels). Layout holds the theme’s options and variants (chapter 26).

  • Empty means the theme’s value. Every field starts empty, and its placeholder says “As the theme has it: …” with the value. Fill in only what differs; a field cleared goes back to following the theme.
  • A token that follows another — every component token, and a foundation the theme writes as an alias — is a fieldset whose switch reads “Follow X” (values.<key>.follow), on by default, the same way round as a block’s “Follow the style”. Turning it off shows the fields, filled with the value it followed, and says under the switch that it now has a value of its own; while it is on, nothing is stored for it. Only the form works this way: the stored document holds the value of its own or nothing (StyleTokens::toForm() and fromForm() turn one into the other).
  • A length counted in steps is a number with “× step” after it, whole or half, and a hint saying what it comes to at the density the form holds now — the style’s own once it has one — and that it moves with it; a length typed in place of a count no longer follows the density, and the hint says that too.
  • Advanced, collapsed at the end of Foundations, keeps apart the measures other parts of a page measure themselves against — --header-height, --sidebar-width, --menu-width, --border-width, --target-min — so one is changed on purpose (StyleForm::ADVANCED). The spacing unit, labelled density, carries the same warning in its own section.
  • Generate… sits in the header of four Foundations sections — Colours, Text sizes, Spacing and Corners (GenerateSectionAction) — on the edit page and the create page alike: it asks for that section’s seeds, fills its fields through StyleTokens::formValues(), empties a field whose value equals the theme’s, leaves every following component token following, opens the section and saves nothing (chapter 26).
  • A warning above the form names the stored overrides the theme no longer takes (StyleCompiler::compile($record)->problems) and says that saving drops them for good — for a built-in, that the theme’s file still names them — and is not drawn when there are none.

The state sits under values, keyed by the custom property without its dashes:

Type Field State
colour an OklchColor pair, light and dark values.color-accent.light, values.color-accent.dark
dimension, number, duration text, read by its value object; a floor or ceiling as a hint that turns into a warning beyond it; a count of steps for a length the theme counts in steps values.radius-box.value
font family a choice (Styles\FontChoices): each family the chain builds and each font on the Fonts page, followed by the system stack of its kind, then FontStacks (sans, serif, rounded, mono), “Same as …” where the theme’s value is an alias, “Custom” for a stored stack matching none values.font-sans.value
font weight a choice from 100 to 900 values.font-weight-bold.value
shadow up to four layers (an OklchColor with alpha, across, down, blur, spread, inset) values.shadow-box.layers
a token that follows another the switch, then the fields above values.button-surface.follow

The pages convert with StyleForm::fill() (the document to values through StyleTokens::toForm(), which shows only what the style changes — and of a colour only the scheme it changes) and StyleForm::dehydrate() (values to the document through StyleTokens::fromForm()). One reader serves the field rules and the save alike, StyleTokens::read($theirs, $input, $base): {group.token} stays an alias when it names another token of the same type, a colour goes through ColorValue::fromCss()->asOklch(), a font stack is read as the list the options write, a count of steps as a StepsValue, everything else through TokenType::fromCss(); show() is its exact inverse. A refusal is that field’s error, worded “This value cannot be used: …” with the parser’s reason. tokensFromForm(..., lenient: true) skips what does not parse, which is how advice is given on a form still being written.

OklchColor (view theme::filament.components.oklch-color, script window.hilmsOklchColor in the module’s resources/js/panel.js, style in its panel.css, both registered as hilms-theme and published by filament:assets) is a text input, a decorative swatch (aria-hidden), the hex value in words (computed on the server by Oklch::toHex()) and labelled range sliders for lightness, chroma and hue, plus opacity with withAlpha(). The server rewrites whatever was typed — #hex, rgb(), oklch() — as OKLCH when the field is left (OklchColor::normalise()), so the script only ever reads and writes oklch() strings. The text and the sliders share a deferred $entangle: the text commits on change, the sliders on change after 300 ms, and a slider moved with the keyboard keeps its focus. A module’s Blade gets no Tailwind build in the panel — only Filament’s own CSS is there — which is why the field’s layout is plain CSS in the module.

EditStyle and ViewStyle implement HasSidebarActions. The sidebar holds Save changes and Cancel, then Activate (ActivateStyleAction, hidden on the active style: a confirmation, then ActivateStyle; a StyleRefused becomes a notification listing what falls short in the panel’s own words), Preview (PreviewStyleAction, the start route in a new tab), Export and Duplicate (StyleResource::duplicateAction(): Filament’s ReplicateAction naming the copy “ (copy)”, excluding the slug, builtin, active_theme and the compiled columns so the copy recompiles, then opening the copy). Four actions or more share two columns (resources/css/panel.css), because Filament lays full-width actions out as one row and six overlapped. Below them, Accessibility (ContrastAdvice) renders the audit of the form as it stands: first whether the installation advises or enforces, then every shortfall as a sentence with an icon — “Text on Page background: 3.1 of 7, dark”, the ratio rounded down and written as the reader’s language writes numbers, a broken floor with the English reason Bounds gives — then how many pairs pass. It is recomputed whenever the form reaches the server: a colour field is committed when it is left or a slider let go, a field with a floor when it is left. Delete sits alone at the bottom.

The active style’s editor opens with “This style is live”. Under enforcement, EditStyle::beforeSave() audits the form and halts a save that would make the active style fall short. A built-in opens on ViewStyle, titled with its name — Filament’s “View …” is Polish “Podgląd …”, the Preview action’s own word — as a summary rather than the form (StyleSummary): its name and one-line description, what it changes — each token it overrides with its label and value, a colour in both schemes, or “Nothing: it draws the theme as it is” — and the options and variants it picks, beside Preview, Export and Duplicate, and Activate while another style is active.

Permissions are Style with the record set plus Replicate, held by no role; administrators pass anyway. Every custom action carries its own ->authorize(): Export and Preview view, Import create, Duplicate replicate, and Activate the Update:Style permission rather than the record’s update, because a built-in is activated but never edited. StylePolicy refuses to edit a built-in and to delete a built-in or the active style — but Shield’s super admin passes every policy (intercept_gate => before), and the administrator is who this section is for. So StyleResource::canEdit() and canDelete() say the same again, reading columns only so a row of the list costs no query, and the edit page of a built-in answers 403 even for an administrator.

Fonts are Font with view any, create and delete, held by no role (FontPolicy declares the other nine methods false); create covers adding a face to a font. The Fonts page’s two header actions and its Delete carry their own ->authorize(), and the guard that refuses a font in use runs when Delete is called, never in ->visible(), so the list costs no query per row.

Export downloads <slug>.tokens.json (StyleExport::document()): a translated $description, $extensions.hilms.style with the name and the theme, then the chain’s whole token set as the style resolves it — TokenDocument::write(array_values($compiler->tokensOf($style)->all())), aliases kept, every token with its type, description, dark value, bounds and tier, and a count of steps with the length it comes to at the style’s own density under $value. Under $extensions.hilms.options and .variants it writes every option and variant the theme offers with the value the style draws. A designer sees every value, and HiLMS WCAG’s export is the theme’s own tokens: the starter file for a look made elsewhere.

Import (ImportStyle::handle($json, $name)) takes the file pasted into a JSON editor in the panel — never an upload, so no new upload path to secure:

  1. More than 256 KB is refused before decoding; the text must decode as JSON and be an object ({} and [] decode to the same empty array, so an object is told by its brace).
  2. Every token’s min, max and editable are taken out, then TokenFile::parse($data, 'the imported file') reads it: an export carries the theme’s bounds, and a style below a floor must not export a file its own import refuses.
  3. A token the theme holds as another type refuses the file, naming its path.
  4. Aliases resolve against the file and the theme together: an alias to a theme token stays an alias, an alias to a token only the file has becomes its value.
  5. A token the theme has not got, or keeps from styles, is ignored and reported; a value equal to the theme’s is dropped — so an export imported again gives back exactly the same overrides. Equal is judged by Styles\StyleOverrides, the door every generated value enters by too: a colour as a colour (StyleOverrides::sameColour()), in OKLCH at the precision the editor shows it with the hue of a grey ignored, and within one step of eight bits a channel when either side is written in sRGB, so a file a tool wrote in hex does not turn every unchanged colour into an override; a count of steps by its count. Other tools’ $extensions are ignored; colours keep the space they were written in.
  6. Options and variants the file picks are kept where the theme offers them and does not already draw them; everything else is named among what was passed over (header.layout: tiled, callout variant: neon).
  7. It always creates a new style in the active theme, named as typed, else by the file’s $extensions.hilms.style.name, else “Imported style”. The panel then says how many values it kept and lists (at most ten of) the tokens it passed over, and opens the new style’s editor.

A refusal is a validation error under the JSON editor: “This file cannot be imported: “ and the reason, the parser’s in English.

A style sets text in three kinds of face: a family the theme builds (Instrument Sans in the default theme, preloaded by Vite), a system stack (FontStacks), and a font kept on this installation’s Fonts page. The last is data, like a style, and lives in theme, so no module edge was needed.

The model. Hilms\Theme\Models\Font (fonts: family unique, source — FontSource::Catalogue or Upload — catalogue_id, category) holds its faces as media items of one collection, faces, on the public media disk (media-library.disk_name), woff2 only, each with weight, style, subset and unicode_range as custom properties. All of them end up inside a stylesheet, so each is held to an allowlist where it enters and refused, never repaired or escaped later: the family by FontFamilyValue::isName() — the rule every font token is read by — and Fonts\FamilyNames (at most 120 characters, not a family the chain builds, not another font’s); the rest by Fonts\FaceProperties. Font::faces() reads them back through the same rules as Fonts\FontFace value objects with the file’s public address.

The catalogue is Bunny Fonts, the EU-hosted mirror of Google’s families (Fonts\BunnyCatalogue): the list, cached for a day as plain arrays, one family’s stylesheet, and its files — all fetched by this server through App\Support\RemoteFiles\RemoteFile with fonts.bunny.net as the only host it may reach, checked on the first address and on every redirect. A visitor never reaches the catalogue: DownloadFont keeps the chosen faces on our disk, each sniffed as font/woff2 before any is stored, named by us, idempotent per weight, style and subset, and at most 48 a download. Which character sets to keep is the administrator’s choice, with Latin, Latin Extended and the scripts of the installation’s languages checked upfront (Fonts\ScriptSubsets); keeping more costs disk, never visitors, because a browser fetches only the subsets whose unicode-range a page’s text touches. UploadFontFace takes a licensed woff2 through MediaUpload; the two sources never share a font.

Drawing one. FontChoices offers every font of the page in the editor, the wizard and “Generate…”, each followed by the system stack of its catalogue category. When a style’s resolved font tokens name a stored family the theme does not build, StyleCompiler writes one @font-face per face at the top of the sheet:

@font-face {
font-family: Lora;
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('https://example.test/storage/18/lora-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,…;
}

A face whose family a token could not hold, or whose address a stylesheet could not quote, is left out. The body text’s regular face — --font-sans’s first family, upright, the weight nearest 400, the Latin subset where the font is split — is the style’s preload, carried by the snapshot and written by head() before the sheet, with crossorigin, because a browser always fetches a font in CORS mode and would otherwise fetch it twice.

Keeping sheets true. A sheet holds each face’s address, so Fonts\FaceObserver compiles again every style using a font whenever one of that font’s media rows is saved or deleted: a face added, removed, or moved to a bucket by hilms:media:relocate. The relocator lives in library, which may not import theme; watching the media rows is how the move still reaches the sheets. A font a style uses cannot be deleted, and the refusal names the styles (Fonts\FontUsage::stylesUsing(), the one answer to “who sets text in this family”).

Serving. The faces are public media, so the front-end’s and the panel’s font-src name the origins of the public media disk — its bucket and public address when it is one — and of no other media disk (the panel’s also allows data: and Filament’s own font host). A bucket that serves fonts to another origin must allow it in its CORS rules; docs/install/theming.md has the rule. The Fonts page draws each family in its own faces under a name of its own per font (hilms-font-<id>), so a font named like the panel’s own face never restyles the panel.

A mail client resolves no custom property and no oklch(), and a mail cannot rely on a dark scheme, so the mail theme cannot read the style’s sheet. It reads a palette instead. Styles\MailPalette::SLOTS names each colour the mail theme draws and the token the site draws the same thing with — the page --color-surface, the card --card-surface and --card-border, headings --heading-ink, links and the panel’s line --color-ink, text --color-ink-muted, the footer --color-ink-faint, lines --color-border, the primary button --button-surface with --button-ink, the success and error buttons --color-success and --color-danger with --color-surface on them — and MailPalette::of($tokens) writes each one’s light value as #rrggbb through Oklch::toHex(). The mail draws the site’s inks on the site’s surfaces, so a style that keeps the site’s pairs keeps the mail readable; the card is the exception worth knowing: no pair names --card-surface itself, only the raised surface it follows, so a style that gives cards a surface of their own is judged on neither the site’s cards nor the mail’s.

The compiler writes a style’s palette as it compiles it (mail_palette), the snapshot carries the active one’s, and themes/hilms/views/vendor/mail/html/themes/hilms.blade.php — Laravel’s default mail theme with each colour printed from $palette — reads MailPalette::current(): the palette of ActiveTheme::mailStyle(), which is the style the design export is forced into, else the active one, and never a style somebody is previewing, because a mail leaves the site for somebody who never saw that preview. When the cache or the database cannot answer, the palette is computed from the theme’s own tokens, so a queued mail always renders; a token nothing resolves keeps the colour the mail theme was first written in. The view finder prefers .blade.php to .css, and ActiveTheme::resolveMailTheme() picks the nearest theme of the chain with a mail theme of its own name in either form. A theme with no parent is scaffolded with the same view; a child inherits its parent’s.

  1. Make it in the panel on a development installation, or have a designer start from HiLMS WCAG’s export.
  2. Export it, and trim the file to the tokens that differ from the theme. A whole export works too, but the sync stores a file’s tokens as they are, so every value would be pinned where it stands today instead of following the theme.
  3. Save it as themes/<name>/styles/<slug>.json, with $extensions.hilms.name.
  4. bin/artisan hilms:theme:styles --check, then hilms:theme:styles; on a deployed installation hilms:upgrade does it.

Nothing to do: a token in the theme’s tokens.json is editable unless it says "editable": false, and the form offers every editable token of the active chain. Give it a label in theme::styles.tokens.<key> in both languages if it belongs to the default theme; a child’s own token is labelled by its $description. A colour drawn on top of another goes into Contract\ContrastPairs, or the audit cannot judge it.

Keep a token out of administrators’ hands

Section titled “Keep a token out of administrators’ hands”

"$extensions": {"hilms": {"editable": false}} on the token. The form stops offering it, the compiler leaves a stored override of it out (and names it), and an import passes it over.

bin/artisan view:clear && npm run build
bin/artisan hilms:design:export --split --at="2026-01-01 10:00:00" --style=<slug>
npm run a11y

The export draws every contract view in the style — its sheet, its options and variants — and npm run a11y checks each document. Export again without --style before npm run visual, whose baseline is the theme’s own values.

After a deploy that changed a theme’s tokens

Section titled “After a deploy that changed a theme’s tokens”

hilms:upgrade recompiles every style. bin/artisan hilms:theme:styles --check lists what each style can no longer use; the health page warns about the active one.

  • A theme with no parent keeps its a11y.css preferences !important. The scaffold writes them so; a theme that rewrites its baseline by hand must keep them, or a style outranks a person’s contrast and motion preferences in it.
  • A style never reaches a sheet but through toCss(). Not the name, not the description, not a string from an import.
  • A document without data-theme on <html> gets no style. Every layout of the default theme writes it; a new document shell must too.
  • Shield’s super admin passes every policy. A guard an administrator must meet goes into the resource’s canEdit()/canDelete() or the action, not the policy alone.
  • The panel’s Blade has no Tailwind. Style a module’s panel component in the module’s plain panel.css, and run filament:assets after a merge.
  • The snapshot is scalars. Never cache a Style: a cache store brings a model back as an incomplete object outside the suite’s array store.
  • After a merge, the container may still serve caches it warmed before it. A new route answers 404 until lando artisan optimize:clear; leave the container’s caches cleared locally (Environment).
  • {} is an empty list to PHP. Anything that must tell an empty object from an empty array reads the text, not the decoded value.
  • MySQL reorders JSON object keys. A stored picks or palette column read back is never === the array written; compare as pairs (!=), as Style::choose() and the compiler’s palette do, or every save writes the row again.
  • A font’s address is in the sheet. Anything that moves or removes a face must save or delete its media row through Eloquent, so FaceObserver hears it; a query-builder write would leave sheets naming a file that is gone.
  • Mail never follows a preview. ActiveTheme::mailStyle() is the door, not style().

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.