| name | gmira-palette |
| description | Use when choosing, deriving, or repairing the colors of a web surface: picking a strategy, building OKLCH ramps, wiring Tailwind v4 theme variables or shadcn base colors, or deciding dark versus light. Also use when the accent only ever shows up on one button, when secondary text on a colored panel came out gray, when a headline is set in gradient text, when contrast was eyeballed instead of measured, when a page reads as "one hue applied everywhere so it stopped meaning anything", or when a WebGL layer renders lighter and more saturated than every source hex you passed it. Covers the four color strategies, the 30 to 60 percent surface coverage floor for Committed, derivation from a named material world instead of a category default, contrast read from computed styles, and pixel verification of canvas surfaces. Owns doctrine section 3.2 and the palette half of Law 1. |
Palette
Color is decided at page scale, from a named material, and verified in measured numbers.
Load ../gmira/references/DOCTRINE.md first. Section 3.2 is the floor this skill enforces;
Law 1 is the reason the last section exists.
The premise
Color commits at page scale: fields that own whole regions, not accents scattered over a neutral
ground. The single most common failure is not an ugly hue, it is a palette that was never
committed to: one saturated color assigned to --primary, spent on a button and a link underline,
with everything else neutral. That page is Restrained pretending to be Committed, and it reads as a
default because it is one.
The second most common failure is deriving the hue from the category. Automotive gets red, finance
gets blue, sustainability gets green, AI gets violet. Hue comes from the material world named in
the direction contract, never from a category association.
Step 1: pick a strategy, then pick colors
| Strategy | Definition | Coverage of the rendered surface |
|---|
| Restrained | neutrals plus one accent | accent under 10% |
| Committed | one saturated color carries the page | 30 to 60%, measured |
| Full palette | 3 to 4 named roles, each owning a region | no single role above 45% |
| Drenched | the surface is the color | above 60%, neutrals become the accent |
Mode gates which are open:
| Mode | Open strategies |
|---|
| Persuade, Experience | all four. Color may own large regions. |
| Operate, Read | Restrained is the floor and usually the answer. Color encodes action, selection, status, wayfinding, and reading hierarchy. Rarity is what gives an accent force. |
Committed is a measurement, not an intention
Screenshot the built page full height, then count.
import sharp from "sharp"
const ACCENT = [0xc1, 0x46, 0x2a]
const { data, info } = await sharp("full.png").removeAlpha()
.raw().toBuffer({ resolveWithObject: true })
let hit = 0
for (let i = 0; i < data.length; i += 3) {
const d = Math.max(Math.abs(data[i] - ACCENT[0]),
Math.abs(data[i + 1] - ACCENT[1]),
Math.abs(data[i + 2] - ACCENT[2]))
if (d < 48) hit++
}
console.log((100 * hit / (info.width * info.height)).toFixed() + )
Under 30% with a Committed strategy declared means the strategy was declared and not executed. Fix
it by giving the color a region (a full-bleed band, the footer, the entire spec column, every
alternating row, the whole first viewport), not by adding more small accents.
INCORRECT --primary: the brand color, used on buttons, links, and one icon. 4% coverage.
CORRECT the plate color owns the section bands, the part-number column, the active
filter chips, and the full-bleed footer. 38% coverage, measured from a screenshot.
Step 2: dark or light comes from the scene, never the category
Write one sentence of physical scene from the brief's SCENE block and let it force the answer.
Do not pick dark because the product is technical. Do not pick light because the product is retail.
| Scene sentence | Forced answer |
|---|
| A workshop foreman checks stock on a wall-mounted 27 inch panel under fluorescent tubes at 07:00, reading from three metres. | Light, high contrast, large type. Dark mirrors the room and the panel becomes a mirror. |
| A student opens the syllabus at 23:40, in bed, phone at 20% brightness. | Dark is primary, light is the secondary theme, and the brightness floor matters more than the ceiling. |
| A buyer compares two listings on a phone, outdoors, in direct sun, standing on the forecourt. | Light, and the price and mileage go past AA to 7:1 because sunlight eats the bottom three stops. |
INCORRECT dark, because the product is a developer tool and developer tools are dark.
CORRECT the scene says a workshop foreman reads it from three metres under
fluorescent tubes at 07:00. Light, with the figures at 7:1. The category
never entered the decision.
If both themes ship, design the dark surface explicitly. Do not invert the light theme
mechanically. Elevation in dark comes from lighter surfaces, not from shadows, and the accent
usually needs its chroma reduced and its lightness raised to hold the same perceived weight.
A world may legitimately have no dark mode. A paper world inverted is not a dark mode, it is a
broken paper world. If the answer is "this world has one light state", ship one and say so in the
brief.
Step 3: derive the palette from the material world
Five steps. The check at the end is the one that matters.
- Read
OWN-WORLD from the direction contract. No contract means no palette. Stop and run
gmira-direction.
- List six actual materials from that world, each written as what it physically is, not as a
color name. "Uncoated newsprint, aged, warm" not "off-white".
- Sample each into OKLCH. Physical materials are duller than screen colors. A 1970s spot-plate
red lands near chroma 0.185, not 0.28. Keeping screen chroma is how a material world turns back
into a default palette.
- Assign roles from the world's own logic. Ground from the substrate. Ink from the mark.
Accent from the second plate or the signal element. Rules and separators from the accent,
tinted, never from gray.
- Delete anything that did not come from step 2.
The trace check: every token names the material it came from, in a comment. A token that cannot
be traced came from the default palette and has to go.
INCORRECT the vertical is automotive, so red: --primary: oklch(0.58 0.24 27)
CORRECT the world is a 1970s parts catalogue and its second ink is a spot vermilion
printed on uncoated stock: oklch(0.552 0.185 34). Duller and warmer than any
screen red, because ink on absorbent paper is. The token comment names it.
Worked example: a 1970s Porsche parts catalogue
The direction contract says: monospace part numbers, hairline rules, photographs on a neutral card,
no rounded corners anywhere. The materials, in order:
| Material | What it physically is | OKLCH |
|---|
| Uncoated stock, aged | warm off-white newsprint, never paper white | oklch(0.962 0.010 88) |
| Shaded block | the same stock under a 6% screen tint | oklch(0.928 0.014 86) |
| Press black | dense warm near-black with dot gain on uncoated | oklch(0.235 0.014 62) |
| Black at 60% tint | secondary copy and captions | oklch(0.470 0.018 66) |
| Spot plate | the second ink: signal vermilion, section bands and callout numbers | oklch(0.552 0.185 34) |
| Diagram ink | the technical blue-green of exploded-view callouts | oklch(0.470 0.075 205) |
Strategy: Committed. The plate owns the section bands, the part-number column, active states,
and the full-bleed footer. Measured coverage on the built page: 38%.
Rules are derived, not invented:
--rule: color-mix(in oklch, var(--plate) 24%, var(--stock));
--line: color-mix(in oklab, var(--rule) 64%, var(--stock));
Contrast, measured on the built page, not calculated from the token:
| Pair | Ratio | Verdict |
|---|
| press black on stock | 11.2:1 | body, everywhere |
| 60% tint on stock | 5.6:1 | secondary copy, passes 4.5 |
| plate on stock | 5.2:1 | passes body but with no headroom, so plate is used for large text and fills, and body copy on stock stays ink |
| stock on plate | 5.5:1 | reversed copy inside the bands |
| secondary on plate | color-mix(in oklch, var(--plate-ink) 84%, var(--plate)), 4.7:1 | the mix percentage is a starting point, then you measure. 74% looked right and lands at 4.05:1, which fails |
That last row is the rule in doctrine 3.2 made concrete: on colored surfaces, secondary text is
tinted from that hue or from the foreground. It is never gray, and the mix ratio is measured, not
guessed.
INCORRECT <div class="bg-plate"><p class="text-muted-foreground">Bestellnummer</p></div>
CORRECT <div class="bg-plate"><p class="text-plate-ink/[0.84]">Bestellnummer</p></div>
then measure it, because opacity over a saturated ground rarely lands where you expect.
Step 4: wire it
Two blocks. Primitives carry values and comments. The semantic block carries only assignments.
No new color value ever appears in the semantic block.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--stock: oklch(0.962 0.010 88);
--stock-2: oklch(0.928 0.014 86);
--ink: oklch(0.235 0.014 62);
--ink-2: oklch(0.470 0.018 66);
--plate: oklch(0.552 0.185 34);
--plate-ink: oklch(0.978 0.012 60);
--diagram: oklch(0.470 0.075 205);
--rule: color-mix(in oklch, var(--plate) , (--stock));
: (in oklab, (--rule) , (--stock));
: (--stock);
: (--ink);
: (--stock);
: (--ink);
: (--stock-);
: (--ink);
: (--plate);
: (--plate-ink);
: (--stock-);
: (--ink);
: (--stock-);
: (--ink-);
: (--diagram);
: (--plate-ink);
: ( );
: (--rule);
: (--rule);
: (--plate);
: ;
}
inline {
: (--background);
: (--foreground);
: (--primary);
: (--primary-foreground);
: (--muted);
: (--muted-foreground);
: (--border);
: (--ring);
: (--line);
: (--plate);
: (--plate-ink);
}
@theme inline is what makes the roles available as utilities while keeping the runtime values in
:root, so a .dark block can remap them without regenerating classes.
Three technique rules that stop palettes from drifting:
- Prefer explicit colors over chains of translucent overlays.
bg-black/5 over bg-muted over
an image makes contrast context-dependent and unmeasurable. Two stacked alphas is the limit.
- When building an OKLCH ramp, vary lightness and reduce chroma near white and black. Holding
high chroma at extreme lightness to keep the math uniform produces the neon step that everyone
can see is wrong and nobody can name.
- Theme changes remap semantic roles, they never rewrite primitives. That is the reason for the
two-block split.
Step 5: measure contrast from computed styles
Eyeballing fails on exactly the pairs that matter: mid-tone on mid-tone, and anything on a
saturated ground. Run this in the console at each of the five verify viewports, or through
page.evaluate in Playwright.
const ctxLum = (c) => { const [r, g, b] = c.map(v => { v /= 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 })
return 0.2126 * r + 0.7152 * g + 0.0722 * b }
const parse = (s) => (s.match(/[\d.]+/g) || []).slice(0, 3).map(Number)
const bgOf = (el) => { for (let n = el; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba?\(0,\s*0,\s*0,\s*0\)|transparent/.test(c)) return parse(c) }
return [255, 255, 255] }
const fails = []
document.querySelectorAll("body *").forEach(el => {
(![...el.].( n. === && n..()))
cs = (el)
px = (cs.), w = (cs.) ||
large = px >= || (px >= && w >= )
= ((cs.)), = ((el))
ratio = (.(, ) + ) / (.(, ) + )
floor = large ? :
(ratio < floor) fails.({ : +ratio.(), floor, px,
: el..().(, ) })
})
.(fails)
.().(
.(el. || el., (el, ).))
Floors: body and placeholder 4.5:1, large text 3:1, controls, icons, and focus indicators
3:1. Placeholder text is the one that fails most often, because the default text-muted-foreground
on a tinted input rarely survives the second surface.
Also simulate protanopia and deuteranopia once. For data, encode with lightness, shape, label, or
pattern as well, so color is never the only code.
Step 6: verify the palette survived the effect layer
Doctrine Law 1. A registry shader sets the palette at layer 2 and then adds prism, iridescence,
and a glow at roughly +1.84 per channel before clamp. Prop names say the palette is honored. The
pixels say it is white. Verify from rendered pixels, never from props.
await page.waitForTimeout(1200)
await page.screenshot({ path: "hero.png", clip: await page.locator("canvas").boundingBox() })
const { data } = await sharp("hero.png").resize(48, 48, { fit: "fill" })
.removeAlpha().raw().toBuffer({ resolveWithObject: true })
const lum = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b
const chroma = (r, g, b) => Math.max(r, g, b) - Math.min(r, g, b)
let maxL = 0, maxC = 0, sumL = 0, n = 0
for (let i = 0; i < data.length; i += 3) {
maxL = Math.max(maxL, lum(data[i], data[i + 1], data[i + 2]))
maxC = Math.max(maxC, chroma(data[i], data[i + 1], data[i + 2]))
sumL += lum(data[i], data[i + 1], data[i + 2]); n++
}
console.({ : +(sumL / n).(), : +maxL.(), : +maxC.() })
Compute the same three numbers for the source hexes you passed the component. Then:
| Symptom | Diagnosis | Fix |
|---|
render maxL more than 12 above the lightest source | an additive glow layer | find the col += ... and zero its coefficient, or promote it to a prop defaulting to 0 |
render maxC more than 20 above the most saturated source | prism or iridescence layer | same, and check for a hardcoded vec3 added after the palette mix |
| render mean luminance above every source on a dark palette | more than two col += without renormalizing | renormalize, or pick a different component |
Full repair procedure in gmira-arsenal, step 3a and 3b. A blown-out effect is not a color
problem you can fix in the palette; it is a welded constant in the shader.
The bans, and how to grep for them
rg -n "bg-clip-text|text-transparent" src app
rg -n "border-l-[2-9]|border-l-\[" src app
rg -n "text-gray-|text-zinc-|text-neutral-" src app
rg -n "backdrop-blur" src app
- No gradient text. Emphasis comes from weight or size.
- Declare elevation once: border or shadow, not both. A 1px border under a wide soft shadow is
the ghost card.
- Shadows carry an offset and a soft blur. A zero-offset colored halo is decoration.
- Never gray on a colored surface. Tint from the hue or from the foreground.
- A single accent applied everywhere stops meaning anything. Give it a region and a job.
Checks before this skill is done