CSS Architecture skill for Codex
Let layers decide the cascade, not selector weight. Codex measures the stylesheet first with a bundled specificity report, then puts order under explicit cascade layers so vendor and legacy CSS stop winning by weight. Components get single-class selectors and semantic tokens, one scoping strategy is chosen for the codebase, and !important is paid down component by component with visual checks and a CI gate that only gets stricter.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- css-architecture Installs to ~/.agents/skills/css-architecture/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install css-architecture The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project css-architecture Installs to .agents/skills/css-architecture/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/css-architecture.zipunzip -o css-architecture.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) css-architecture PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $css-architecture, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Structures application CSS so it scales without specificity wars - cascade layers (@layer) for ordering, design tokens as custom properties, one naming or scoping strategy (BEM, CSS Modules, @scope, utility-first), low and flat specificity, component boundaries, and a measured path off !important. Ships a standard-library specificity report script. Use when starting a stylesheet structure, reviewing CSS that keeps needing !important, untangling third-party or legacy CSS overrides, choosing between BEM, CSS Modules and utilities, or setting lint rules for a design system.
Use it when
- Starting the stylesheet structure for an app or design system
- Overrides keep needing higher specificity or !important
- Third-party or legacy CSS fights your components
- Adding stylelint rules or a CI gate for CSS quality
Use something else when
- The work is utility classes and Tailwind configuration: use tailwind-v4
- The problem is page layout with grid, flexbox or container queries: use modern-css-layout
How to check the result
css_specificity.py passes the agreed --max and --max-important gates, the counts improve on the saved baseline, and visual regression screenshots are unchanged.
The mistake it prevents
Adopting cascade layers but leaving old CSS unlayered, which makes the legacy styles beat every new layered rule.
Done well means: the order of styles is decided by explicit cascade layers, not by file
import order or selector weight; component selectors are a single class (specificity
0,1,0 to 0,2,0); values come from tokens; there is one scoping strategy per codebase;
and !important appears only in utilities or reset layers where it is intentional.
Measure before changing
- Read
AGENTS.md, the styling stack (package.json: Sass, PostCSS, Tailwind, CSS Modules, CSS-in-JS) and the stylelint config if one exists. - Find the entry stylesheets and their import order (
main.css,_index.scss,globals.css, framework layout files). - Run the bundled report on the CSS sources (standard library only):
python3 scripts/css_specificity.py --top 20 src/styles
python3 scripts/css_specificity.py --json src > /tmp/css-before.json # baseline for later
It prints selector count, a specificity histogram, ID selectors, !important count,
deeply chained selectors, and the highest-specificity selectors with file and line.
Gate CI with --max 0,3,0 --max-important 10; it exits 1 when a limit is exceeded.
Run it on compiled CSS for Sass projects if the source uses heavy mixins.
Layer order
/* styles/index.css: the only place that decides order */
@layer reset, tokens, base, vendor, components, utilities, overrides;
@import url("./reset.css") layer(reset);
@import url("./tokens.css") layer(tokens);
@import url("./base.css") layer(base);
@import url("some-datepicker/dist/styles.css") layer(vendor);
@import url("./components/index.css") layer(components);
@import url("./utilities.css") layer(utilities);
| Layer | Contains | Specificity budget |
|---|---|---|
reset | normalize, box-sizing | element selectors |
tokens | :root custom properties, theme switches | :root, [data-theme] |
base | element defaults: body, headings, links, forms | elements, :where() |
vendor | third-party CSS you cannot edit | whatever it ships, now contained |
components | one block per component | single class, max 0,2,0 |
utilities | single-purpose helpers (.visually-hidden, .stack) | single class, may use !important |
overrides | temporary hotfixes with a ticket link | empty most of the time |
Rules:
- Later layers win regardless of specificity. An ID in
vendorloses to a single class incomponents. That is how you stop fighting third-party CSS. - Unlayered styles beat all layered styles. Anything not imported into a layer becomes an accidental top layer; wrap legacy files in a layer when you adopt layers.
!importantreverses layer order (important declarations in earlier layers win). Keep!importantinresetfor things like[hidden] { display: none !important; }and inutilities, nowhere else.- Tailwind v4 already uses layers named
theme,base,components,utilities; put your own layers in the same order statement rather than inventing parallel names.
Tokens
@layer tokens {
:root {
--color-surface: oklch(0.99 0 0);
--color-text: oklch(0.21 0.02 260);
--color-accent: oklch(0.6 0.17 255);
--space-1: 0.25rem; --space-2: 0.5rem; --space-4: 1rem; --space-8: 2rem;
--radius-2: 0.5rem;
--font-body: system-ui, sans-serif;
}
[data-theme="dark"] {
--color-surface: oklch(0.2 0.01 260);
--color-text: oklch(0.95 0 0);
}
}
@layer components {
.card {
--card-padding: var(--space-4); /* component-level token: the public knob */
padding: var(--card-padding);
background: var(--color-surface);
border-radius: var(--radius-2);
}
.card--compact { --card-padding: var(--space-2); }
}
- Primitive tokens (
--blue-600) feed semantic tokens (--color-accent); components use semantic ones only, so theming changes one place. - Expose component variants by setting component custom properties, not by overriding properties from outside.
- Register animatable or typed tokens with
@propertywhen you need transitions on them.
Scoping strategy: pick one
| Codebase | Default | Notes |
|---|---|---|
| React/Vue/Svelte app with a bundler | CSS Modules or framework-scoped styles | Hashed class names, no global leaks |
| Team already using utilities | Tailwind (see $tailwind-v4) plus a small component layer | Keep custom CSS in layers |
| Server-rendered templates, multiple teams | BEM (.block__element--modifier) | Pure convention; lint it |
| Component-local styles without build tooling | @scope (.card) to (.card__slot) { ... } | Check Baseline support for your browser targets |
| Design system shipped to other apps | Web components or prefixed BEM (.ds-button) plus layers | Consumers put it in their vendor layer |
Mixing two strategies for the same kind of thing (some components BEM, some CSS Modules) is the failure mode. If you must migrate, migrate per component and record it.
Selector rules
- One class per component element. No IDs for styling. No element selectors below the
baselayer, except inside@scopeor:where(). - Do not nest more than one level (
.card { &:hover {} & .card__title {} }is fine; four levels deep reproduces the specificity problem). Prefer flat BEM classes over descendant chains. - State via attributes that also serve accessibility:
[aria-expanded="true"],[aria-current="page"],[data-state="open"], not.is-opentoggled by JS. - Zero-specificity wrappers for defaults that must be easy to override:
:where(.prose) a { color: var(--color-accent); }. - Never style by DOM position across component boundaries (
.sidebar .card .title). The parent sets a custom property or a modifier class on the child instead.
Paying down !important and specificity debt
- [ ] Baseline report saved (css_specificity.py --json)
- [ ] Declare the layer order; wrap all existing CSS in a "legacy" layer placed before components
- [ ] Move vendor CSS into the vendor layer; delete overrides that existed only to beat it
- [ ] Per component: rewrite to single-class selectors in the components layer, remove its !importants
- [ ] Visual check after each component (screenshots or the visual regression suite)
- [ ] Re-run the report; tighten the CI gate (--max, --max-important) to the new numbers
Wrapping legacy CSS in an early layer instantly makes every new layered rule win, which lets you rewrite incrementally without touching old selectors.
Linting
Stylelint is the usual enforcement point: selector-max-id: 0,
selector-max-specificity: "0,3,0", declaration-no-important: true (with overrides for
the utilities file), selector-class-pattern for the chosen naming convention, and
max-nesting-depth: 1. Adding stylelint needs a package install (network).
Gotchas
@importwithlayer()must come before other rules in the file; a misplaced import is silently ignored by browsers.- CSS-in-JS libraries inject styles at runtime, usually unlayered, so they beat layered CSS. Either configure them to emit into a layer or keep them in a single strategy.
- Sass
@extendacross files creates long selector lists with surprising specificity; prefer mixins or plain classes. - Removing a “dead” selector based on grep misses classes built dynamically
(
`btn--${variant}`). Search for the prefix too.
Verification
python3 scripts/css_specificity.py --max 0,3,0 <css-dir>exits 0, and the!importantcount went down compared with the baseline JSON.- Visual regression screenshots of key pages unchanged (or changes are intended).
- DevTools Styles pane on a component: winning rule comes from the expected layer, with no crossed-out overrides fighting it.
Output
## CSS architecture: <scope>
- Layer order: <statement>
- Strategy: <CSS Modules | BEM | utilities + components>
- Before -> after: selectors <n>, IDs <n>, !important <n>, max specificity <a,b,c>
- Components migrated: <list>; remaining legacy layer size: <n selectors>
- CI gate: css_specificity.py --max <a,b,c> --max-important <n> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "CSS Architecture"
short_description: "Let layers decide the cascade, not selector weight"
default_prompt: "$css-architecture Our CSS is full of !important; give me a plan and start fixing it" scripts/css_specificity.py
#!/usr/bin/env python3
"""Report selector specificity, ID selectors and !important usage in CSS files.
Usage:
python3 css_specificity.py [--top N] [--max A,B,C] [--max-important N] [--json] <file-or-dir> [...]
Prints a summary (selector count, specificity histogram, ID selectors, !important
count, deepest selectors) and the N highest-specificity selectors with file:line.
Gating: with --max, exits 1 if any selector exceeds that specificity (compared as a
tuple); with --max-important, exits 1 if more !important declarations exist.
Without gates it exits 0. Exit 2 means bad usage or unreadable input.
Handles plain CSS and native nesting (nested specificity is approximated as parent
plus child). Ignores keyframe selectors. Scans .css/.pcss/.scss (SCSS is parsed as
CSS; mixins and functions are skipped, so treat SCSS numbers as estimates).
Standard library only.
"""
from __future__ import annotations
import json
import os
import re
import sys
EXTS = {".css", ".pcss", ".postcss", ".scss"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".nuxt", ".output", "coverage", "vendor"}
FORGIVING = {"is", "matches", "not", "has", "-webkit-any", "-moz-any"}
ZERO = {"where"}
LEGACY_PSEUDO_ELEMENTS = {"before", "after", "first-line", "first-letter"}
IDENT = re.compile(r"-?[_a-zA-Z -\\][-_a-zA-Z0-9 -\\]*")
def _matching_paren(s: str, i: int) -> int:
depth = 0
for j in range(i, len(s)):
if s[j] == "(":
depth += 1
elif s[j] == ")":
depth -= 1
if depth == 0:
return j
return len(s) - 1
def _split_top(s: str, sep: str = ",") -> list[str]:
parts, depth, cur = [], 0, []
for ch in s:
if ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
if ch == sep and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
parts.append("".join(cur))
return [p.strip() for p in parts if p.strip()]
def add(x, y):
return (x[0] + y[0], x[1] + y[1], x[2] + y[2])
def specificity(sel: str) -> tuple[int, int, int]:
a = b = c = 0
i, n = 0, len(sel)
while i < n:
ch = sel[i]
if ch == "#":
m = IDENT.match(sel, i + 1)
a += 1
i = m.end() if m else i + 1
elif ch == ".":
m = IDENT.match(sel, i + 1)
b += 1
i = m.end() if m else i + 1
elif ch == "[":
b += 1
depth = 0
while i < n:
if sel[i] == "[":
depth += 1
elif sel[i] == "]":
depth -= 1
if depth == 0:
break
i += 1
i += 1
elif ch == ":":
is_element = sel.startswith("::", i)
start = i + (2 if is_element else 1)
m = IDENT.match(sel, start)
name = m.group(0).lower() if m else ""
i = m.end() if m else start
arg = None
if i < n and sel[i] == "(":
close = _matching_paren(sel, i)
arg = sel[i + 1:close]
i = close + 1
if is_element or name in LEGACY_PSEUDO_ELEMENTS:
c += 1
if arg and name == "slotted":
a2, b2, c2 = max_spec(arg)
a, b, c = a + a2, b + b2, c + c2
elif name in ZERO:
pass
elif name in FORGIVING and arg is not None:
a2, b2, c2 = max_spec(arg)
a, b, c = a + a2, b + b2, c + c2
elif name in {"nth-child", "nth-last-child"} and arg and " of " in arg:
b += 1
a2, b2, c2 = max_spec(arg.split(" of ", 1)[1])
a, b, c = a + a2, b + b2, c + c2
elif name in {"host", "host-context"} and arg:
b += 1
a2, b2, c2 = max_spec(arg)
a, b, c = a + a2, b + b2, c + c2
else:
b += 1
elif ch == "*" or ch == "&" or ch in " >+~|\t\n":
i += 1
else:
m = IDENT.match(sel, i)
if m:
c += 1
i = m.end()
else:
i += 1
return (a, b, c)
def max_spec(selector_list: str) -> tuple[int, int, int]:
specs = [specificity(s) for s in _split_top(selector_list)]
return max(specs) if specs else (0, 0, 0)
def strip_comments(text: str) -> str:
# keep newlines so line numbers stay correct
return re.sub(r"/\*.*?\*/", lambda m: "\n" * m.group(0).count("\n"), text, flags=re.S)
def parse(path: str):
text = strip_comments(open(path, encoding="utf-8", errors="replace").read())
rules, important = [], 0
stack: list[tuple[str, tuple]] = [] # (kind, parent specificity) kind in rule/at/keyframes
buf_start, line = 0, 1
line_at = []
for ch in text:
line_at.append(line)
if ch == "\n":
line += 1
for i, ch in enumerate(text):
if ch == "{":
prelude = text[buf_start:i].strip()
prelude_line = line_at[min(buf_start + (len(text[buf_start:i]) - len(text[buf_start:i].lstrip())), len(line_at) - 1)]
in_keyframes = any(k == "keyframes" for k, _ in stack)
parent = next((s for k, s in reversed(stack) if k == "rule"), None)
if prelude.startswith("@"):
kind = "keyframes" if re.match(r"@(-\w+-)?keyframes\b", prelude) else "at"
stack.append((kind, parent or (0, 0, 0)))
elif in_keyframes or not prelude or prelude.startswith("%") or prelude.startswith("@include"):
stack.append(("at", parent or (0, 0, 0)))
else:
selectors = _split_top(prelude)
best = (0, 0, 0)
for s in selectors:
sp = specificity(s)
if parent is not None:
sp = add(sp, parent)
best = max(best, sp)
rules.append({"file": path, "line": prelude_line, "selector": " ".join(s.split()),
"specificity": sp, "has_id": "#" in re.sub(r"\[[^\]]*\]", "", s),
"depth": len(re.findall(r"\s*[>+~]\s*|\s+", s.strip()))})
stack.append(("rule", best))
buf_start = i + 1
elif ch == "}":
if stack:
stack.pop()
buf_start = i + 1
elif ch == ";":
decl = text[buf_start:i]
if "!important" in decl.replace(" ", "").lower() or re.search(r"!\s*important", decl, re.I):
important += 1
buf_start = i + 1
return rules, important
def walk(target: str):
if os.path.isfile(target):
yield target
return
for root, dirs, files in os.walk(target):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
if os.path.splitext(f)[1].lower() in EXTS:
yield os.path.join(root, f)
def main(argv: list[str]) -> int:
top, gate, gate_imp, as_json, targets = 15, None, None, False, []
it = iter(argv)
try:
for arg in it:
if arg == "--top":
top = int(next(it))
elif arg == "--max":
gate = tuple(int(x) for x in next(it).split(","))
if len(gate) != 3:
raise ValueError
elif arg == "--max-important":
gate_imp = int(next(it))
elif arg == "--json":
as_json = True
else:
targets.append(arg)
except (StopIteration, ValueError):
print("error: bad option value", file=sys.stderr)
return 2
if not targets:
print(__doc__.strip(), file=sys.stderr)
return 2
rules, important, files = [], 0, 0
for t in targets:
if not os.path.exists(t):
print(f"error: {t} does not exist", file=sys.stderr)
return 2
for p in walk(t):
r, imp = parse(p)
rules += r
important += imp
files += 1
rules.sort(key=lambda r: r["specificity"], reverse=True)
ids = [r for r in rules if r["has_id"]]
hist: dict[str, int] = {}
for r in rules:
a, b, c = r["specificity"]
bucket = "id" if a else ("0,4+,x" if b >= 4 else f"0,{b},x")
hist[bucket] = hist.get(bucket, 0) + 1
over = [r for r in rules if gate and r["specificity"] > gate]
failed = bool(over) or (gate_imp is not None and important > gate_imp)
if as_json:
out = {"files": files, "selectors": len(rules), "important": important,
"id_selectors": len(ids), "histogram": hist,
"top": [dict(r, specificity=list(r["specificity"])) for r in rules[:top]],
"over_max": [dict(r, specificity=list(r["specificity"])) for r in over]}
print(json.dumps(out, indent=2))
else:
print(f"Files: {files} Selectors: {len(rules)} !important: {important} ID selectors: {len(ids)}")
print("Histogram: " + ", ".join(f"{k}={v}" for k, v in sorted(hist.items())))
deep = [r for r in rules if r["depth"] >= 3]
print(f"Selectors with 3+ combinators: {len(deep)}")
print(f"\nTop {min(top, len(rules))} by specificity:")
for r in rules[:top]:
a, b, c = r["specificity"]
print(f" ({a},{b},{c}) {r['file']}:{r['line']} {r['selector']}")
if gate:
print(f"\nSelectors above --max {','.join(map(str, gate))}: {len(over)}")
if gate_imp is not None:
print(f"!important {important} vs --max-important {gate_imp}: {'FAIL' if important > gate_imp else 'ok'}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.