Skip to content

CSS Hard Rules

FieldValue
TypeSkill Resource
Source~/.copilot/skills/design/references/css/hard-rules.md
DescriptionNot specified

Source Content

CSS Hard Rules

These are non-negotiable. SKILL.md gives a one-line reminder of each and links here; this file is the canonical home. Every rule states the rule, why it exists, how to apply it, and how it is enforced. How each rule is checked — Stylelint, a Python script, or judgment — is laid out in ./linting.md.


The rules

Enforced rules

1. Tokens only

Rule: never write a raw color, spacing, or radius literal in component CSS — reference var(--token). Raw values are allowed only in the token-definition layer where the tokens are declared.

Why: a literal hardcodes one theme and one scale into a file that should bend to all of them.

How to apply:

/* button.css — consumes tokens, declares none */
.button {
color: var(--color-fg); /* not #1a1a1a */
background: var(--color-bg); /* not white */
padding: var(--space-2) var(--space-4); /* not 8px 16px */
border-radius: var(--radius-md); /* not 6px */
}

Enforcement: Stylelint’s declaration-property-value-disallowed-list rejects raw hex and rgb()/rgba()/hsl()/hsla() literals on the color properties (color, background-color, border-color, outline-color, fill, stroke) and points you at var(--token); the token-layer override turns this off where literals are declared.

2. Theme-compatible always

Rule: assume multiple themes exist; colors come from tokens that flip via :root and [data-theme]. Never hardcode a color, and never conditionally swap classes per theme in markup.

Why: when the token flips, every component restyles for free — class-swapping puts theme logic in a hundred call sites.

How to apply:

/* tokens.css — the ONE place literals live */
:root { --color-bg: #ffffff; --color-fg: #1a1a1a; }
[data-theme="dark"] { --color-bg: #121212; --color-fg: #f5f5f5; }
{/* Right: same class in every theme — the token flips */}
<div className="card" />
{/* Wrong: theme branching in markup */}
<div className={isDark ? "card card--dark" : "card"} />

Enforcement: hardcoded colors are caught by Rule 1; check_theme_completeness.py verifies every token is defined in every theme, and check_contrast.py checks each foreground/background pair for legibility. Theme-conditional class logic in markup is judgment — not statically checkable. Full model: ../tokens-and-theming.md.

3. Symmetric padding

Rule: padding-inline left == right and padding-block top == bottom, unless there is a specific, commented reason. Prefer the two-value shorthand or padding-inline / padding-block.

Why: symmetric padding is the default that reads as intentional; asymmetry without a note reads as a bug.

How to apply:

.card {
/* symmetric: one value per axis */
padding-block: var(--space-4);
padding-inline: var(--space-6);
}
.alert {
/* asymmetric on purpose: extra room for the close button at the end */
padding-block: var(--space-3);
padding-inline: var(--space-4) var(--space-10);
}

Enforcement: the custom Stylelint rule css-skill/symmetric-padding flags a padding shorthand whose top != bottom or left != right, and unequal padding-top/padding-bottom or padding-left/padding-right within a rule; an adjacent comment containing the word asymmetric clears it. More: ../spacing.md.

4. Margins live at the layout layer, not on components

Rule: components own padding and internal spacing only. Outer spacing between components is the parent layout’s job, via gap or a stack/cluster primitive.

Why: a component cannot know the spacing of every layout it lands in; component margins become the hook people use to hack one-off spacing.

How to apply:

/* Wrong: the card decides its own outer gap */
.card { margin-bottom: var(--space-4); }
/* Right: the layout owns the gap between children */
.stack { display: flex; flex-direction: column; gap: var(--space-4); }

Enforcement: the custom Stylelint rule css-skill/no-component-margins flags any margin / margin-* declaration except value 0 and value auto; layout, global, token, and print file overrides switch it off where margins belong. Layout primitives: ../spacing.md.

5. Never duplicate

Rule: do not repeat a declaration block; share it via a generic reusable class used in every place that needs it.

Why: duplicated CSS drifts — one copy gets fixed, the others rot.

How to apply:

/* Right: one reusable surface class, reused everywhere */
.surface { background: var(--color-surface); border-radius: var(--radius-md); }
/* .card and .panel both compose .surface instead of re-declaring it */

Enforcement: Stylelint’s no-duplicate-selectors rejects a selector that appears more than once. Sharing patterns: ./architecture.md.

6. BEM owns structure, Tailwind owns layout/spacing

Rule: BEM classes own component structure and variants; Tailwind utilities own layout, spacing, and one-off visual tweaks. Never use both for the same responsibility.

Why: one owner per responsibility means there is exactly one place to change a thing — and no specificity war.

How to apply:

{/* Right: variant owned by BEM, layout owned by Tailwind */}
<button className="button button--primary mt-0 self-start">Save</button>
{/* Wrong: variant owned twice — which one wins? */}
<button className="button button--primary bg-blue-600">Save</button>

Enforcement: judgment — not statically checkable. The decision table and examples: ./bem-and-tailwind.md.

7. Semantic HTML and semantic class names

Rule: use the correct HTML element for the job, and name classes by role, never by appearance.

Why: role-based names survive a redesign; .red-box lies the moment the box turns gray.

How to apply:

<!-- Right: real element, role-named class -->
<button class="button button--danger">Delete</button>
<!-- Wrong: div acting as a button, appearance-named class -->
<div class="red-rounded-box" onclick="...">Delete</div>

Enforcement: judgment — neither appearance-named classes nor non-semantic HTML are statically checkable. Naming standard: ./comments-and-naming.md.

8. Visible focus on every interactive element

Rule: every interactive element shows a visible focus style. Never outline: none / outline: 0 without an equally visible replacement.

Why: keyboard users navigate by the focus ring — removing it strands them, and it fails WCAG.

How to apply:

/* Right: replace the default outline, never just remove it */
.button:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}

Enforcement: the Stylelint a11y plugin rule a11y/no-outline-none rejects outline: none / outline: 0 that kills the focus outline.

9. Print is black and white

Rule: print styles must work in pure black and white and never assume color. There is one central print stylesheet — never a per-component @media print block.

Why: color print costs ink and is unreliable; one central sheet keeps print behavior in a single auditable place.

How to apply:

/* Wrong: color-dependent print rule scattered in a component file */
@media print { .badge { background: var(--color-warning); } }
/* Right: lives in the central print stylesheet, B&W only */
@media print { .badge { border: 1pt solid #000; background: none; } }

Enforcement: judgment — central-stylesheet placement and black-and-white safety are not statically checkable. Discipline, the vendored reference, and the print file overrides: ./print-and-legal.md and ../assets/print.css.

10. Co-located component CSS

Rule: a component’s styles live in a .css file next to the component, and every component folder contains a .css file even if it is currently empty.

Why: an empty file is an obvious, findable home — it stops one-off styles from leaking into global sheets.

How to apply:

components/
button/
button.tsx
button.css <- always present, even if empty

Enforcement: check_colocation.py flags a component folder with no co-located .css file. Layout and global structure: ./architecture.md.

11. Great comments

Rule: every CSS file opens with a header comment block, and non-obvious declarations carry a “why, not what” note.

Why: CSS is full of intentional-looking accidents; a one-line “why” is the difference between a fix and a regression.

How to apply:

/* ===================================================================
* card.css — surface container for grouped content
* Owns: padding, radius, surface color. Does NOT own outer margin.
* =================================================================== */
.card {
/* clip the decorative top accent to the rounded corner */
overflow: hidden;
}

Enforcement: judgment — comment presence and quality are not statically checkable. Comment standard: ./comments-and-naming.md.

12. Scope transitions

Rule: never transition: all. Name the exact properties that animate.

Why: all transitions properties you never meant to (including layout), causing jank and surprise repaints.

How to apply:

/* Wrong: animates everything, including layout */
.button { transition: all 150ms; }
/* Right: only what should move */
.button { transition: background-color 150ms, color 150ms; }

Enforcement: Stylelint’s declaration-property-value-disallowed-list rejects the keyword all on transition and transition-property and tells you to name the properties. Timing, easing, and reduced-motion: ../motion.md.