Skip to content
MCG Filament CMS Demo

Design system

This document describes the default front-end theme that mcg/filament-cms ships: its design tokens, dark mode, the six skins, the component class names, and the accessibility rules built into the base layer. It is written for a designer or front-end developer who needs to adopt, extend or replace the theme without reading the source code.

Every value listed in this document is the value that ships in the package. The base stylesheet is resources/css/cms.css, and the skin stylesheets are in resources/css/skins/.

The token model

The theme has one token layer, which three kinds of rule consume:

Plain text

@theme            declares every color, font and measurement once
  β”œβ”€β”€ dark mode   reassigns the same tokens
  β”œβ”€β”€ skins       reassign the same tokens
  └── components  consume the tokens, and only the tokens

No component rule names a raw color value. Because of this rule, a skin can be a block of token reassignments rather than a second stylesheet: the token rule in the stylesheet that cms:make-skin generates is 19 lines. Keep this rule when extending the theme: if a component rule needs a value such as color: #333, the token for that value does not exist yet. Add the token to the token layer first, then reference it from the component.

Tokens

Every token name starts with --cms- or --color-cms-, so that no token can collide with a token in the host application's own scale.

Color

Colors are declared in oklch. Lightness in oklch is perceptual, so a skin can change a color's hue without changing how heavy its tints look. In hex, by contrast, replacing a blue accent with a yellow one at the same values produces a yellow that appears twice as bright.

Token Role Base (light)
--color-cms-surface Page background oklch(0.985 0.003 85)
--color-cms-raised Cards and inputs, which sit above the page oklch(1 0 0)
--color-cms-sunken Wells, code and quiet panels oklch(0.955 0.005 85)
--color-cms-ink Primary text oklch(0.21 0.012 285)
--color-cms-muted Secondary text and body copy oklch(0.48 0.012 285)
--color-cms-faint Captions, meta text and placeholders oklch(0.55 0.01 285)
--color-cms-line Hairlines oklch(0.9 0.005 85)
--color-cms-line-strong Input borders and emphasis rules oklch(0.82 0.008 85)
--color-cms-accent The single accent color oklch(0.46 0.075 175)
--color-cms-accent-hover The accent's hover state oklch(0.4 0.08 175)
--color-cms-accent-ink Text on an accent background oklch(0.99 0 0)
--color-cms-accent-faint Tints, marks and washes oklch(0.95 0.02 175)
--color-cms-link Links in prose var(--color-cms-accent)
--color-cms-danger Errors oklch(0.52 0.19 25)
--color-cms-success Confirmations oklch(0.5 0.11 155)

Editing chrome tokens

Seven further tokens style the front-end editor's controls. A skin cannot reassign them:

Token Role Base
--color-cms-inline-add The front-end editor's outline, pencil, + and region tag oklch(0.55 0.17 258)
--color-cms-inline-add-ink Text and icons on that blue oklch(0.99 0 0)
--color-cms-inline-neutral An empty region's box, its + and its tag, and the drag grip oklch(0.55 0.02 258)
--color-cms-inline-neutral-ink Text on that gray oklch(0.99 0 0)
--color-cms-shared-edit All of the above when the block belongs to a shared region oklch(0.72 0.15 75)
--color-cms-shared-edit-ink Text on that amber oklch(0.24 0.05 75)
--font-cms-inline The typeface of the editing controls ui-sans-serif, system-ui, …

These seven are the tokens marked skinnable: false in DesignTokens (src/Support/DesignTokens.php), and they are the only tokens with no dark value. They paint controls that only editors see, and an editor moving between a light site and a dark site sees the same controls on both. The cms_validate_skin MCP tool does not report them as tokens a skin failed to set.

The editing controls stopped following the skin in v0.54.0. Before that release, only the two inline-add tokens and the two shared-edit tokens were fixed. The outline and pencil used --color-cms-accent, the grip used --color-cms-raised and --color-cms-muted, the empty region used --color-cms-line-strong and --color-cms-faint, and all of the controls inherited --font-cms-body. Every skin reassigns those tokens, so a control with the same meaning looked different from site to site. On a skin whose accent was close to the page's text color, the outline was hard to distinguish from the content it surrounded.

No skin file needs a change for this. The tokens a skin sets are unchanged and still style everything else; they no longer reach the editing controls.

Reasons for the base palette

  1. The neutrals are warm. The base surfaces carry hue 85 at low chroma. A site in pure gray looks like a wireframe, and a few degrees of warmth makes the gray read as a choice. The Signal skin uses cool neutrals instead, at hue 265.
  2. The accent is low in chroma. A default theme's accent has to sit beside another organization's brand colors without competing with them. The base accent is a deep teal at chroma 0.075.
  3. accent-ink is a separate token, not white. In dark mode the accent is lightened to a pale cyan, and text on it must be dark. Making the text color a token means dark mode changes one value.
  4. link follows the accent unless it is reassigned. For most brands, the link color and the accent color are the same, so --color-cms-link defaults to var(--color-cms-accent), and a skin that reassigns the accent changes its links too. The token exists separately for brands where the two must differ. A black-and-white identity has a black accent: buttons fill with it and the navigation underlines in it. Links that inherit black are invisible on a dark band. Reassigning --color-cms-link changes the links and leaves the buttons unchanged.

Type

The theme defines four type roles:

Token Role Base
--font-cms-display Headings, buttons, navigation and labels Instrument Sans
--font-cms-body Interface text Instrument Sans
--font-cms-prose Long-form reading Newsreader
--font-cms-mono Code, meta text and URLs system monospace

body and prose are separate roles because a typeface chosen for a 13px navigation label is not suited to 800 words of reading at 19px. A site that uses one role for both often has well-set headings above paragraphs that are hard to read.

Scale

Type sizes are fluid. Each size is computed with clamp() rather than changed at breakpoints, because a heading that changes size in one step at 768px looks wrong at 700px, and the effect is most visible at display sizes.

Token Minimum Maximum
--text-cms-hero 2.75rem 5.5rem
--text-cms-title 2rem 3.5rem
--text-cms-section 1.5rem 2.25rem
--text-cms-lead 1.125rem 1.375rem

Shape and rhythm

Token Base Press Signal Marquee SaaS Arcade Market
--radius-cms 0.5rem 0.25rem 0 0 0.75rem 0 0.3125rem
--cms-measure 68ch 64ch 72ch 62ch 62ch 58ch 68ch (base)
--cms-rhythm 2rem 2rem 2rem 2rem 2rem 2rem 2rem (base)
--cms-gutter 1.5rem 1.5rem 1.5rem 1.5rem 1.5rem 1.5rem 1.5rem

--cms-measure is the width of the reading column. At 68ch, a line is long enough not to feel cramped and short enough that the eye finds the start of the next line. Press narrows it to 64ch because a serif face is wider per character.

--cms-rhythm is 2rem in every skin, because it is a reading measurement rather than a stylistic one. A heading is usually its own block, with its copy in the next block, so --cms-rhythm is the space under every h2 on the page. The skins previously set it between 4.5rem and 9rem, which put up to 144px between a section title and the sentence that follows it, and made the two read as unrelated.

Each skin's larger value still exists, set only on .cms-main--home. A front page is a sequence of full-width bands rather than a column of prose, and bands need more space between them:

Skin Reading (--cms-rhythm) Home bands (.cms-main--home)
Base 2rem 5rem
Press 2rem 5.5rem
Signal 2rem 4.5rem
Marquee 2rem 9rem
SaaS 2rem 5rem
Arcade 2rem 5.5rem
Market 2rem 5rem

--cms-gutter is the distance from the page's content to the edge of the window. It is one value for the whole page: every block reads it, as do the header, the footer, a section's inner column, and the docs and article headers. Changing it moves the left edge of the whole site together. Setting it on only one of those elements leaves the brand in the header out of line with the text beneath it.

Motion

CSS

--ease-cms: cubic-bezier(0.2, 0.8, 0.25, 1);

The theme uses this one easing curve for every transition and animation, so that movement is consistent across the page. Durations range from 160ms for a button press to 500ms for the page-load transition.

Dark mode

Dark mode reassigns tokens and changes nothing else. Every component rule is written once and works in both modes.

The theme honors two signals, the operating system preference and the data-cms-theme attribute on <html>. The attribute takes precedence in both directions:

CSS

@media (prefers-color-scheme: dark) {
  :root:not([data-cms-theme='light']) { /* … */ }
}
:root[data-cms-theme='dark'] { /* … */ }

A host can therefore follow the operating system (the default), force dark, or force light while the operating system prefers dark. Forcing light against a dark preference is the case an implementation that uses only the media query cannot support.

Dark mode is not an inversion of the light palette. The accent is raised in lightness and reduced in chroma, because the light accent is too dark to read on a dark surface and too saturated once it has been lightened.

The visitor's light and dark switch

A third signal takes precedence over both: a switch in the site header. It is shown by default and removed by setting cms.theme.toggle to false. The switch writes light or dark to localStorage under the key cms-theme (Theme::THEME_STORAGE_KEY) and sets the same data-cms-theme attribute, so it needs no CSS of its own; it reuses the attribute override described above.

config('cms.theme.mode') and the visitor's choice are independent. cms.theme.mode is the site's default, and the switch overrides it for that visitor. A host can force dark and still let a visitor switch to light.

The stored value is applied by a blocking script in <head> (resources/views/components/head.blade.php), before the stylesheet is requested. If the choice were read later, on DOMContentLoaded or from a bundled script, a visitor who chose dark would see a white page for the length of the stylesheet fetch, which is the flash the stored choice exists to prevent.

The same script adds a cms-js class to <html>. Both header controls, the theme switch and the menu button, are hidden until that class is present, so a browser without JavaScript shows no buttons rather than buttons that do nothing.

Skins

A skin is a [data-cms-skin] rule that reassigns tokens, plus a small number of component rules for effects a token cannot express, such as a drop cap. The package ships six skins: Press, Signal, Marquee, SaaS, Arcade and Market. With no skin selected, the site uses the base theme.

The labels below are the ones shown in the Skin field of the Site settings screen (resources/lang/en/cms.php, theme.skins).

Base

The base theme is designed to sit unobtrusively behind a brand. It uses warm neutrals, a deep teal accent and a moderate radius, and sets everything in a sans-serif face except prose, which uses a serif.

Press

Label: "Press β€” editorial, serif headlines, warm paper". Press is intended for publications, studios, restaurants and other sites whose content is mainly text and should look printed.

Press adds these rules beyond its tokens:

  1. Variable-axis settings on Fraunces ('SOFT' 30, 'WONK' 1, 'opsz' 84), which use the font's axes rather than its default instance.
  2. Old-style figures in running text and lining figures in statistics.
  3. A horizontal rule extending from each section eyebrow.
  4. A centered italic blockquote with no left border, following print convention rather than blog convention.
  5. A drop cap on the first paragraph of the first rich-text block of an article only. A drop cap on every rich-text block would repeat down the page.

Signal

Label: "Signal β€” technical, hard edges, monospace labels". Signal is intended for products, agencies and documentation.

Signal adds these rules beyond its tokens:

  1. Tighter display letter-spacing: -0.038em, and -0.05em on the hero.
  2. Monospace uppercase labels with 0.18em letter-spacing.
  3. An em-dash marker before eyebrows.
  4. Square buttons with no lift on hover.
  5. An accent-colored rule on top of each statistic.
  6. An accent-colored left border on code blocks.
  7. A hairline vertical grid on hero backgrounds, in place of the base theme's radial wash.

Arcade

Label: "Arcade β€” 8-bit pixel type, hard shadows, four bright colors". Arcade is intended for a games studio, a game jam, a launch party or another site with a playful tone. It is designed dark first, after an arcade cabinet's lit screen in a dark room. Light mode is designed separately, as a white sticker sheet with hard black keylines, rather than as an inversion of dark mode.

Arcade adds these rules beyond its tokens:

  1. Hard offset shadows in place of every blurred shadow. Buttons move down and across by exactly the shadow's offset when pressed, so the button face lands where the shadow was.
  2. Every heading is drawn in two palette colors: the letters in one, and a hard copy of them offset behind in another. The heading level selects the pair.
  3. A β–Ά selector on the current and hovered menu item.
  4. A blinking cursor after the hero heading only.
  5. Figures set as scores in tabular-nums, with the four palette colors cycled across the row.
  6. A dashed focus ring, after the marching selection box of menus from that era.

Four constraints apply when copying Arcade:

  1. Press Start 2P is a bitmap face with one weight. Every size in this skin is well below the size a normal display face would use. A font-weight above 400 produces the browser's synthetic bold.
  2. The light palette is not a port of the dark one. At the dark palette's brightness on white, cyan measured 3.1:1 and gold measured 2.6:1 against a minimum of 4.5:1. The four light colors were therefore chosen to meet the contrast minimum, not matched by eye.
  3. Gold is used only as an offset color, never for text. A yellow that reaches 4.5:1 on a white ground has to come down to about lightness 0.54, and a yellow that dark is olive. The other three colors stay vivid at that contrast, so heading letters use those three, and gold appears only behind them, where it does not need to be legible.
  4. An element the skin draws as a box needs padding. Most of what Arcade outlines with a keyline (a statistic, a list item, a column, an accordion row) is not a box in the base theme, so it has the vertical padding its base shape needed and no horizontal padding. The skin adds that padding. It also adds space between such elements, which a stack of divided rows does not need and a stack of outlined boxes does.

Marquee and SaaS have no entry in this section yet. Their labels are "Marquee β€” near-black, viewport-sized headlines, one loud accent" and "SaaS β€” Inter on slate, indigo accents, soft cards and gradients".

Market

Label: "Market β€” retail, Montserrat throughout, a clear blue on white". Market is intended for shops, product launches and pricing pages that should look like a retail catalog.

Market uses one font family for every role, as the design file it was built from specifies: display, body and prose are all set to Montserrat. Requesting a second face would download a font that nothing renders.

Market adds these rules beyond its tokens:

  1. More space between bands on the home layout, set on .cms-main--home rather than by changing --cms-rhythm. --cms-rhythm is the gap under every h2 on the page, which is a reading measurement; see Shape and rhythm.
  2. Tighter letter-spacing on the hero heading.

The accent is the one value where Market departs from its source design. The brand blue #23A6F0 is oklch(0.693 0.151 240.6), which measures 2.7:1 on white, below the 4.5:1 minimum for a color used for link and button text. Market keeps that exact value in dark mode, where it measures 5.2:1 against the dark ground. In light mode it lowers the lightness to 0.55, where it measures 4.7:1. Hue and chroma are unchanged, and only lightness moves; oklch allows this because its lightness axis is perceptual.

Skin registration and resolution

Theme::skin() (src/Support/Theme.php) resolves the stored or configured skin name through SkinRegistry (src/Support/SkinRegistry.php). The registry is seeded with the six skins the package ships and accepts any number of additional skins.

Resolution order:

  1. The skin stored in site settings (the Skin field on the **Site settings** screen).
  2. cms.theme.skin in config/cms.php.
  3. null, which is the base theme.

The config value is the deployed default and travels with a release. The stored value is an editor's override, so an editor can change the skin on a live site without a deploy, and select "Use the deployed default" to fall back to the config value. A name that is not registered resolves to null rather than throwing an exception, so a typo in a skin name removes the skin without taking the site down.

A bespoke skin name is valid only once it is registered. There are two ways to register one:

  1. In config/cms.php, add the name and label under theme.skins:

PHP

// config/cms.php
'theme' => ['skins' => ['foundry' => 'Foundry']],
  1. In a service provider, call the registry. A skin distributed as its own Composer package uses this method, so the host application does not have to add a config line:

PHP

app(SkinRegistry::class)->register('foundry', 'Foundry');

The package registers its skins in boot() rather than register(), so a skin package that boots afterwards can relabel or replace one of the six. A registered skin is offered in the Skin field on the Site settings screen alongside the six, under the label it registered with.

Generating a skin with cms:make-skin

cms:make-skin writes a new skin stylesheet, and optionally a Blade partial, in the host application. In a terminal at the site's root:

Shell

php artisan cms:make-skin Foundry            # resources/css/skins/foundry.css
php artisan cms:make-skin Foundry --partial  # and resources/views/cms/skins/foundry.blade.php

The name is converted to a slug (Foundry becomes foundry). If a target file already exists, the command asks whether to overwrite it in an interactive terminal, and otherwise stops with … already exists. Pass --force to overwrite. Passing --force overwrites without asking.

The generated stylesheet contains:

  1. The token groups a skin usually reassigns: type, color and radius.
  2. Two dark-mode declarations: a media query for the operating system preference, and an attribute rule for a theme the visitor forced with the header switch. Both are needed, because neither alone takes precedence in both directions.
  3. A .cms-main--home rhythm override, rather than a --cms-rhythm on the root, for the reason given under Shape and rhythm.
  4. A closing comment recording that [data-cms-skin='foundry'] .cms-block__heading has specificity (0,2,0), the same as the hero's own heading rule. Skins are imported last, so a skin rule at that specificity wins the tie without any warning.

Steps the generator prints

The generator does not perform the following two steps. It prints them instead, and the skin has no effect until both are done:

  1. In resources/css/app.css, on the line after the one importing cms.css, add:

CSS

@import './skins/foundry.css';
  1. In config/cms.php, register the name under theme.skins, or register it from a service provider as described in Skin registration and resolution.

Then select the skin in the Skin field on the Site settings screen, or set cms.theme.skin to 'foundry'.

Both steps edit a file the host application owns and formats to its own conventions. The import path in app.css depends on whether the package is symlinked. config/cms.php may be a published copy, an untouched vendor default, or under review with an unfinished diff. A generator that edits either file by pattern matching can corrupt a file it does not understand. Until both steps are done the stylesheet is inert: nothing imports it, and the attribute it is scoped to is never written onto <html>. The command states this in its output.

Fonts for a registered skin

The command also prints a warning about fonts, because registration does not load any typeface. Font families are defined per skin in a map inside Theme (Theme::SKIN_FAMILIES), keyed by the skins this package ships. A skin registered by a host is served the base pair, Instrument Sans and Newsreader, whatever it sets --font-cms-display to, and no error is raised.

To load a registered skin's fonts, use one of these methods:

  1. In config/cms.php, list the skin's families under theme.skin_fonts, keyed by the skin's name, in Bunny Fonts' family:weights notation:

PHP

'theme' => ['skin_fonts' => ['foundry' => ['fraunces:400,600']]],
  1. In a service provider, pass the families as the third argument when registering the skin:

PHP

app(SkinRegistry::class)->register('foundry', 'Foundry', ['fraunces:400,600']);
  1. In config/cms.php, set theme.fonts to false and load the fonts yourself, or point the font tokens at families the application already serves.

Fonts covers fonts in full: the four font tokens, which families the CMS requests for each skin, self-hosted fonts, a skin that uses Bunny Fonts and self-hosted fonts together, and how to check that a font loads.

Reproducing a design file

When a site is built to match a design file, such as a Figma file, the result is a new skin in the application. A core skin (press, signal, marquee, saas, arcade or market) and the package's base styles are never edited, for two reasons:

  1. Every site using that core skin would change with it.
  2. The files live under vendor/mcg/filament-cms, so the next composer update replaces the edit.

A core skin can be used as the starting point:

  1. In a terminal at the site's root, copy the core skin's stylesheet into the application under the new name:

Shell

cp vendor/mcg/filament-cms/resources/css/skins/press.css resources/css/skins/foundry.css
  1. In resources/css/skins/foundry.css, replace every data-cms-skin='press' with data-cms-skin='foundry'. The core stylesheets scope every rule with that attribute selector, in single quotes. A rule left on the old name continues to style the core skin instead of the new one.
  2. In resources/css/app.css and config/cms.php, import the file and register foundry, as in Steps the generator prints.
  3. In config/cms.php, list the copy's fonts under theme.skin_fonts.foundry. The core skin's fonts are keyed by the core skin's name and are not applied to the copy.
  4. In a terminal at the site's root, run npm run build.
  5. On the Site settings screen, select the skin in the Skin field.

The MCP prompt cms_design_from_figma gives the full order of work for a Figma file, from reading the design system to a written plan for sign-off.

Measuring a page against its source

php artisan cms:measure compares a page built in the CMS with the page it reproduces and reports every difference in spacing, size, type and color, at several viewport widths. A page matches its source when every length is within 1px.

One-time setup

  1. In a terminal at the site's root, install the measurement tools as development dependencies:

Shell

npm install --save-dev playwright pixelmatch pngjs
npx playwright install chromium

When the tools are installed somewhere other than the application's own node_modules, set cms.measure.node_modules in config/cms.php to that directory.

Measuring against a live page

  1. In a new file, such as measure/about.json, pair each element of the source page with the matching element of the CMS page, in page order:

JSON

[
  { "label": "Hero", "source": "section.hero", "cms": ".cms-block--hero" },
  { "label": "Hero heading", "source": "section.hero h1", "cms": ".cms-block--hero h1" },
  { "label": "Standfirst", "source": "section.hero p", "cms": ".cms-block--hero p" }
]
  1. In a terminal at the site's root, run:

Shell

php artisan cms:measure /about --source=https://www.example.com/about --map=measure/about.json

Measuring against a Figma file or a PDF

  1. In a new file, such as measure/about.expected.json, list the CMS selector for each element and the values the design specifies. A number is read as px. Values under "*" apply at every width; a width key overrides them:

JSON

{
  "pairs": [
    {
      "label": "Hero",
      "cms": ".cms-block--hero",
      "expect": { "*": { "padding-top": 80, "padding-bottom": 80 }, "375": { "padding-top": 48 } }
    }
  ]
}
  1. In a terminal at the site's root, run:

Shell

php artisan cms:measure /about --expect=measure/about.expected.json

Reading the result

The command prints one table per viewport width (375, 768, 1280 and 1440 by default; change them with --viewport=). Each row names the element, the property, the source value, the CMS value, the difference and PASS or FAIL. The properties are the four margins, paddings and border widths, gap, width, height, font size, line height, letter spacing, font weight, font family, color and background color.

The gap-above row is the visible space between an element and the previous element in the map, measured from one's bottom edge to the next one's top edge. It catches space produced by two layers at once, such as a heading's bottom margin and a paragraph's top margin inside a flex container, which do not collapse, even when each margin on its own looks correct.

For a live source, a pixel-diff image per width is written to storage/app/cms-measure/{width}-diff.png, with the number of differing pixels printed under the table. The command exits with status 1 when any length differs by more than --tolerance= px (default 1), so it can gate a script. --json prints the result as JSON for an agent to read.

Spacing

Every vertical and horizontal gap on a CMS page is drawn by one rule in resources/css/cms.css, and most of those rules read a spacing token. This section lists which rule draws which gap, so a gap that cms:measure reports as different from the source can be traced to the token or the selector that changes it. The same information is available to an agent as JSON from the cms://spacing MCP resource, which Mcg\FilamentCms\Support\SpacingMap produces.

How the gap between two blocks is formed

The space between two top-level blocks is the larger of the two blocks' vertical margins, not their sum. Blocks sit in normal flow inside .cms-region, which is display: block with no padding, so the bottom margin of one block and the top margin of the next collapse into one margin.

  1. Every block's vertical margin is --cms-block-space. The token is unset by default, and .cms-block reads var(--cms-block-space, var(--cms-rhythm)), so the margin follows --cms-rhythm as it is set at the block: 2rem (32px) on an ordinary page, 5rem (80px) on a page using the home layout (.cms-main--home, where each shipped skin sets its own value), and 1.5rem (24px) inside a section, column, tab or accordion entry.
  2. The first block in .cms-main has no top margin, and the last block has no bottom margin. The space above the first block is the top padding of .cms-main, 4rem (64px). That padding is 0 on the home layout and when the first block is a hero.
  3. A section band (.cms-block--section) has no margin of its own. The gap above a section is the margin of the block before it. The space inside the band is --cms-section-padding-block, 1.5rem (24px) by default, and the first and last blocks inside the band have their outer margin set to 0.
  4. Inside a column, a tab or an accordion entry, blocks are spaced by a flex gap of --cms-rhythm (1.5rem there), and their own margins are set to 0. The container is a flex column, and margins in a flex container do not collapse with each other or with the gap.

Rules that read a spacing token

Selector Property Token Default Stacks with
.cms-main--home --cms-rhythm --cms-rhythm 5rem (80px); each skin sets its own
.cms-block margin-block --cms-block-space 2rem (32px); 5rem in .cms-main--home; 1.5rem inside a section the neighboring .cms-block (collapses to the larger)
.cms-block, .cms-blockinner, .cms-columns, .cms-page-header, .cms-docs, .cms-headerinner, .cms-footerinner, .cms-articleheader, .cms-block--content-section.cms-block--bleed .cms-blocktext padding-inline --cms-gutter 1.5rem (24px)
.cms-prose > + and the eleven other rich text containers listed in cms://spacing margin-top --cms-paragraph-space 1em (16px in body text, 14px in a 0.875rem caption)
.cms-block--rich-text > + margin-top --cms-prose-space 1.5rem (24px)
.cms-block--rich-text :where(ul, ol) margin-top --cms-prose-space 1.5rem (24px)
.cms-block--rich-text :where(h2, h3, h4) + :not(h2, h3, h4) margin-top --cms-heading-space 1.5rem (24px)
.cms-block--hero, .cms-carouselslide padding-block --cms-hero-padding-block 89.25px at 375px wide, 176px at 1280px and wider
.cms-block--section .cms-sectioninner padding-inline --cms-section-padding-inline 1.5rem (24px), from --cms-gutter
.cms-block--section .cms-sectioninner padding-block --cms-section-padding-block 1.5rem (24px) the margin of the block before the section
.cms-column, .cms-tabspanel, .cms-accordionanswer, .cms-sectioninner --cms-rhythm --cms-rhythm 1.5rem (24px)
.cms-column, .cms-tabspanel, .cms-accordionanswer gap --cms-rhythm 1.5rem (24px)
:where([class='cms-grid--']) gap --cms-stack-gap 2rem (32px)
.cms-pricing gap --cms-stack-gap Γ— 0.75 1.5rem (24px)
.cms-cardinner, .cms-pricingtier padding --cms-card-padding 1.5rem (24px)
.cms-cards--overlay .cms-cardinner padding --cms-card-padding Γ— 2/3 1rem (16px)

Blocks that ignore --cms-block-space

Eight blocks set their own vertical margin, which replaces the token-driven margin of .cms-block because the rule is declared later in cms.css at the same specificity. Setting --cms-block-space or --cms-rhythm does not move these blocks, including on the home layout. Change them with a rule on the selector in the site's skin.

Selector margin-block
.cms-block--tabs 2rem (32px)
.cms-block--table 2rem (32px)
.cms-block--link-list 2rem (32px)
.cms-block--callout 2rem (32px)
.cms-block--toc 2rem (32px)
.cms-block--series-nav 2.5rem (40px)
.cms-block--code 2rem (32px)
.cms-block--tag-list 1.5rem (24px)

Fixed offsets

The remaining spacing inside blocks, sections, cards, pricing tiers and testimonials is a fixed multiple of Tailwind's --spacing (0.25rem by default), written as calc(var(--spacing) * n). Examples are the bottom margin of a block heading (.cms-block__heading, 20px), the bottom margin of a section heading (.cms-section__heading, 32px) and the space above a card's buttons (.cms-card__foot, 20px). cms://spacing lists every one of them, read from cms.css when the resource is requested, with its pixel value at the default --spacing. No token controls these offsets; change one with a rule on its selector in the site's skin.

tests/Unit/SpacingTokensTest.php fails when a rule in these selector families uses a literal length or a Tailwind spacing utility, so every spacing value in them is either a token or a visible multiple of --spacing.

Finding the rule behind a measured gap

  1. In a terminal at the application root, run php artisan cms:measure for the page and note each FAIL row: the element, the property and the difference.
  2. If the row is gap-above, the gap is formed by the previous element's bottom margin and this element's top margin. For two top-level blocks, the larger margin wins (see "How the gap between two blocks is formed" above). Inside a flex container, the two margins and any gap add together.
  3. Find the element's selector in the table "Rules that read a spacing token" above. If it is there, set that token in the site's skin (resources/css/skins/{name}.css), under the skin's [data-cms-skin='{name}'] selector.
  4. If the element is one of the blocks in "Blocks that ignore --cms-block-space", write a margin-block rule on that selector in the skin instead.
  5. Otherwise, read the fixed list in cms://spacing for the element's selector, and write a rule on that selector in the skin with the source's value.
  6. Run npm run build, then run php artisan cms:measure again and repeat until every row passes.

Skin partials

A skin that needs markup or runtime behavior, such as a canvas, a gradient mesh or a scroll observer, ships a Blade partial with the same name as the skin. <x-cms::head> includes the partial if it exists. This is a naming convention, not a registry: a skin is a stylesheet plus an optional partial named after it.

Theme::skinPartial() checks the host application's view cms.skins.{skin} first, which is resources/views/cms/skins/{skin}.blade.php, the file --partial writes. It then falls back to the package's own cms::skins.{skin}. Checking the host first is what allows a skin registered from outside the package to carry a partial. None of the six shipped skins has one; kinetic was the only skin with a partial, and it has been removed.

Components

Component class names follow BEM: a block (cms-form), an element (cms-form__label) and a modifier (cms-button--primary). These class names are the theming contract. They are stable selectors that the markup and the CSS both rely on, so a host can override one component or a skin can restyle all of them.

Containment

.cms-block, the class on every block's outer element, carries the maximum width and the side gutters. Three classes opt out and paint edge to edge: .cms-block--hero, .cms-block--hero-carousel (the hero block's carousel variant) and .cms-spacer.

Most block views put their content directly into the outer element. Of the 43 block views in resources/views/blocks/, two wrap their content in a .cms-block__inner element: the hero, which needs it to re-center its content inside an edge-to-edge band, and the CTA, which keeps its background on the outer element and caps only the content width. A skin that applies containment to __inner, which is the common assumption, leaves every other block against the edge of the viewport with no padding, and any panel styling placed on the inner wrapper never renders. That mistake shipped once and was found on screen, not by a test.

The opt-out rule uses plain class selectors rather than :where(). :where() contributes no specificity, so an opt-out written with it would lose to .cms-block.

The column system

The cms-grid--N classes are written by six blocks (image grid, columns, logo wall, stats, pricing table and cards), with N taken from the content or from the editor's choice. They are defined once in cms.css:

Class Phone (below 640px) Tablet (sm, 640px) Desktop (lg, 1024px)
cms-grid--1 1 1 1
cms-grid--2 1 2 2
cms-grid--3 1 2 3
cms-grid--4 1 2 4
cms-grid--5 2 3 5
cms-grid--6 2 3 6

Every grid collapses to one or two columns on a phone regardless of the editor's choice, because four columns at 375px are too narrow to read.

Cards

A card with a button is clickable across its whole area, not only on the button. The mechanism is a stretched pseudo-element on the card's link, .cms-card__link::after, set to position: absolute; inset: 0. The page still has exactly one link per card, and that link's accessible name is the button's own label. A card with no link has no clickable area.

Two rules follow from this mechanism for anyone writing a skin:

  1. .cms-card is the only element inside a card that may set position. inset: 0 resolves against the nearest positioned ancestor, so giving .cms-card__inner or .cms-card__foot a position shrinks the clickable area to that element. Repositioning .cms-card__inner to sit over the picture, which is how the overlay layout would be built by hand, leaves a 387 Γ— 56px clickable area on a 389 Γ— 256px card.
  2. A link typed into the card's own text carries z-index: 2, so it sits above the card-wide link and remains clickable.

The cards block has a Card layout field with two values:

Option label Stored as Renders
Picture above the words stacked The original layout and the default. The picture is on top, with the heading, text and button beneath it on the card's surface.
Words over the picture overlay The picture fills the card at 3:2, the heading runs across its foot on a tint of --color-cms-ink, and the button is collapsed to zero size but still carries the link.

overlay stacks the picture and the text with grid-template-areas, not with absolute positioning, so that neither element becomes a containing block and the card-wide link keeps working. The button is collapsed with font-size: 0 rather than with display: none or a clipping wrapper. Both of those alternatives would remove the clickable area, and neither keeps the label available to a screen reader.

Versions saved before the field existed have no value for it. Block::cast() fills the value from defaults() when the version is read, so those versions render as stacked.

Content sections

The content_section block takes a heading, prose, an optional picture and an optional button. The button is a real field (Link label, stored as link_label, with the usual link_type, link_content_id and link_url fields beside it) rather than a link typed as the last line of the prose. The button is not rendered when its target page has been deleted or unpublished.

When the block's width is set to Full bleed, the block becomes a split band instead of a wide two-column block:

  1. It has no gutter and no gap between the columns.
  2. Its minimum height is 50vh.
  3. The picture is cropped to fill its half.
  4. The words are held to a reading measure inside their half.

To achieve this, the picture is taken out of normal flow. In flow, width: 100% against a half-window column and height: 100% against a row whose height the picture itself determines form a circular dependency, which the browser resolves from the image file's own proportions: a square photograph in a 1778px column produced a band 1778px tall. Out of flow, the band's height is the greater of 50vh and the height of the words.

Bands

A section block renders as a band across the window. It can take one of three background colors: muted, accent or inverse. inverse paints the band in the ink color and reassigns the ink tokens on the band's inner element, so everything inside renders in light colors without any block needing to know it is on a dark band.

Two consequences of that reassignment are handled by the base stylesheet:

  1. A component that paints its own background keeps the page's ink colors. A card, a code sample, a pricing tier and a form field each draw themselves on raised or surface, so the band's light ink would land on their light background and disappear. Before this was fixed, a card heading on an inverse band measured white on white. Each of these components resets ink, muted, line and link to the page's values, which are held in --cms-page-* tokens that a band does not reassign. The reset has no specificity, so a skin that needs a transparent card on a dark band can override it with one class.
  2. A background image gets a scrim. Without one, text over a photograph is legible or not depending on the photograph. The scrim follows the band's ink: on a plain or muted band, which has dark text, it lightens the picture toward the page color; on an inverse band it darkens the picture. The hero does the same with a gradient, because its text sits at the bottom of the band rather than filling it.

The header and menu panel

Below 768px, the navigation becomes a panel that slides in from the right, with a scrim, a close button (X), Escape to close, and focus kept inside the panel while it is open. At 768px and above, the navigation is a row in the header.

There is one navigation element, not two. .cms-nav-panel wraps the <nav> and is display: contents above the breakpoint. The wrapper generates no box, so the <nav> sits in the header's flex row as if the wrapper were not there. Below the breakpoint, the same element becomes the fixed panel. Rendering the menu twice, once for each width, would duplicate every link and landmark and put the site's navigation in the accessibility tree twice.

The breakpoint is one value used in two places. The panel and the desktop dropdown must switch at the same width. If they do not, an absolutely positioned dropdown appears inside the slide-out panel, or a nested list appears loose in the header row. If the breakpoint is changed for one, change it for both.

Every panel in the header is a <details> element. Dropdowns and mega panels both use the disclosure element, as the language switcher does, and open on click. Until v0.76.0 they opened on :hover and :focus-within, which failed WCAG 2.1 SC 1.4.13: content shown on hover must be dismissible without moving the pointer. The classes are .cms-nav__disclosure on the element, .cms-nav__link--mega on its <summary>, and .cms-nav__caret on the chevron, which rotates when the element has the [open] attribute.

A mega panel adds .cms-mega and, inside it, .cms-mega__column, .cms-mega__list, .cms-mega__item, .cms-mega__link, .cms-mega__text, .cms-mega__label, .cms-mega__description, .cms-mega__image and .cms-mega__overview. Below the breakpoint, .cms-mega is a plain column in normal flow. At 768px and above, it becomes an absolutely positioned grid.

A mega panel is positioned against the header, not against its menu item. .cms-nav__item is position: relative so that a dropdown can hang from it. That positioning does not suit a panel several hundred pixels wide: on a 1584px window, a three-column panel opening from an item at x=1021 extended 118px past the right edge, and items further right extended further. Above the breakpoint, .cms-nav__item--mega is therefore position: static, which makes .cms-header the containing block. .cms-header is position: sticky and so is already a containing block. The panel spans the header from edge to edge: .cms-nav__item--mega > .cms-nav__disclosure > .cms-mega (cms.css:1291-1298) sets right-0, left-0 and min-w-full, with no max-inline-size limiting it to the 1152px width of .cms-header__inner.

The column grid is centered inside the panel; the panel itself is not centered. .cms-nav__item--mega > .cms-nav__disclosure > .cms-mega > .cms-mega__columns (cms.css:1306-1310) sets grid-template-columns: repeat(auto-fit, 14rem) with justify-content: center. auto-fit counts how many 14rem tracks fit the container's actual width and collapses the tracks that have no content, so the remaining space is split between the two margins. At a 1503px viewport, five columns draw five 224px tracks with 120px free on the left and 120px free on the right. The tracks are a fixed 14rem rather than minmax(12rem, 1fr): a 1fr track grows to absorb all spare space, so three columns in a 1577px window became about 500px each, leaving no free space for justify-content to center.

The overview link sits outside the column grid, in the panel's bottom-right corner. .cms-mega__overview (cms.css:1237-1240) sets align-self: flex-end. .cms-mega is a flex column, so align-self moves the link across the panel rather than down it. At a 1953px viewport, the link sits 25px from the panel's right edge and 25px from its bottom edge, which is the panel's p-6 padding (24px) plus its 1px border.

.cms-nav__link is defined twice in cms.css. The header component sets display: inline-block on it. The in-page Navigation block sets display: block on the same class name several thousand lines later, and wins on source order. This makes no visible difference to an ordinary link, because .cms-nav__list is a flex container and converts its items to block-level boxes either way. A host rule at the specificity of a single class will lose to the later definition, however. Scope the rule to .cms-nav to avoid this, as .cms-nav .cms-nav__link--mega does.

The closed panel is hidden with visibility, not opacity. Content hidden with visibility: hidden is removed from the tab order without any JavaScript keeping an inert attribute in step with the viewport. The visibility transition must be a step: visibility 0s linear 0s when opening and visibility 0s linear 0.32s when closing. An eased visibility transition still computes hidden on the first frame, so a focus() call in that frame has no effect and the panel opens with keyboard focus left behind it. That version shipped first and was found by testing with a keyboard.

The header's blur is on .cms-header::before, not on .cms-header. An element with a backdrop-filter becomes the containing block for its fixed-position descendants. With the filter on .cms-header, the panel and its scrim sized themselves to the 69px-tall header instead of the viewport. On the pseudo-element, the blur paints identically and creates no containing block. A test asserts that no rule puts backdrop-filter on the header element itself.

The scrim is inside the header, before the panel. The scrim and the panel are then both in the stacking context opened by the header's z-index, so the scrim dims the header too. A scrim outside the header would leave an undimmed strip along the top of the page.

The brand link has mr-auto instead of the row relying on justify-between. With the header controls added, the row has three children, and justify-between would center the navigation instead of keeping it beside the controls. An auto margin absorbs the free space first, so the layout holds with either number of children.

Reusable implementation details

Element Implementation Reason
Navigation underline Grows from the left with transform: scaleX(), not width. Transforms animate on the compositor; animating width triggers layout.
Hero scrim A gradient overlay, not reduced opacity on the image. Lowering the image's opacity dims all of it. A gradient keeps the top of the image clear and darkens only the area behind the text.
Logo wall Grayscale at 55% opacity until hovered, when the block's Mute the logos until hovered toggle is on (the default). The wall reads as a texture until a visitor looks for a specific name.
Accordion chevron A drawn ::after with two borders, rotated between 45Β° and -135Β°. A drawn shape can be animated; a text glyph cannot.
Search result marks --color-cms-accent-faint, never the browser's default yellow. The highlight stays within the skin's palette.
Page-load reveal The first three blocks rise into place, selected by nth-child, with delays of 0, 60 and 120ms and a duration of 450ms each. Blocks after the third do not animate. A visitor who has scrolled past the fold is waiting for content, not for animation.

The page-load reveal animates transform only and does not fade. The common version of this effect starts at opacity: 0, which leaves the page blank for the delay plus the duration. On a content site that has three costs:

  1. A screenshot taken during that window shows an empty page.
  2. A slow device lengthens the window.
  3. Anything that captures a page early, such as a crawler, a link preview card or a monitoring check, can capture an empty page.

With transform only, every word is legible in the first painted frame. The reveal was first shipped with a fade and corrected in review. Under prefers-reduced-motion: reduce it does not run.

Accessibility

The following rules are in the base layer and should be kept in any redesign:

  1. Focus ring. A 2px solid outline in the accent color, offset by 3px, on :focus-visible. The theme never sets outline: none, because keyboard users cannot see where they are without a focus indicator.
  2. Skip link. The first focusable element on the page. It is visually hidden until focused and targets #cms-content.
  3. prefers-reduced-motion. Under reduce, every animation and transition duration is set to 0.01ms and smooth scrolling is disabled. A visitor who has asked the operating system to reduce motion is not asked again.
  4. Reading measure. Every text column is capped at --cms-measure.
  5. Line wrapping. Headings use text-wrap: balance and body text uses text-wrap: pretty, which avoid orphans and two-word last lines.
  6. Contrast. Every text color measures at least 4.5:1 against every surface it sits on, in light and dark mode, in every skin.

The contrast rule was stated in this document before it was true. The base theme's --color-cms-code-comment and every skin's --color-cms-faint measured between 2.51:1 and 4.10:1. Those tokens color captions, navigation headings, the language switcher, footer notes and code comments, on every site, until the values were measured and corrected.

The cause was that the rule was documented and believed but never measured. The palettes had been matched by eye, and faint is intended to look faint, so a token that looks correct is the one most likely to fail the measurement without anyone noticing. Measure a skin before shipping it, against every surface each color lands on. The page, the raised card and the sunken well are three different backgrounds, and a value that meets 4.5:1 on one can fail on another. Arcade passed the audit because its palette had been chosen to meet the contrast minimum rather than matched by eye.

Extending the theme

Recoloring the whole site

Six variables change the site's accent, display face and corner radius:

CSS

:root {
  --color-cms-accent: oklch(0.55 0.19 25);
  --color-cms-accent-hover: oklch(0.48 0.2 25);
  --color-cms-accent-ink: oklch(0.99 0 0);
  --color-cms-accent-faint: oklch(0.94 0.04 25);
  --font-cms-display: 'Your Face', sans-serif;
  --radius-cms: 0;
}

Switching to a shipped skin

Both of the first two steps are required:

  1. In config/cms.php, set theme.skin to the skin's name, for example 'press'. This writes data-cms-skin onto <html> and selects the skin's fonts.
  2. In resources/css/app.css, import the skin stylesheet after cms.css. The stylesheet supplies the rules that the attribute selects.
  3. In a terminal at the site's root, run npm run build, then php artisan cms:cache-clear.

Writing a skin

  1. In a terminal at the site's root, run php artisan cms:make-skin Foundry.
  2. Complete the two steps the command prints. See Generating a skin with cms:make-skin.

Limit the skin's component rules to effects that tokens cannot express.

Overriding one component

CSS

@layer components {
  .cms-block__heading { @apply text-6xl tracking-tighter; }
}

Every rule in the theme is in @layer components, so a utility class written directly in a Blade view always takes precedence over it.

What the theme does not provide

  1. A general-purpose component library. The theme styles the CMS's own blocks, including the Cards and Tabs blocks. It provides no modals, toasts or other components for the host application's own markup.
  2. Utility classes in the markup. The package's views use semantic classes styled with @apply. Tailwind therefore never has to scan the package's views, and a single block view can be overridden without rewriting its styles.
  3. Container queries. The theme uses media queries throughout. Container queries would suit the block model better and are a candidate for a later release.
  4. A JavaScript framework. The theme's scripts are plain inline JavaScript with no build step and no dependency, because a CMS page must render on a host that does not bundle assets. They are the 14-line blocking script in resources/views/components/head.blade.php, which applies the stored theme choice, and the script in resources/views/components/site-header.blade.php (218 lines including comments), which runs the theme switch, the slide-out menu panel, and closing the header's disclosures on an outside click or Escape. Anything more interactive is the host application's responsibility.