CSS Judgment Skill
CSS is not a combination of properties but a design of responsibility, language, and scope. Work with intent, not pixels.
Three lenses:
- Layout responsibility — who owns sizing, spacing, alignment
- Design language — does this follow the system's meaning units
- Blast radius — how far does this change reach
Phase split:
- Pre-work asset decisions → image-asset-strategy skill
- CSS judgment during work → this skill
- Post-work reference comparison → visual-reference-compare skill
1. Pre-edit Self-questions
If any answer is "I don't know," stop and read the code before writing.
Pre-flight: image-asset-strategy gate (must pass before CSS work)
If the task started from a design reference image / mockup / Figma URL and that reference contains any photographic or composite visual asset — human face, model figure, product photo, hero shot, editorial visual, 3D-rendered cluster, illustration, brand mark, or a custom-styled icon set — stop and run image-asset-strategy first. Do not begin writing layout / styling code, and especially do not type <svg> for any of those slots, before the asset catalog has been produced and the routing decision is made.
The default routing decision for assets is delegate to Codex via /codex:rescue (Codex owns imagegen). Placeholders are a fallback that must carry an explicit reason in the final report. Don't pre-emptively reach for the placeholder while you're still in the CSS skill.
If you find yourself already drawing a face with <circle> + <path>, or mocking a product photo with stacked SVG shapes, or replicating a custom icon style by hand — that's a sign this gate was skipped. Stop, revert that draft, and run image-asset-strategy now.
Sizing ownership (first CSS question)
Who decides this element's size?
- Outer-layout-driven (cards, grid cells, nav, screen areas) — content fits the container. Pattern: parent
padding+display: flex/grid, childrenflex: 1orgrid 1fr. Children that should be square useaspect-ratio: 1. Do not pin fixed px on the children. - Inner-content-driven (button labels, chips, text) — content size determines it. Children use
fit-content; parent only aligns viagap. - Scroll/carousel item — when the parent is a horizontal/vertical scroller and children appear N-at-a-time in the viewport, an intentional fixed size +
flex-shrink: 0is correct. Still keep the fixed value in a semantic token (--size-card-product) rather than raw px scattered in components.
Failing to make this decision leads to bugs like "fixed 48px child collides with container distribution and erases padding."
Layout context
- This element:
block/flex item/grid item/absolute? - External spacing ownership: parent
gap(default) vs childmargin(only clear exceptions like overlap) - Introducing
overflow/z-index/position? State the intent in one sentence. - Fill properties (
flex: 1,width: 100%,height: 100%) — have you confirmed the parent'sflex-direction? In a column parent,flex: 1expands vertically. margin-*: autoto push a child? Fine for small-area push. Large-area distribution (e.g., pushing a footer to the bottom) belongs in the parent viajustify-content: space-betweenorgrid-template-rows: auto 1fr auto— the child should not carry layout decisions.
Design language
- Can the value (color, spacing, size) be expressed via a semantic token? If not, via a component variant?
- Gradients / multi-stop color compositions are part of the design language too. Do not compose
linear-gradient(155deg, #ff9a4f 0%, #ff7a1a 55%, #e85a06 100%)at the call site each time — give gradients semantic tokens (--surface-card-citrus-gradient) or trap them inside variant CSS. - Neither works? That's a signal the system lacks expressive vocabulary. Don't apply CSS; propose a contract instead.
Blast radius
- Have you considered every call site this change touches?
- Are you modifying common components or layout primitives?
(Visual comparison belongs in the post-work visual-reference-compare skill.)
2. Core Principles
Parent owns spacing — sibling gaps via parent
gap. Child margin is allowed only for named exceptions: (a) overlap (stacked avatars withmargin-left: -10px), (b) fine push withmargin: auto(small alignment nudge — large-area distribution stays in the parent), (c) bleed pattern —margin: 0 calc(var(--space-md) * -1)on a horizontal-scroll row to break the parent's padding and run edge-to-edge. Anything else is a smell.Token hierarchy —
Component variant > Semantic token > Primitive token. Direct primitive use means proposing a new semantic.- Beyond spacing/color/radius, width/height/offset px for intended widget intrinsic sizes (touch target, icon size) should default to semantic tokens (
--size-touch-min,--size-icon-md). Clear exceptions like 1px borders or -1px overlaps are fine. - Gradients / multi-stop colors belong in tokens or variant CSS — no composition at call sites.
- Beyond spacing/color/radius, width/height/offset px for intended widget intrinsic sizes (touch target, icon size) should default to semantic tokens (
State intent when changing layout properties —
display/position/overflow/z-indexonly when you can name the intent in one sentence.Fill properties are a patch signal — find the real cause before stamping
width: 100%,flex: 1, orheight: 100%. The #1 cause of flex items refusing to shrink is a missingmin-width: 0.Preserve the box model — declared
padding/border/marginmust remain. When child total size exceeds container inner width, padding gets visually consumed. Don't shrink children to fit; redesign with relative distribution (flex:1 / grid 1fr).Don't fake photographic assets or custom-styled icons with raw SVG — faces/photos/composite illustrations/custom icon families drawn from circles and paths become awkward placeholders. Asset decisions go to image-asset-strategy, whose default route is delegate to Codex via
/codex:rescue(Codex owns theimagegenpipeline). Only canonical trivial UI glyphs (search, home, menu, chevron, well-known social logos) stay as inline SVG.Report system-level problems — repeated patterns, missing semantics, one-context-only components: don't keep stamping CSS, propose a token/variant/contract addition.
CSS unit & sizing rules — unit choice is accessibility, not aesthetics. The browser default font-size and user zoom must remain usable. Ten rules, in priority order:
font-sizedefaults torem— never lock body text inpx.remlets users scale via the browser default font-size and is the foundation for everything else listed here.line-heightis a unitless number —1.5,1.7. Unitless multiplies the element's ownfont-size; usingrem/px/emhere locks line-height to a snapshot of the parent and breaks responsive type.- Global spacing tokens (
--space-*) are inrem—padding,margin,gapall scale with the user's chosen base size. A--space-md: 1remtoken is correct;16pxis not. - Component-internal
paddingmay useemwhen the padding should grow with the text inside it (e.g., a chip's horizontal padding tracking its label size). This is the rare allowedem; default is stillrem. border/divider/hairlineusepx— 1px must remain 1 device pixel;remborders thicken when users scale up, which is visually wrong.border-width,outline, hairlinebox-shadow/insetrings stay inpx.- Avoid fixed
height— prefermin-height+ content wrapping. A fixedheightcollides with longer text / larger fonts / user zoom;min-heighthonors the floor while letting content grow. - Media-query breakpoints default to
em—@media (min-width: 48em), not768px.em-based breakpoints respect the user's font-size scaling, so a user at 125% browser font-size hits the breakpoint at the proportionally larger viewport — what they actually want. - Mobile viewport height: prefer
svh/dvhovervh— bare100vhon mobile measures the largest possible viewport (toolbar collapsed), causing the bottom of the page to sit behind the URL bar on first load.svh(small) /dvh(dynamic) /lvh(large) are the modern fix; default tosvhfor first-paint correctness ordvhif the layout should breathe as the toolbar collapses. html { font-size }defaults to100%— do not pin it to16pxor62.5%. Pinning erases user preference. The62.5%trick (1rem = 10px math convenience) costs the very accessibility thatremwas meant to give you; don't take it.- Verify three accessibility states before declaring layout done: 320px width (smallest common viewport — WCAG reflow requirement at 400% zoom on 1280px), 200% browser zoom, and browser-default font-size cranked up (Chrome / Firefox
chrome://settings/about:preferences"Default font size: Very large"). If any of these breaks the layout, the unit choices above were violated somewhere.
Use this hierarchy as your default; the named exceptions in rules 4-5 are the only places
em/pxshould appear whenremcould have worked.
3. Anti-patterns (stop on sight)
- ❌ Faking a photographic asset or custom icon with raw SVG primitives —
<svg><circle><rect><path>arrangements to stand in for a human face / model figure / product photo / hero image / editorial moodshot / 3D object cluster / illustration / brand mark / custom-styled icon set. Route toimage-asset-strategy→ Codex via/codex:rescue. This anti-pattern is the most frequent cause of session restarts in UI work; do not even draft "a quick SVG version for now." - ❌ Child
margin-bottom/margin-rightfor sibling spacing (margin: autofor fine push and bleed pattern —margin: 0 calc(var(--space-md) * -1)to break parent padding for a full-bleed row — are the named exceptions; large-area distribution still belongs in the parent) - ❌ Arbitrary px (
padding: 7px,width: 250px,top: -22px) — raw px scattered without semantic tokens - ❌ One-off primitive token combinations (
var(--space-3) var(--space-5)) - ❌ Multi-stop gradient hex composition at the call site (
linear-gradient(155deg, #ff9a4f 0%, #ff7a1a 55%, #e85a06 100%)) - ❌ Intentionless
flex: 1— stamping it without checking parentflex-direction - ❌ Fixed-size children +
space-around/space-between+flex-shrink: 0→ container overflow → padding erased - ❌
width: 100%/height: 100%patching a layout problem - ❌
gap+justify-content: space-aroundsimultaneously (distribution conflict) - ❌
margin: autofor large-area distribution (footer-to-bottom push) — use parentjustify-contentorgrid-template-rows - ❌ Raw
z-indexnumbers, intentionlessoverflow: hidden,!important - ❌ Changing
display/positionon shared components - ❌ Bypassing a semantic token with a primitive
- ❌ Unifying text/UI density in px (rem territory) or 1px hairlines in rem (px territory)
- ❌ Grid item without
min-width: 0when an inner child can be wider than the track (long text,<pre>code block, image, mono-spaced URL). The grid track refuses to shrink → the whole grid overflows the viewport, cropping the right edge silently. Same root cause as Example 5 (flex), but the symptom is "section is wider than viewport" instead of "child won't shrink." - ❌
align-items: stretch(default) + sibling-with-shorter-content in grid/flex → the shorter card stretches to the taller sibling and grows an awkward empty band of background. Either let the shorter card keep its intrinsic height (align-self: start) or rebalance content so both sides are visually full. - ❌ Choosing the responsive breakpoint by the wrong child — a 2-col grid where one child is a long code block needs a wider breakpoint than a 2-col grid where both children are short text. A single "all 2-col layouts collapse at 900px" rule will leave the heavier layout broken between 900 and ~1024px. Decide per-layout, not per-skill.
- ❌ Forgetting to reset browser-default
<ol>/<ul>styling when using them as design containers —<ol>ships withpadding-inline-start: ~40px+ numeric markers,<ul>ships withpadding-inline-start: ~40px+ disc markers. If you're treating the list as a flex/grid card stack (markers replaced by your own indices, alignment owned by the parent), you must reset:margin: 0; padding: 0; list-style: none;. Symptom: card stack pushed inward by ~40px on the leading edge, no apparent rule to point at — because it's a UA default, not your stylesheet. - ❌ Flex column scroll-container with content overflow → siblings get squashed — a scroll container declared as
display: flex; flex-direction: column; overflow: autowill let the defaultflex-shrink: 1collapse short-but-important children (a hero, a sticky header) down to their min-content height once the total content exceeds the container. The hero looks fine in dev when content is short, then "disappears" (collapses to 36px) once you add the rest of the page. Fix: mark every non-scrolling child withflex-shrink: 0, or switch the container todisplay: block+min-heightif scroll is the only reason flex was chosen. - ❌ Sticky element without an explicit
z-index—position: stickydoes not create a stacking context that floats above siblings by default. Once a later section (a banner, a modal-like card, a toast) introduces its own stacking context or its own positioned children, the sticky nav silently slides under them. Always pairposition: stickywithz-index: <value above any sibling stacking context>. - ❌ Introducing a sticky / fixed bar without reserving content padding for it — adding a sticky bottom nav of height 64px without adding
padding-bottom: 64px(or its safe-area equivalent) to the scroll content means the last screenful of content sits under the nav and is permanently hidden. The bug rarely shows up in dev because designers always scroll to the top. Same trap with sticky top headers and the first viewport. - ❌ Fixed
heighton content containers —height: 80pxon a card / header / button forces longer text or larger user font-size to clip or overflow. Usemin-height+ content wrapping instead. The single exception is when the element is genuinely a fixed-pixel widget (icon, avatar, hairline) whose visual identity is the size. - ❌ Media-query breakpoints in
px—@media (max-width: 768px)ignores user font-size scaling. A user at 125% browser font-size has effectively a smaller viewport in content units, but a px breakpoint doesn't know that. Useembreakpoints (@media (max-width: 48em)); they respect the user. - ❌
100vhon mobile layouts —vhmeasures the largest possible viewport (mobile toolbar collapsed), so the bottom of the page is hidden behind the browser UI on first load. Prefersvh(small / first-paint correct) ordvh(dynamic / breathes with the toolbar). Reserve barevhfor desktop or with explicit fallback (min-height: 100vh; min-height: 100svh). - ❌ Locking
html { font-size }to16pxor62.5%— erases user font-size preference. The62.5%"1rem = 10px" trick costs the very accessibility thatremwas supposed to give you. Default to100%. If aremvalue math is awkward, fix the value, not the root. - ❌ Shipping without checking 320px width / 200% zoom / browser font-size up — these are the three accessibility tripwires for the unit rules above. If you didn't see the layout in those states, the unit choices were assumptions, not decisions.
4. Examples
Example 1 — Misjudged sizing ownership
A card holds three icon buttons. Children pinned at width: 44px; flex-shrink: 0, parent at padding: 16px; space-around; gap: 12px. The card's inner width (141px) is smaller than children+gap (156px) → children overflow right → right padding visually becomes 0.
Bad
.card { padding: 16px; display: flex; justify-content: space-around; gap: 12px; }
.iconButton { width: 44px; height: 44px; flex-shrink: 0; }
Good (box model preserved; children fit the container)
.card { padding: 12px; display: flex; align-items: center; gap: 4px; }
.iconButton { flex: 1; aspect-ratio: 1; /* parent decides size */ }
Judgment: "Card design is the anchor" → outer layout owns sizing → children get flex: 1 + aspect-ratio: 1, not fixed px.
Example 2 — margin: auto for large-area distribution
Bad
.screen { display: flex; flex-direction: column; }
.navHolder { margin-top: auto; } /* child carries the layout decision */
Good
.screen { display: grid; grid-template-rows: auto 1fr auto; }
/* or display: flex + justify-content: space-between */
Judgment: Pinning the footer to the bottom is a layout decision → express it in the parent.
Example 3 — List item gap (parent gap)
Bad
<Item style={{ marginBottom: 12 }} />
<Item style={{ marginBottom: 12 }} />
<Item /> {/* :last-child cleanup — patch-on-patch */}
Good
<List gap="md"><Item /><Item /><Item /></List>
Example 4 — Composing a gradient at the call site
Bad
.card[data-surface='citrus'] {
background: linear-gradient(155deg, #ff9a4f 0%, #ff7a1a 55%, #e85a06 100%);
}
.card[data-surface='mango'] {
background: linear-gradient(155deg, #fcd35e 0%, #f7b32b 55%, #d88d11 100%);
}
Good
:root {
--surface-card-citrus: linear-gradient(155deg, #ff9a4f 0%, #ff7a1a 55%, #e85a06 100%);
--surface-card-mango: linear-gradient(155deg, #fcd35e 0%, #f7b32b 55%, #d88d11 100%);
}
.card[data-surface='citrus'] { background: var(--surface-card-citrus); }
.card[data-surface='mango'] { background: var(--surface-card-mango); }
Judgment: Gradients are part of the design language. Reserve the chance to reuse the same tone elsewhere; keep it tokenized.
Example 5 — flex / grid item refusing to shrink
Default min-width: auto on flex and grid items lets the item demand the intrinsic width of its widest descendant. A long URL, a <pre> block, an <input>, a mono-spaced API path — any one of them can pin the item open, forcing the whole layout wider than the viewport.
Bad (flex symptom)
.child { width: 100%; } /* symptom patch */
.parent { overflow: hidden; } /* hides it by clipping */
Bad (grid symptom — same root cause)
.layout {
display: grid;
grid-template-columns: 1fr 1fr;
}
.layout > .code-column pre { /* long code block */ }
/* → section overflows viewport on smaller screens; right edge cropped */
Good (both)
.layout > * { min-width: 0; }
The fix is one line. The detection is the hard part — right edge cropped or horizontal scroll appearing at unexpected widths is the symptom; check grid/flex children for min-width: 0 first, before reaching for overflow-x: hidden (which only hides the bug).
Example 8 — Grid stretch + short sibling = empty band
A hero is display: grid; grid-template-columns: 1.2fr 1fr; align-items: stretch (the default). The left column has a long headline + paragraph + buttons. The right column is a small quote card with three lines. Stretch makes the card grow to match the left column → an awkward empty background band appears below the quote.
Bad
.hero {
display: grid;
grid-template-columns: 1.2fr 1fr;
/* align-items: stretch is the default — card stretches and gains empty space */
}
Good — option A (let the smaller card keep its intrinsic height)
.hero { display: grid; grid-template-columns: 1.2fr 1fr; align-items: start; }
.heroCard { align-self: start; } /* explicit, even if redundant with align-items */
Good — option B (rebalance: push the card's footnote to the bottom so the empty space becomes a deliberate gap)
.heroCard { display: flex; flex-direction: column; }
.heroCardFootnote { margin-top: auto; }
Good — option C (the empty space is real because the right card is too thin — add a second piece of content there: a signature, a related link, a date stamp). Layout choices are content choices.
Judgment rule: when one child of an align-items: stretch parent is consistently shorter, ask "is the gap because of layout default or because the content is genuinely missing?" before patching.
Example 10 — Flex column scroll-container squashing a child
A mobile-shaped page uses an inner scroll container for the hero / list / banners. Initial dev shows a tall hero. Add more sections; reload. The hero is now 36px tall — squashed — because:
Bad
.scroll {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
}
/* hero, list, banner are all flex items with the default flex-shrink: 1 */
Once total content height > container height, every flex item gets pro-rata shrunk. The hero, which has no flex-shrink: 0, collapses first.
Good
.scroll {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
}
.hero, .stickyHeader { flex-shrink: 0; } /* must keep intrinsic height */
.list { /* this one is the one that should shrink/grow */ }
Or, if scroll was the only reason for flex:
.scroll { display: block; overflow-y: auto; height: 100%; }
Judgment rule: a scroll container made of display: flex; flex-direction: column opts every child into mutual shrinking — make the opt-out explicit on every child that must hold its own height.
Example 11 — Sticky bar without z-index / without content reserve
A bottom nav: position: sticky; bottom: 0;. Looks fine on a calm page. Later a NEW promo banner with its own transform or position: relative is added mid-page. Now scrolling brings the banner over the nav — the banner has an implicit stacking context that beats the unspecified z-index: auto of the nav.
Bad
.bottomNav { position: sticky; bottom: 0; }
.promoBanner { transform: translateZ(0); /* or any positioned child */ }
Good — two fixes, both needed
.bottomNav {
position: sticky;
bottom: 0;
z-index: 20; /* above any sibling stacking context */
}
.scrollContent {
padding-bottom: calc(64px + env(safe-area-inset-bottom));
/* reserve the nav's height so the last section isn't trapped under it */
}
Judgment rule: every position: sticky / position: fixed introduces two debts — declare z-index, and reserve space at the opposite edge so content isn't permanently hidden.
Example 9 — Per-layout breakpoint, not per-skill breakpoint
A landing page has four display: grid 2-column layouts: hero (1.2fr 1fr, both short), risk matrix (1fr 1fr, both short cards), skill structure (1.05fr 1fr, one column is a long code block), conclusion (1fr 1fr, both short). Defining one @media (max-width: 900px) { all-of-them: 1fr } looks tidy but leaves skill-structure broken between 900px and ~1024px — the code column overflows because it cannot shrink below its intrinsic content width.
Bad (one breakpoint for all)
@media (max-width: 900px) {
.hero, .riskGrid, .skillLayout, .conclusion { grid-template-columns: 1fr; }
}
Good (the heavier layout collapses earlier)
@media (max-width: 1024px) {
.skillLayout { grid-template-columns: 1fr; } /* contains a long code block */
}
@media (max-width: 900px) {
.hero, .riskGrid, .conclusion { grid-template-columns: 1fr; }
}
Judgment rule: breakpoints follow content, not design consistency. If you reuse one breakpoint, the layout with the heaviest column will silently break first.
Example 6 — Face avatar (visual asset)
Bad: <svg> with <circle> + <path> mimicking a face → an awkward placeholder.
Good: route through image-asset-strategy. From Claude Code, delegate to Codex via /codex:rescue (Codex owns imagegen and will produce + wire the asset). From inside Codex, invoke imagegen directly. Placeholder + report only if Codex routing is genuinely blocked (user veto, unauthenticated setup) — and surface the reason explicitly.
Example 7 — Custom-styled icon set
A nav has 5 icons in a duotone peach/coral style that matches the design's brand language.
Bad: hand-drawing each icon with <path d="..."> from memory, trying to approximate the duotone effect with two stacked <path> elements per icon.
Good: catalog all 5 icons in image-asset-strategy as a single asset slot ("custom nav icon set, 24x24, duotone peach/coral, matching reference style"), then batch-delegate to Codex via one /codex:rescue call. The set will be coherent because it was generated together; piecemeal hand-drawing tends to drift between icons.
5. Verification
After edits, you should be able to answer:
- Reuse safety — does the component still behave correctly placed in another container?
- State matrix — hover/focus/disabled/loading/empty handled consistently?
- Token hygiene — every value (spacing/color/gradient/size) expressible as variant or semantic? No raw scatter?
- Blast radius — no visual regression at any importing call site?
- Accessibility tripwires (the unit hierarchy in §2.8 manifests here) — three states you must actually observe before declaring done:
- 320px viewport width — the WCAG reflow floor (content must remain readable / interactive at 400% zoom on a 1280px screen, which collapses to ~320 CSS px). Cards stack, no horizontal scroll, hit targets still ≥ 44px.
- 200% browser zoom — Cmd/Ctrl+
+until 200%. Does layout reflow gracefully or does anything clip, overflow, or break out of its container? - Browser-default font-size cranked up — Chrome
chrome://settings/ Firefoxabout:preferences→ set "Default font size" to Very large.rem-based layouts should grow;px-locked layouts will visibly fail. This is the test that catches violated unit rules.
For visual / measurement comparison against a reference image, hand off to visual-reference-compare.
6. Reporting
Lead with what you decided, not what you changed.
Decision: <sizing ownership / responsibility shift / variant addition / etc.>
Why: <why not the alternatives>
Changes: <files + core change>
Verification: <viewports / states / measurements>
Outstanding: <placeholder gaps / follow-ups / concerns>
Always list placeholder gaps under "Outstanding".
7. Skip conditions
- The user gave a precise file/line directive that doesn't conflict with these principles.
- Pure typo / string-only edits unrelated to visual or layout concerns.
- Drawing a simple UI icon already registered in the design system (the ✅ case in principle 6).