KwadMarket Docs
Conventions

Styling

The CSS Modules + design-token model that replaced Tailwind in apps/web and packages/ui

apps/web and packages/ui style with vanilla CSS in co-located CSS Modules — there is no Tailwind, no @apply, no utility compiler. This page is the reference for the model; apps/docs (this site) is unaffected and keeps its own Tailwind v4 setup.

Co-location

Every component with non-trivial styling gets a sibling stylesheet:

deal-card.tsx
deal-card.module.css
import { cx } from '@marketplace/ui'
import s from './deal-card.module.css'

function DealCard({ isSelected, className }: DealCardProps) {
  return <div className={cx(s.card, isSelected && s.cardSelected, className)} />
}
  • Import the module as s; access classes as s.foo — class names are camelCase for plain dot access (variantDefault, not variant-default).
  • A module may only be imported by its own sibling .tsx — this is what makes co-location a real invariant instead of a convention nobody checks. scripts/lint-styles.sh enforces it.
  • Conditional classes go through cx() (from @marketplace/ui), never template-literal interpolation:
// not this
className={`${s.base} ${isActive ? s.active : ''}`}

// this
className={cx(s.base, isActive && s.active)}

Design tokens

Defined once in apps/web/app/styles/tokens.css, ported verbatim from the old tailwind.config.ts scale:

Token familyExamplesNotes
Spacing--space-4, --space-2-5Full default scale, reified
Type--text-sm / --text-sm-lhSize + line-height pairs
Radius--radius-sm/md/lg/xl/2xl/3xl/full
Shadow--shadow-sm/md/lg/xl, --shadow-hard*
Border width--border-1 (hairline), --border-2
Color--c-primary, --c-destructive, --c-muted-foregroundResolved hsl(var(--x)) aliases over the raw HSL triplets
Z-index--z-*

Rule: var(--c-*) for opaque colors; hsl(var(--x) / a) only when you need alpha. Never a hex literal or bare rgb()/rgba()/hsla() on a color property — stylelint.config.mjs's intent is tokens-only, though the enforcement for this specific rule lives in scripts/lint-styles.sh as a ratchet (see Guardrails) because a handful of pre-Tailwind literal colors (badge variants, #fff icons) are grandfathered in rather than force-fixed as a side effect of the migration.

Breakpoints are custom media queries, not raw pixel values — custom properties don't work inside @media conditions, so postcss-custom-media resolves @media (--bp-lg) from the declarations in app/styles/media.css.

Cascade layers

Declared once, first thing, in packages/ui/src/styles/ui.css:

@layer reset, tokens, base, ui, globals, app;
  • packages/ui/**/*.module.css rules live in @layer ui.
  • apps/web/**/*.module.css rules live in @layer app.
  • app always beats ui regardless of selector specificity or chunk load order — this is what lets a caller's className safely override a primitive's own styling without !important or a runtime class-merge utility. Nothing may be unlayered: an unlayered rule beats every layer, silently defeating this model.

Motion

Shared @keyframes live in global stylesheets (packages/ui/src/styles/ui.css, apps/web/app/styles/animations.css) — never inside a .module.css. CSS Modules hashes @keyframes names and rewrites any animation-name/animation reference to match, even when no local @keyframes exists in that module — a module referencing a global keyframe by name directly fails silently.

The fix is a --motion-* indirection:

/* animations.css (global) */
:root {
  --motion-panel-drop: kwPanelDrop 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-panel-drop: kwPanelDrop 1ms linear both;
  }
}

/* some-component.module.css */
.panel {
  animation: var(--motion-panel-drop);
}

var() contents are opaque to css-loader, so nothing gets rewritten — and reduced-motion becomes a token override instead of a second class or a global selector list.

Custom properties are global

Unlike classes, --custom-property names are not scoped by CSS Modules. A component-local custom property (e.g. a value passed from JS) must be prefixed --kw-<name> to avoid colliding with another component's property of the same short name:

<li className={s.row} style={{ '--kw-row-index': index } as CSSProperties}>
.row {
  animation-delay: calc(180ms + var(--kw-row-index, 0) * 45ms);
}

This is the only legitimate use of inline style={{ }} — everything else is a module class. (app/global-error.tsx is the sole exception: it's Next's root error boundary, replacing <html>/<body> on a catastrophic client error, so it cannot depend on any stylesheet loading and is fully inline by necessity.)

The icon-utilities exception

A closed set of static, non-dynamic className strings — icon sizes and colors like className="h-4 w-4 text-primary" — are deliberately not converted to module classes, and instead exist as real, hand-authored CSS in apps/web/app/styles/utilities.css, reproducing the exact former-Tailwind class names verbatim:

@layer globals {
  .h-4 {
    height: var(--space-4);
  }
  .text-primary {
    color: var(--c-primary);
  }
}

This is not Tailwind residue: there's no compiler, no config, no arbitrary-value escape hatch. Every class is declared once, by hand, mapped to a token, and the full set is closed and audited — scripts/lint-styles.sh's Tailwind-residue check fails if a new, unaccounted-for Tailwind-shaped class shows up anywhere else. If you need a size/color combination not already in that file, either add it there (if it's truly a static icon/spacing utility) or give the component a real module class (if it's anything with actual component-specific styling).

className on @marketplace/ui primitives

Every primitive keeps className?: string — cascade layers make it safe, and removing it would mean reinventing prop-based styling for every layout need. The contract:

className positions a primitive in the caller's layout (margin, width, grid-area, order). It must not restyle the primitive's interior (colors, padding, typography, borders) — that needs a variant or a token.

Four multi-element primitives (DialogContent, Select, ScrollArea, Toast) accept a classes?: Partial<Record<Slot, string>> prop for targeting internal slots instead.

Guardrails

pnpm check:styles

Runs scripts/lint-styles.sh (7 checks) then stylelint:

  1. Tailwind-residue ratchet — any className token that looks like a Tailwind utility (bg-, px-, rounded-, flex, hover:…) and isn't one of the accounted-for icon-utility tokens. Baseline 0.
  2. No inline style={{ }} except --kw-* custom properties (and app/global-error.tsx).
  3. Orphaned modules — every *.module.css must have a sibling .tsx importing it.
  4. Cross-file module imports — a module may only be imported by its sibling.
  5. Dead classes — every class a module exports must appear as s.<name> in its sibling.
  6. Template-literal class conditionals — zero tolerance; use cx().
  7. Raw <input>/<textarea> in features (ratchet, baseline 25) + raw hex/rgb/hsla on color properties (ratchet, baseline 80 — pre-existing literal colors grandfathered in, may only decrease).

stylelint.config.mjs (repo root) enforces camelCase class selectors, --kw-*-prefixed custom properties, no-unknown-animations (catches the keyframe-rewrite footgun above), and that @layer/@custom-media/@container aren't flagged as unknown at-rules. It deliberately does not extend stylelint-config-standard — its formatting opinions (blank line before every rule, percentage alpha notation) conflict with this codebase's established style.

Both ratchet baselines in scripts/lint-styles.sh may only be lowered, never raised, as violations are cleaned up.

On this page