Authoring Themes

A theme maps framework slots to different tokens and never touches the paint pipeline, so a themed screen keeps its device-capability rendering. This page walks the workflow: start from the boilerplate, map your slots, register the id, and lint.

#

The contract

A theme sets token references through the theme-slot mixins and never touches the paint pipeline directly. The screen's device mode still decides how every token renders: dither patterns on 1-bit, palette images on limited color, solids on full color.

  • Allowed: semantic channel refs (theme-slots.semantic-bg/text/stroke/border)
  • Allowed: component slot refs (theme-slots.bg-slot/text-slot/border-level-slot/border-token-slot)
  • Allowed: utility token remaps (theme-slots.utility-* and the bulk utility-remap-* helpers)
  • Optional: icon paint for adaptive images (theme-slots.semantic-icon). When omitted, image--adaptive icons follow the theme's semantic text-primary paint.
  • Disallowed: root palette overrides (--white, --black, --gray-*, --color-*)
  • Disallowed: framework-owned paint variables (--bg-*, --text-*, --border-*)

The full vocabulary of channels, slots, and remaps lives on Theme Slots .

#

Start from the boilerplate

framework/themes/_theme-boilerplate.scss is the starting point. A theme is one file: load the theme-slot mixins, emit the framework layer order, open the tn--themes layer, and scope every rule to .trmnl .screen--theme-<name>.

Keep @include layers.order; above the layer block. Cascade layer order is fixed by whichever stylesheet the browser parses first. Without it, a theme link placed before plugins.css loses to the framework defaults.

@use "../config/layers" as layers;
@use "../mixins/theme-slots" as theme-slots;

@include layers.order;

@layer tn--themes {
    .trmnl .screen--theme-example {
        // 1. Semantic channels: the whole screen in a few lines.
        @include theme-slots.semantic-bg("canvas", "yellow");
        @include theme-slots.semantic-text("text-primary", "black");

        // 2. Component slots: tune specific surfaces.
        @include theme-slots.bg-slot("title-bar", "yellow-40");

        // 3. Utility remaps: re-point the raw bg--/text-- utilities.
        @include theme-slots.utility-remap-grayscale("yellow");

        // Optional: give image--adaptive icons their own paint
        // (they follow text-primary when this is omitted)
        // @include theme-slots.semantic-icon("yellow-20");
    }
}

Work in that order: semantic channels first, then component slots, then utility remaps. The shipped themes follow the same section order, so they read as reference implementations: black-and-yellow-theme.scss is a dark-side grayscale remap, white-and-red-theme.scss a bright-side remap with one targeted utility exception.

#

Register and lint

The file name, the registry id, and the screen class stay in sync: themes/<id>-theme.scss registers as <id> in lib/framework/themes.rb and applies as screen--theme-<id>.

rake framework:themes:lint enforces the contract on every theme file:

  • The theme files on disk match the registry ids.
  • No framework-owned paint variables (--bg-*, --text-*, --border-*).
  • No root palette overrides (--white, --black, --gray-*, --color-*).
  • No calls to the deprecated role-token helper.
#

Dark mode

Themed screens are exempt from the framework's dark-mode remaps: every dark rule carries a zero-specificity :where(:not([class*="screen--theme-"])) gate, so a theme is a complete color statement and screen--dark-mode has no effect on it.

A theme that wants a dark variant styles the combination itself, with the same plain mixins as the rest of the theme. There is no dark remap to work around, so the device mode still resolves every token: dither patterns on 1-bit, palette tiles on limited color, solids on full color.

.trmnl .screen--theme-example.screen--dark-mode {
    @include theme-slots.semantic-bg("canvas", "black");
    @include theme-slots.semantic-bg("surface", "black");
    @include theme-slots.semantic-text("text-primary", "yellow");
}

Do not use $raw: true in a theme. It skips the mode pipeline, so the surface renders as one flat color with no dither on 1-bit and no palette tile on screen--color-4bwry. The framework uses it only for its own unthemed dark defaults.