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.

  • Frontend
  • $css-architecture
  • 179 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- css-architecture

Installs to ~/.agents/skills/css-architecture/ for every project on this machine.

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.

~/.agents/skills/css-architecture/SKILL.md

--- name: css-architecture description: Structures application CSS so it scales without specificity wars - cascade layers (@layer) for ordering, design tokens as custom properties, one… license: MIT ---

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

  1. Read AGENTS.md, the styling stack (package.json: Sass, PostCSS, Tailwind, CSS Modules, CSS-in-JS) and the stylelint config if one exists.
  2. Find the entry stylesheets and their import order (main.css, _index.scss, globals.css, framework layout files).
  3. 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);
LayerContainsSpecificity budget
resetnormalize, box-sizingelement selectors
tokens:root custom properties, theme switches:root, [data-theme]
baseelement defaults: body, headings, links, formselements, :where()
vendorthird-party CSS you cannot editwhatever it ships, now contained
componentsone block per componentsingle class, max 0,2,0
utilitiessingle-purpose helpers (.visually-hidden, .stack)single class, may use !important
overridestemporary hotfixes with a ticket linkempty most of the time

Rules:

  • Later layers win regardless of specificity. An ID in vendor loses to a single class in components. 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.
  • !important reverses layer order (important declarations in earlier layers win). Keep !important in reset for things like [hidden] { display: none !important; } and in utilities, 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 @property when you need transitions on them.

Scoping strategy: pick one

CodebaseDefaultNotes
React/Vue/Svelte app with a bundlerCSS Modules or framework-scoped stylesHashed class names, no global leaks
Team already using utilitiesTailwind (see $tailwind-v4) plus a small component layerKeep custom CSS in layers
Server-rendered templates, multiple teamsBEM (.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 appsWeb components or prefixed BEM (.ds-button) plus layersConsumers 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 base layer, except inside @scope or :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-open toggled 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

  • @import with layer() 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 @extend across 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 !important count 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.yamlgenerated from this page

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.py266 lines, 9.7 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem