template-kit/no-hex
The template-kit/no-hex authoring rule — what it enforces, why, and how to fix it.
No hard-coded colours in section code. Islands are not exempt — this is the one rule that holds on both sides of the hydration boundary.
This rule ships as a warning, not an error: a section spending theme tokens is
recommended, not gated. A hard-coded colour squiggles in your editor, but it does not
fail template-kit check (the check gates on errors only, so a warning never reaches it)
and so does not block ingest either. The guidance below is still the right thing to do —
it only changes what the linter does with a violation, not whether the violation is
worth fixing.
The rule flags two shapes:
- A CSS colour function that bakes a colour, anywhere in a string or template
literal:
oklch(…),oklab(…),rgb(…),rgba(…),hsl(…),hsla(…),color-mix(…). No context test — nobody spells an anchor idrgb(...). One carve-out: acolor-mix()whose colour arguments are all theme-derived is legal — see below. - A hex literal (3, 4, 6, or 8 digits) in a colour context. A colour context is
a Tailwind arbitrary-value bracket that resolves to a colour:
- a colour property —
[color:#fff],[background:#fff],[background-color:#fff], and the rest ofborder-color,outline-color,box-shadow,text-shadow,fill,stroke,caret-color,accent-color,text-decoration-color; - any custom property —
[--brand:#ff0000]; - a colour-utility prefix, with variants stripped —
bg-[#f00],hover:bg-[#f00],md:dark:text-[#f00],border-t-[#000], and the same forring,outline,shadow,fill,stroke,decoration,divide,accent,caret,placeholder,from,via,to; - or anywhere inside a JSX
styleattribute.
- a colour property —
Everywhere else, a #-prefixed string is left alone: href="#cafe" is an anchor, and
w-[#…] is not a colour utility. #cafe, #facade, #decade, and #beef are real
anchor ids that happen to be spelled from hex digits.
Every hex-shaped token in a string is checked, not just the first — an out-of-context
token early in a className cannot shadow a genuine colour later in it.
The color-mix() carve-out
Section titled “The color-mix() carve-out”A color-mix() carries no palette of its own when every one of its colour arguments
is theme-derived — a var(--…) reference, transparent, currentColor, or a nested
color-mix() judged on these same terms. It composes a colour out of the theme the
section was handed, so it is as theme-agnostic as bg-surface and cannot break when
another template reuses the section. It is legal:
hover:bg-[color-mix(in_srgb,var(--tr-color-surface)_92%,var(--tr-color-ink))]bg-[color-mix(in_srgb,var(--tr-color-ink)_50%,transparent)] ← a translucent tint: overlay, scrim, hover washA mix weight (92%) is not a colour; only the colour arguments are tested.
transparent and currentColor are the only palette-free keywords: transparent names
the absence of a colour, and currentColor defers to the inherited (theme-driven)
color. Every other CSS named colour (white, black, rebeccapurple, …) is a baked
palette exactly as a hex is. So these report:
color-mix(in srgb, var(--tr-color-primary) 80%, #fff) ← a hex literalcolor-mix(in srgb, var(--tr-color-primary) 80%, white) ← a CSS named colourcolor-mix(in srgb, var(--tr-color-accent, #fff) 80%, …) ← a var FALLBACK is a baked colour the moment the property is undefinedThe carve-out is color-mix() only — it is the one colour function that composes a
colour rather than stating one. The other six take raw channel values, so they bake a
colour by construction: rgb(0 0 0), oklch(0.7 0.1 20), and even
rgb(var(--r) var(--g) var(--b)) report.
Reason
Section titled “Reason”A section carries zero palette of its own. It is theme-agnostic, and must look right under every template’s theme — so a baked colour breaks the first time another template reuses the section. Keeping colour in tokens is what makes a section portable rather than a one-off.
This is why islands get no exemption. An island is exempt from the effect, state,
handler, and inline-style bans because those are server-render concerns and an island
runs in the browser. A palette is not a render-path concern: the hydration boundary is
not a palette boundary.
Use one of your theme’s colour utilities. If the value is genuinely brand decoration with
no token yet, add it to colors in the template’s theme.ts — the key lagoon
compiles to --tr-color-lagoon and the bg-lagoon / text-lagoon utilities — which
keeps the value in one place instead of in one section.
If you need a colour the theme does not name — a hover, active, or pressed step — derive
it from the tokens the theme does name with color-mix(), rather than reaching for a
literal to lighten or darken with.
Before
Section titled “Before”import { Section } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) { return ( <Section> <div className="bg-[#f5f5f5]"> <h2 style={{ color: "#1a1a1a" }}>{slots.headline}</h2> </div> </Section> );}import { Section } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) { return ( <Section> <div className="bg-surface-alt"> <h2 className="text-ink">{slots.headline}</h2> </div> </Section> );}A state colour, derived
Section titled “A state colour, derived”Lightening a hover step by mixing in a hard-coded white is both the violation and the bug: under a dark theme it lightens a button the theme wanted dark. Mix toward a colour the theme names, and the step follows whatever theme the section is handed.
// Before — `#fff` is a palette this section invented.<button className="bg-primary hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,#fff)]"> {slots.cta}</button>
// After — every colour argument is a theme var.<button className="bg-primary hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,var(--tr-color-surface))]"> {slots.cta}</button>See also
Section titled “See also”- Theme and CSS — the
token system, and how a
colorsentry becomes a utility. - Server vs client — why the other bans stop at the hydration boundary and this one does not.