Skip to content
HomePagesHomePages template kit

The CSS pipeline

The kit's two CSS entries, how a section's own CSS reaches the page, the cascade-layer order a published page loads in, where breakpoints come from, and why a frame is a viewport.

The kit owns only what is true of every template: the spacing base unit, the motion defaults, the reset gaps Tailwind’s preflight leaves, and the section box model. It ships no colour, font, type, radius or shadow of its own, and there is no shared file for you to edit to make a token of yours usable.

Two CSS entries ship, and they are not interchangeable.

Entry What it is How it loads
@homepages/template-kit/styles.css The global Tailwind preamble: --spacing, motion defaults, and @source registration of the kit’s compiled JS. No breakpoints — those are yours imported into your Tailwind entry, after Tailwind
@homepages/template-kit/base.css Cascade-layer order, the three reset gaps Tailwind’s preflight leaves, .tr-section, .tr-frame, .tr-image-frame, and the motion tokens its own stylesheet on the page — not part of the Tailwind entry
/* your CSS entry */
@import "tailwindcss";
@import "@homepages/template-kit/styles.css";
@import "./theme.css"; /* generated by `template-kit theme` from theme.ts */

Three constraints, all of them silent when broken:

  • Import Tailwind exactly once per build graph. styles.css deliberately does not @import "tailwindcss" itself — yours does. Two double-emit Tailwind’s preflight reset (box-sizing declared twice, several KB of duplicate base layer), and nothing errors.
  • Your generated theme must be imported here, inside the Tailwind build graph. It contains an @theme block, which is a Tailwind directive: anywhere outside the graph it is inert, and you get a stylesheet with no token utilities and no error explaining why.
  • Import it after styles.css, so the kit’s globals are in place before your tokens land on top of them.

styles.css also carries an @source line registering the kit’s own shipped dist for Tailwind’s class scan. That is how the utility classes baked into the kit’s primitives (Image’s object-cover, …) reach your compiled CSS; it also overrides Tailwind’s default exclusion of node_modules. You do not need to add a @source for the kit yourself.

Why a section carries no palette of its own is the theme model. In practice: the template-kit/no-hex lint rule flags a hard-coded hex literal in a colour context, and a CSS colour function anywhere, without exempting "use client" files the way most of the preset’s other rules do.

For a colour the theme does not name — a hover, active, or pressed step, or a translucent tint — derive it from the tokens that are named, with a color-mix() over theme-derived arguments: hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,var(--tr-color-surface))]. Such a mix composes a colour out of the theme rather than stating one, and is the single colour function no-hex allows — it carves out no other.

A section’s compiled CSS layer is assembled from two sources, both automatic — you never write an import to make either happen:

  1. Hand-written CSS, collected by path. Every .css file inside the section’s folder — styles.css and any other stylesheet nested under it — is picked up by walking the folder, wherever it sits, and concatenated into @layer sections. A template’s own hand-written stylesheet is collected the same way, into @layer template.
  2. Package CSS, harvested from the render path. The build also bundles Renderer.tsx’s module graph — every component and island it imports — and pulls in whatever .css those imports resolve to. A bare specifier (import "swiper/css";) resolves into node_modules and lands in the same @layer sections; that is the sanctioned way for a third-party package’s stylesheet to reach the page.

Two constraints follow directly, and both are enforced by lint:

  • Don’t re-import your own stylesheet. It is already collected by path — importing it again from a .ts/.tsx file only double-emits it, and can smuggle a stray @layer statement into a nested layer. See no-css-import-from-render-path.
  • @import never works inside hand-written CSS, package or local. An @import landing inside @layer sections / @layer template is invalid CSS, and the browser drops it silently rather than erroring — the package’s styling is simply missing from the published page. Reach a package’s stylesheet from code instead. See no-bare-css-import.

Why this order is fixed by the platform rather than negotiated per section is the theme model. A published page loads, in order:

  1. base.css — layer order, the reset gaps, .tr-section, .tr-frame, .tr-image-frame, motion tokens.
  2. Your compiled Tailwind bundle — your entry: @import "tailwindcss" + the kit’s styles.css + your compiled theme + the utilities your section markup uses.

The layer order base.css declares is:

@layer theme, base, components, sections, template, utilities;

Utilities come last, so a Tailwind utility always beats the reset’s element defaults.

base.css does not ship a reset. Your entry’s @import "tailwindcss" brings Tailwind’s preflight, and preflight is the reset — box-sizing, margins, list-style, the button and anchor resets. base.css carries only the three declarations preflight does not make: font-smoothing, button { cursor: pointer } (Tailwind v4 leaves buttons at cursor: default), and svg { max-width: 100% } (preflight caps only img and video).

Ship base.css without a Tailwind entry on the page and you get an unreset page.

base.css references no design token of yours — nothing global is opinionated about your brand. Its one tintable surface is the placeholder fill shared by the Image and Video frames — one class, one --tr-image-frame-bg, so you retint both at once; see Slot.Image/Slot.Video.

Breakpoints read the window’s width. On a website that is the visitor’s browser; in a rendered class it is the frame’s own geometry, so the same ladder is legal there and means frame width — see A frame is a viewport below, and Frames and formats for the authoring discipline.

The kit declares no breakpoint and resets none of Tailwind’s. Where your design swaps is your decision, so sm: md: lg: xl: 2xl: arrive at their standard Tailwind values and nothing is switched off.

Name your own in theme.ts’s breakpoints group, beside your colours and type:

const theme = {
// …colours, fonts, type, radii, shadows, layout…
breakpoints: {
phone: "390px",
wide: "1440px",
},
};

Each becomes a variant prefix — phone:flex, wide:grid-cols-3 — and they compile into their own @theme block. Redefining one of Tailwind’s (md, lg) is fine; a name that collides with a Tailwind state variant (hover, dark, print, …) is rejected when your theme is validated, because the collision would otherwise silently turn every existing hover: into a media query.

For a one-off threshold not worth naming, write the arbitrary variant inline: min-[640px]:flex.

Stepping a theme token at a breakpoint: set it on body, not :root. Your compiled theme declares its tokens in an unlayered :root block, and your hand-written CSS is layered — so a :root { --tr-text-display: 72px } inside an @media in your stylesheet loses to the theme’s value at every width, and the published page keeps the base size. body has no unlayered rival and every section inherits from it.

An image or document template renders into a frame, and every host owes a frame document a window that is exactly the format’s bleed geometry. base.css’s .tr-frame fills that window (width: 100vw; height: 100vh), so viewport units and width/height/aspect-ratio @media read frame geometry by construction — they are the primary responsive vocabulary, exactly as on a website. Container queries and cqw/cqh inside a section are ordinary component styling, resolving against containers the section declares itself.

A host that got that window wrong would leave you reading a plausible but mis-measured frame, so base.css says so in the document: on a mismatch, .tr-frame::after covers the frame with a diagnostic naming the window the format declares and the window it was actually given. It is invisible whenever the two agree. That pseudo-element and the --tr-frame-window-* custom properties on the frame root belong to the kit — don’t repurpose either.

Two disciplines still hold for the rendered classes, graded over the compiled stylesheet, so a Tailwind utility and a hand-written declaration are judged the same: rendered-no-motion and rendered-no-fixed. For a same-geometry destination difference, reach for a per-format variant — unknown-format-variant catches a variant prefix naming no declared format, which would otherwise compile to nothing and say nothing.

  • Frames and formats — the authoring model: geometry-first styling, per-format variants, bleed and trim, and the format switcher.
  • The theme model — why colour lives in tokens and why the cascade order is fixed rather than negotiated per section.
  • TokenTheme — the tokens your generated theme.css carries.
  • no-bare-css-import — why an @import inside a layered stylesheet is dropped.
  • no-css-import-from-render-path — why a section does not import its own stylesheet.
  • Use content-visibility — a hand-written rule placed into @layer template, and what containment costs.