Theme Forge
Recolouring a page looks like find-and-replace and is not. The colour values are
easy. The failure modes are where the damage happens, and each one has been
learned the expensive way.
When to use
- "把页面换成绿色系" / "change this page's colours"
- "做成和这张截图一样的配色"
- "抽一下这张图的色板"
- "深色和浅色模式都得能看"
- "检查这套配色有没有对比度问题"
- Building a token file (CSS variables, Tailwind config, SCSS) from a palette
Core workflow
1. Never eyeball a palette
If the source is a screenshot, image, or design file, sample it:
python3 scripts/screenshot_palette.py shot.png
Reports the saturated region, the per-control colour ramp, the dominant
endpoint colours, and the glyph colour. The first and last sampled pixels sit
on an antialiased edge, so the script also reports the dominant colour per
end — use that, not the literal endpoint.
If the source is prose ("make it feel like X"), invent the palette, then verify
it with the contrast script before writing any CSS.
2. Decide what each colour is FOR, not just what it looks like
This is the part that gets skipped and the reason recolours break. A single
gradient variable rarely survives contact with a new palette. Split by role:
| Role | Requirement | Typical use |
|---|---|---|
--grad-ink |
readable as text on the page background | headings, gradient text |
--grad-solid |
dark enough for a white or dark label to sit on it | CTA buttons, icon fills |
--grad-lime |
decoration only, no contrast requirement | accents, borders, glows |
--on-accent |
the label colour that goes on --grad-solid |
button text, chevrons |
The original palette may have needed only one gradient because both ends
happened to work for every role. A new palette often cannot do both. If
--grad-ink is a light gradient, the label colour and the light-mode override
both need attention (see step 4).
3. Recolour structurally, not globally
A blind global replace of the old hex values misses three things and breaks two:
Misses
1. Light-mode background — #F4F2FB never appears in dark-theme rules, so
a replace targeting dark values leaves the old tint behind.
2. Values inside gradient strings — linear-gradient(180deg,#0F1024,#070814)
holds two colours the token scan never sees.
3. Copy that names the colour — "Added a violet to pink hero" next to a
green UI is worse than a missing style.
Breaks
4. ("var(--grad-cyan)", "var(--grad-lime)") changes the CSS value but leaves
the class name .gradient-text-cyan intact. Rename classes separately.
5. Structural colours get caught: macOS window dots, a status pip, error-state
red. These are not theme colours.
Best practice: convert the page to token placeholders once, then fill them per
scheme. Structure changes happen in the placeholder pass and are never repeated:
reference.html --normalise--> template.html --scheme--> page.html
(once) (reusable) (per palette)
4. The light-mode override is not optional
A gradient that is perfectly legible on a dark background can be invisible
on white. Measure it:
python3 scripts/contrast.py --label "#46F8A0" --bg "#F2FAF7"
# 1.30:1 FAIL
The rule that follows: a light gradient is fine as a block, never as text on
a light background. Either deepen it for the light theme, or keep the light
gradient only for filled surfaces and reserve a darker variant for text.
[data-theme="light"] {
--grad-ink: linear-gradient(120deg, #0B7A55 0%, #096B7A 100%); /* 5.04 / 5.83:1 */
--accent: #0B7683; /* 5.03:1 */
/* --grad-solid intentionally NOT overridden:
a bright gradient with a dark label reads fine on white, 12.71 / 10.82:1 */
}
5. Measure, never estimate
Every contrast figure in a deliverable must come from the script. Estimates are
consistently pessimistic and shipping them puts unverifiable numbers in front of
the user.
# label against each gradient stop
python3 scripts/contrast.py --label "#061D17" --gradient "#46F8A0,#0FE0F0"
# translucent text, flattened against its background
python3 scripts/contrast.py --label "#EAFBF7" --bg "#04120F" --alpha 0.72
# a whole dark/light token pair at once
python3 scripts/contrast.py --audit tokens-audit.json
Thresholds: 4.5 for body text, 3.0 for large text and non-text (icons, borders,
pips). All three exit non-zero on failure, so they gate a build.
6. Lint the result
# trust the file's own colours, flag leftovers from a previous theme
python3 scripts/token_lint.py page.html --palette auto \
--foreign "#7C3AED,#EC4899,#06B6D4" --no-dashes
# or check against a known palette file
python3 scripts/token_lint.py page.html --palette my-palette.json
Catches unfilled @TOKEN@ placeholders, foreign colours, stale colour words in
selectors, and CSS custom properties that are referenced but never defined.
Two subtleties it handles, both of which produce false alarms otherwise:
- Token blocks mix #04120F and rgba(70,248,160,.2). Matching only hex
literals silently drops half the palette and reports it back as foreign.
- Colour words in prose are legitimate ("a mint to cyan hero"). Only class
names and CSS selectors are checked.
7. Keep the content self-consistent
After a palette change, grep for text that names the old colours and for
hardcoded values inside illustrative code blocks, mockups, and screenshots
rendered in CSS. A product mockup showing from="#7C3AED" in a green page
reads as broken.
Anti-patterns
| Do not | Do instead |
|---|---|
| Estimate contrast | Run contrast.py, quote the output |
| One gradient variable for every role | Split --grad-ink / --grad-solid / --grad-lime |
| Global find-and-replace | Normalise to placeholders once, then fill per scheme |
| Hardcode label colour as white | Add an --on-accent token |
| Only recolour dark mode | Always write the light override, then measure it |
| Trust class names after a value rename | Rename classes explicitly |
| Assume a rename hit | Assert on every replacement; print misses |
Delivering
Ship a token file the user can paste into their own project, not just a rendered
page. Include:
- both light and dark blocks
- RGB triples when the consumer writes
rgba(var(--theme-rgb-mint), .4) - the measured contrast table, clearly labelled as measured
- one note on anything that differs between modes and why
Reference files:
- references/palette-recipes.md — ready-made palettes with measured contrast
- references/recolour-traps.md — the full failure catalogue with fixes
Portability
The scripts need only Python 3 and Pillow (Pillow only for
screenshot_palette.py). Paths in SKILL.md are relative to this skill
directory; run them from there or pass absolute paths.