Skip to content

DraftForge Theming Guide

Theme Overview

Visual Vibe: Neon Cyber Esports | Dota-style Draft UI | Dark Mode First

This theme uses a violet/indigo primary palette with cyan accents for an esports-focused, competitive gaming aesthetic.

Location: frontend/app/app.css


Color Palette

Primary (Brand) - Violet-to-Blue Gradient

Token Tailwind Classes Usage
--primary bg-primary (violet-400 dark) Base brand color: focus rings, links, badges
brandGradient bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-400 hover:to-blue-400 text-white Primary CTA buttons
brandSecondary bg-gradient-to-r from-violet-500/30 to-blue-500/20 ring-1 ring-violet-300/60 text-violet-100 hover:from-violet-500/40 hover:to-blue-500/30 Supporting/contextual actions

Primary CTA buttons use the brand gradient via <PrimaryButton>, not flat bg-primary. The --primary CSS variable remains the base brand color for non-button contexts.

// Primary CTA (brand gradient + 3D depth)
<PrimaryButton>Create Tournament</PrimaryButton>
<PrimaryButton depth={false}>Flat Variant</PrimaryButton>

// Secondary brand action (violet gradient + ring outline)
<SecondaryButton>Edit Settings</SecondaryButton>

Secondary - Indigo

Variable Tailwind Usage
--secondary indigo-500 Links, tabs, secondary actions
--secondary-hover indigo-600 Hover states
<a className="text-secondary hover:text-secondary-hover">Link</a>

Accent - Sky/Cyan (Esports Flair)

Variable Tailwind Usage
--accent sky-400 Success glow, emphasis
--interactive cyan-400 Interactive elements, hover effects
<span className="text-accent">Featured</span>
<button className="hover:bg-interactive">Hover me</button>

Background Scale (Slate)

Class Value Usage
bg-base-950 slate-950 Deepest background
bg-base-900 slate-900+ Page backgrounds
bg-base-800 slate-800 Section backgrounds
bg-base-700 Recessed panels
bg-base-600 Deep cards
bg-base-500 slate-900 Cards, elevated surfaces
bg-base-400 Hover backgrounds
bg-base-300 slate-800 Containers, modals
bg-base-200 Cards, dropdowns
bg-base-100 slate-700 Navbar, elevated elements
bg-base-50 Brightest elevated surface

Dark Mode Logic: Lower numbers = brighter (elevated), higher numbers = darker (recessed)


Text Colors

Class Value Usage
text-foreground slate-100 Primary text
text-text-secondary slate-300 Secondary text, subtitles
text-muted-foreground slate-400 Muted/tertiary text
text-link indigo-500 Links

Status Colors (Intentional Accents)

Use accents intentionally - don't scatter random colors.

Status Color Class Usage
Success emerald-400 text-success, bg-success Match won, completed
Warning amber-400 text-warning, bg-warning Achievements, rankings
Error (text/badges) rose-500 text-error, bg-destructive Inline errors, delete buttons
Error (surfaces) red-900/violet-900 brandErrorBg Error containers — muted wine gradient
Error (cards) red-900/60 brandErrorCard Error cards within containers
Info sky-400 text-info, bg-info Informational callouts
Selection violet-500 bg-selection Selected items
// Status examples
<span className="text-success">Match Won</span>
<span className="text-warning">1st Place</span>
<span className="text-error">Connection Lost</span>
<Badge className="bg-info">New Feature</Badge>

Error surfaces use raw Tailwind colors (red-900, violet-900) instead of semantic --error tokens. This is intentional: error containers need deep wine/muted tones to maintain the "muted surfaces + bright accents" pattern. The semantic rose-500 error token is for bright inline accents.


Gradient Utilities

Button Gradients

// Standard button gradient (violet to indigo)
<button className="gradient-button">Action</button>

// Glow button gradient (purple-violet-indigo)
<button className="gradient-button-glow">Premium Action</button>

Background Gradients

// Subtle page gradient (slate-950 to slate-900)
<div className="gradient-bg-subtle min-h-screen">

// Hero banner with violet glow
<section className="gradient-hero">

Glow Effects

// Radial glow backgrounds
<div className="gradient-glow-violet">  {/* Violet glow */}
<div className="gradient-glow-cyan">    {/* Cyan glow */}

// Card hover glow
<div className="glow-hover">Card with hover glow</div>

// Neon border
<div className="border border-glow">Glowing border</div>

Text Glow

<h1 className="text-glow">Glowing Text</h1>
<h1 className="text-glow-violet">Violet Glow</h1>
<h1 className="text-glow-cyan">Cyan Glow</h1>

Brand Button System

Style Constants

All brand style constants are exported from ~/components/ui/buttons:

Constant Tailwind Classes Usage
brandGradient bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-400 hover:to-blue-400 text-white Primary CTA color
brandSecondary bg-gradient-to-r from-violet-500/30 to-blue-500/20 ring-1 ring-violet-300/60 text-violet-100 hover:from-violet-500/40 hover:to-blue-500/30 Supporting actions
brandErrorBg bg-gradient-to-r from-red-900/40 to-violet-900/40 border border-red-500/20 Error container surfaces
brandErrorCard bg-red-900/60 border border-red-500/15 Error cards
brandSuccessBg [background-image:var(--brand-success-bg)] Success dialog surfaces (confirm dialogs)
brandSecondaryOpaque bg-violet-950 border border-violet-400/30 text-violet-100 hover:bg-violet-900 Secondary on colored backgrounds (dialogs)
brandBg [background-image:var(--brand-bg)] Subtle surface background (default on dialogs)
brandGlow shadow-brand-glow Brand glow shadow
brandToxic bg-gradient-to-br from-violet-700 via-emerald-800 to-emerald-700 hover:from-violet-600 hover:via-emerald-700 hover:to-emerald-600 text-white Cyberpunk "toxic ooze" edit affordance — violet-700 → emerald-800 → emerald-700 diagonal blend (same palette family as brandHighlight, deeper and smoothly mixed). Used by <EditButton> / <EditIconButton>.
brandToxicDepthColors border-b-emerald-900 shadow-emerald-950/60 3D depth + shadow tuned to the toxic gradient (deep-emerald glow on press)

Brand Surface Background (brandBg)

All Dialog and AlertDialog content areas automatically include the brandBg gradient overlay. This provides a subtle violet-to-accent gradient on top of the solid bg-background base color.

Why CSS variables? brandBg uses [background-image:var(--brand-bg)] instead of Tailwind's bg-gradient-to-* utilities. This is critical because tailwind-merge treats bg-gradient-to-* and bg-{color} as the same CSS group — so a gradient would silently strip bg-background from the dialog, making it translucent. Using the arbitrary property [background-image:...] sets only background-image (the gradient overlay) without touching background-color, so both coexist.

The same pattern is used by brandSuccessBg for success dialog surfaces.

// brandBg is automatic on Dialog/AlertDialog — no extra class needed
<DialogContent>...</DialogContent>

// For custom surfaces, import and apply manually
import { brandBg } from '~/components/ui/buttons/styles';
<div className={cn("bg-background", brandBg)}>Branded surface</div>

Dialog Widths

The shadcn DialogContent default is sm:max-w-lg (32rem). That's right for decision dialogs (one question, two buttons), but too narrow for form-heavy modals with multiple sections — they wrap awkwardly or spawn a horizontal scrollbar inside the modal.

Dialog kind Width Examples
Form-heavy (edit/signup/create with multiple sections) sm:max-w-2xl EditProfileModal, EventSignupModal, CreateEventModal
Decision (single question, two buttons) default (sm:max-w-lg) — no override ConfirmDialog, AlertDialog
Wide picker / table / chart (rare) sm:max-w-3xl+ with a justification comment bracket pickers, large team rosters
// Form-heavy modal — bump to max-w-2xl
<DialogContent className="flex max-h-[90vh] flex-col sm:max-w-2xl">...</DialogContent>

// Decision dialog — keep the default
<AlertDialogContent>...</AlertDialogContent>

Scrollable body. When a form-heavy modal's content exceeds max-h-[90vh], use <ScrollArea> from ~/components/ui/scroll-area, not raw overflow-y-auto. The shadcn ScrollArea wraps the content in a Radix Viewport with a brand-styled thin scrollbar that matches the rest of the app; the chunky OS-default scrollbar inside an otherwise-themed dialog reads as visual drift.

// Inside DialogContent, when the body scrolls:
<ScrollArea className="flex-1 min-h-0 -mx-6 px-6">
  <div className="flex flex-col gap-4 pb-4"></div>
</ScrollArea>

The -mx-6 px-6 cancels the dialog's p-6 for the viewport so the scrollbar sits flush against the edge without chopping content.

See theming-guide/ai/references/scrollbars-dialogs.md for the full ScrollArea-inside-Dialog contract (the overflow-hidden rule, why Radix Viewport's size-full needs a definite parent height, and how to verify the dialog actually clips).

3D Depth Effects

Buttons support a depth prop for 3D press effects:

// 3D button (default for PrimaryButton)
<PrimaryButton>Create</PrimaryButton>

// Flat button
<PrimaryButton depth={false}>Create</PrimaryButton>

The 3D system uses button3DBase (shadow, border-b-4, active press) and button3DDisabled (muted disabled state).

Button Policy

All new buttons should use PrimaryButton or SecondaryButton unless there is a specific reason not to (e.g., icon-only buttons, dropdown triggers, shadcn component internals).

  • PrimaryButton: Guides the user toward the most important action on screen — the button they're most likely to click. There should typically be one primary action per view/section.
  • SecondaryButton: Everything else — supporting actions, cancel/back, contextual options.

Do not use <Button> directly for user-facing actions. Reserve <Button> for structural/internal uses only (dropdown triggers, combobox triggers, shadcn wrappers).

Button Selection Guide

Context Component Visual
Main CTA / most-clicked action <PrimaryButton> Brand gradient + 3D
Form submission <SubmitButton> Brand gradient + 3D
Dialog confirmation <ConfirmButton variant="success"> Brand gradient + 3D
Destructive dialog action <ConfirmButton variant="destructive"> Red + 3D
Warning dialog action <ConfirmButton variant="warning"> Orange + 3D
Supporting/contextual action <SecondaryButton> Violet gradient + ring
Cancel/back/dismiss <SecondaryButton> or <CancelButton> Translucent violet
Colored contextual action <SecondaryButton color="sky"> Colored background + 3D
Navigation action <NavButton> Sky blue + 3D
Edit action <EditButton> / <EditIconButton> Toxic violet→emerald cyberpunk blend + 3D
Destructive page action <DestructiveButton> Red + 3D

PrimaryButton, SubmitButton, and ConfirmButton variant="success" share the brand gradient visual. Choose based on HTML semantics and context, not appearance.


Border Colors

Class Usage
border-border Standard borders (slate-700)
border-glow Glowing violet borders
ring-ring Focus rings (violet-600)

Component Patterns

ALWAYS use <EntityBreadcrumb> on detail pages (organization, league, event, series, tournament). Never build manual breadcrumb nav elements.

import { EntityBreadcrumb, type BreadcrumbSegment } from '~/components/ui/entity-breadcrumb';

// Entity types: 'organization' | 'league' | 'event-series' | 'event' | 'tournament'
// Each segment shows a small type label above the name

<EntityBreadcrumb segments={[
  { type: 'organization', label: 'DTX', href: '/organizations/1' },
  { type: 'league', label: 'DTX League', href: '/leagues/1' },
  { type: 'tournament', label: 'Weekly Inhouse #12' },
]} />

Renders as a hierarchical breadcrumb with type labels (e.g., "ORGANIZATION" above "DTX"). The last segment is non-clickable (current page). Uses the shadcn Breadcrumb primitives internally.

Pages that must have breadcrumbs: - /organizations/:id — Organization - /leagues/:id — Organization → League - /events/:id — Organization → Event - /event-series/:id — Organization → Event Series - /tournament/:pk — Organization → League → Tournament - /rollcall/:eventId — Organization → Event → Roll Call

Cards with Depth

<div className="bg-base-300 border border-border rounded-lg p-4">
  <h3 className="text-foreground">Card Title</h3>
  <p className="text-muted-foreground">Description</p>
</div>

// With hover glow
<div className="bg-base-300 border border-border rounded-lg p-4 glow-hover">

Buttons

// Primary CTA (brand gradient + 3D depth)
<PrimaryButton>Create Tournament</PrimaryButton>
<PrimaryButton size="lg">Large CTA</PrimaryButton>

// Form submission
<SubmitButton loading={isSubmitting}>Save Changes</SubmitButton>

// Dialog confirmation
<ConfirmButton variant="success">Approve</ConfirmButton>
<ConfirmButton variant="destructive">Delete</ConfirmButton>

// Secondary (default brand violet gradient + ring)
<SecondaryButton>Settings</SecondaryButton>

// Secondary with colored background + 3D
<SecondaryButton color="cyan" depth>Colored Action</SecondaryButton>

// Avoid using <Button> directly for user-facing actions.
// Reserve for structural uses: dropdown triggers, combobox triggers, etc.

Brand Select

User-facing value pickers (draft order, roles, MMR bracket, sort key) use the brand-styled Select family from ~/components/ui/brand-select, not the bare shadcn <Select> primitive. The brand version maps the trigger onto bg-base-300 with a violet hairline, renders the popover content on the brand violet-glow surface, and highlights items with the brandSecondary gradient on focus / hover.

import {
  BrandSelect,
  BrandSelectContent,
  BrandSelectItem,
  BrandSelectTrigger,
  SelectValue,
} from '~/components/ui/brand-select';

<BrandSelect value={order} onValueChange={setOrder}>
  <BrandSelectTrigger size="sm" className="w-9 justify-center">
    <SelectValue placeholder="—" />
  </BrandSelectTrigger>
  <BrandSelectContent>
    {options.map((o) => (
      <BrandSelectItem key={o.value} value={o.value}>{o.label}</BrandSelectItem>
    ))}
  </BrandSelectContent>
</BrandSelect>

Sizes: size="default" (h-9, matches PrimaryButton), size="sm" (h-8, for tight columns like the UserStrip actionSlot). SelectValue, SelectGroup, SelectLabel, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton are re-exported unchanged.

When to use the bare shadcn primitive: only when the surrounding surface already paints its own treatment that the brand select would clash with (rare — and document the exception inline next to the call site).

// Standard link
<a className="text-secondary hover:text-secondary-hover">Link</a>

// Using utility class
<a className="text-link">Auto-hover Link</a>

Status Indicators

// Win/Loss
<span className="text-success font-bold">WIN</span>
<span className="text-error font-bold">LOSS</span>

// Ranking badge
<span className="bg-warning text-warning-foreground px-2 py-1 rounded">
  1st
</span>

// Info callout
<div className="bg-info/10 border border-info text-info p-3 rounded">
  Draft starts in 5 minutes
</div>

User Avatars

ALWAYS use <UserAvatar> component for displaying user avatars. Handles Discord CDN, fallback generation, online indicators, and multiple sizes.

import { UserAvatar } from '~/components/user/UserAvatar';

// From a UserType object
<UserAvatar user={user} size="md" />

// From partial data (e.g., DM recipient)
<UserAvatar user={{ nickname: "Player", discordId: "123" }} size="sm" />

// With online indicator
<UserAvatar user={user} size="lg" showOnline online />

// Captain border
<UserAvatar user={captain} size="md" border="captain" />

// Direct URL
<UserAvatar src="https://cdn.discordapp.com/avatars/..." size="sm" />

Sizes: tiny (16px) | xs (20px) | sm (24px) | md (32px) | lg (40px) | xl (48px)

Borders: none | primary (violet ring) | muted (subtle) | captain (gold ring)

DO NOT use <img> with AvatarUrl() directly — use <UserAvatar> which wraps AvatarUrl with proper loading states, fallback initials, and memoization.


The navbar uses bg-base-100 for elevation:

<nav className="bg-base-100 shadow-sm">
  {/* Subtitle text uses text-text-secondary for visibility */}
  <span className="text-text-secondary">Subtitle</span>
</nav>

Quick Reference

Most Used Classes

// Backgrounds
bg-base-900           // Page background
bg-base-300           // Cards, containers
bg-base-100           // Navbar, elevated
bg-primary            // Primary buttons
bg-secondary          // Secondary elements

// Text
text-foreground       // Primary text
text-muted-foreground // Muted text
text-text-secondary   // Subtitles, secondary
text-primary          // Brand color text
text-interactive      // Cyan accent text

// Status
text-success          // Green
text-warning          // Amber/gold
text-error            // Red
text-info             // Sky blue

// Effects
gradient-button       // Button gradient
glow-hover            // Hover glow effect
text-glow-violet      // Glowing text
border-glow           // Neon border

// Brand tokens (import from ~/components/ui/buttons)
brandGradient          // Primary CTA gradient (violet-to-blue)
brandSecondary         // Violet gradient + ring for secondary actions
brandErrorBg           // Muted wine error container
brandErrorCard         // Dark red error card
brandSuccessBg         // Success dialog surface gradient
brandSecondaryOpaque   // Opaque violet for colored backgrounds
brandBg                // Subtle brand surface gradient
brandGlow              // Brand glow shadow

Color Values (oklch)

Name oklch Approx Hex
violet-600 (primary) oklch(0.541 0.251 293) #7c3aed
indigo-500 (secondary) oklch(0.585 0.233 277) #6366f1
cyan-400 (interactive) oklch(0.789 0.154 212) #22d3ee
sky-400 (accent) oklch(0.746 0.16 233) #38bdf8
slate-950 (bg) oklch(0.129 0.042 265) #020617
emerald-400 (success) oklch(0.765 0.177 163) #34d399
amber-400 (warning) oklch(0.82 0.164 84) #fbbf24
rose-500 (error) oklch(0.645 0.246 16) #f43f5e

Migration Notes

From DaisyUI

Button classes map directly: - btn btn-primary works as-is - btn btn-ghost, btn btn-outline work as-is - bg-base-* classes work with new values

shadcn/ui Components

All shadcn components automatically use the theme: - <Button> uses --primary - <Card> uses --card - Focus rings use --ring (violet)


References

The pages below are AI-targeted review references. They live under docs/theming-guide/ai/ (intentionally not listed in mkdocs.yml nav:, so they don't appear in the sidebar) and are pulled in here via pymdownx.snippets so the canonical guide carries the full review surface in one place.

The companion brand skill (.claude/skills/brand/SKILL.md) loads these same files on demand when reviewing or writing UI in frontend/app/. Update content here and the skill picks it up automatically.

Scope

Scope: In-Scope Source Trees

This skill applies to every file that renders DraftForge UI in the frontend. The brand is one — feature-specific or page-specific exemptions don't exist.

In-Scope

Tree Purpose
frontend/app/components/ Shared components — ui/, user/, events/, etc. The shadcn primitives in components/ui/*.tsx are review targets when callers misuse them.
frontend/app/components/ui/buttons/ Brand button library (PrimaryButton, SecondaryButton, SubmitButton, ConfirmButton, EditButton, NavButton, DestructiveButton, CancelButton, plus icons/ and domain folders). Adding a new button MUST follow the README convention.
frontend/app/routes/ React Router route components. Top-level page UI.
frontend/app/features/ Feature-scoped components and hooks. Page-specific buttons, dialogs, forms.
frontend/app/app.css Token SSOT (CSS variables, --primary, --base-*, gradients, glows). Token additions must be reflected in THEMING-GUIDE.md.
frontend/tailwind.config.js Tailwind theme extension (custom colors, bg-base-* scale, --ring). Changes here ripple through every component.

Out of Scope

  • frontend/app/components/ui/*.tsx — the bare shadcn primitives (button.tsx, dialog.tsx, etc.). These define the brand contract; review here is for callers. Use the ui-styling or shadcn skills if changing the primitives themselves.
  • Build tooling: vite.config.ts, tsconfig.*.json, eslint.config.js, playwright.config.ts.
  • Generated bindings, type stubs.
  • E2E tests under frontend/tests/ — selectors are the contract, not the styling. (Exception: if a test asserts a specific brand class, that's a brand contract; review the assertion.)
  • Backend Python (backend/), Django templates, Daphne/Channels routing.
  • Docs (docs/), nginx config, Docker config.

Special Cases

Mandatory components

Two components MUST be used wherever applicable; raw markup is a block:

Use case Required component Why
Showing a user's avatar <UserAvatar> from ~/components/user/UserAvatar Handles Discord CDN, fallback initials, online indicator, captain ring, memoization. Raw <img> with AvatarUrl() is a known regression source.
Detail-page breadcrumb on org/league/event/series/tournament/rollcall <EntityBreadcrumb> from ~/components/ui/entity-breadcrumb Renders the typed segment labels ("ORGANIZATION" above "DTX") and disables the last segment automatically. Manual breadcrumb nav is a block.

See THEMING-GUIDE.md §"Component Patterns" for the full mandatory list.

Button policy

Per THEMING-GUIDE.md §"Button Policy": all user-facing actions must use a brand button wrapper, not raw <Button> from ~/components/ui/button. Raw <Button> is reserved for structural uses only — dropdown triggers, combobox triggers, shadcn internals.

A raw <Button> with onClick that performs a user-facing action is a block.

Dialog / AlertDialog surfaces

Dialog and AlertDialog content automatically include the brandBg overlay (see THEMING-GUIDE.md §"Brand Surface Background"). Adding bg-gradient-to-* to a DialogContent would silently strip bg-background (tailwind-merge group collision) — do not do it. Use [background-image:var(--brand-bg)] arbitrary property if a custom overlay is needed.

Width convention (see THEMING-GUIDE.md §"Dialog Widths"):

Dialog kind DialogContent width Examples
Form-heavy (edit/signup/create with multiple sections) sm:max-w-2xl EditProfileModal, EventSignupModal, CreateEventModal
Decision (single question, two buttons; or destructive with typed-name gate) default (sm:max-w-md) — no override ConfirmDialog, DeleteDialog. DeleteDialog composes ConfirmDialog and inherits the width.
Wide picker / table / chart (rare) sm:max-w-3xl or larger, only with a justification comment bracket pickers, large team rosters

A form-heavy DialogContent that ships with no sm:max-w-* override defaults to the shadcn sm:max-w-lg — too narrow for multi-field forms. The content then wraps awkwardly or spawns a horizontal scrollbar inside the modal. Reviewers should flag this as warn.

Long forms inside DialogContent MUST scroll via <ScrollArea> (from ~/components/ui/scroll-area), not raw overflow-y-auto — the brand-styled thin scrollbar replaces the chunky OS default and matches the rest of the app.

Token vs literal

The brand uses semantic tokens (bg-primary, bg-base-900, text-foreground, text-success) that map to oklch values in app.css / tailwind.config.js. Raw Tailwind color classes (bg-violet-500, text-slate-100) bypass the token layer.

Exception: error surface containers explicitly use raw red-900 / violet-900 per THEMING-GUIDE.md §"Status Colors" — that's intentional, not drift. The semantic --error token (rose-500) is for inline accents only.

Component Substitutions

Component Substitutions

Replace hand-rolled HTML and one-off styled markup with DraftForge's brand component wrappers. The wrappers ship the brand gradient, 3D depth, hover/active states, focus rings, and disabled treatment; raw markup loses all of it and drifts visually over time.

Buttons (Primary Targets)

Per THEMING-GUIDE.md §"Button Policy": all user-facing actions use a brand button wrapper from ~/components/ui/buttons. Raw <Button> is reserved for structural uses only.

Anti-pattern Replace with Notes
<button onClick={...}>... <PrimaryButton onClick={...}> Brand violet→blue gradient + 3D depth. The headline CTA on a view.
<button className="bg-primary ..."> <PrimaryButton> bg-primary flat is reserved for non-button contexts. Buttons use the gradient.
<Button onClick={submit}>Save</Button> (form submission) <SubmitButton loading={isSubmitting}>Save</SubmitButton> Wires type="submit" + loading spinner.
<Button>Confirm</Button> inside a dialog <ConfirmButton variant="success">Approve</ConfirmButton> Pairs with variant="destructive" / variant="warning" for the matching dialog tone.
Supporting / contextual action <SecondaryButton> Violet gradient + ring outline. "Cancel", "Edit Settings", etc.
Cancel / dismiss inside a dialog <CancelButton> or <SecondaryButton> Translucent violet on opaque backgrounds.
Edit affordance <EditButton> / <EditIconButton> Toxic violet→emerald cyberpunk blend per brandToxic.
Destructive page-level action <DestructiveButton> Red + 3D. NOT for dialog confirmation — use <ConfirmButton variant="destructive"> there.
Navigation action (link styled as button) <NavButton> Sky blue + 3D.
Icon-only variant of a generic button The matching *IconButton in buttons/icons/ (e.g. <EditIconButton>, <ViewIconButton>) Don't roll a new icon-only button — extend the icons folder.

Structural <Button> exceptions (NOT a violation)

Raw <Button> from ~/components/ui/button is correct in these contexts:

  • <DropdownMenuTrigger asChild><Button>...</Button></DropdownMenuTrigger>
  • <PopoverTrigger asChild><Button>...</Button></PopoverTrigger>
  • <Command> / <Combobox> triggers
  • shadcn primitive internals (e.g. inside dialog.tsx itself)

If <Button> has an onClick that performs a domain action (save, delete, navigate), that's a block regardless of where it sits.

Avatars

Anti-pattern Replace with Notes
<img src={user.avatar} ...> <UserAvatar user={user} size="md" /> Handles Discord CDN, fallback initials, memoization.
<img src={AvatarUrl(user)} ...> <UserAvatar user={user} size="md" /> AvatarUrl() is wrapped — never call it directly in JSX.
Custom online-indicator dot <UserAvatar user={user} showOnline online /> The component renders the indicator.
Custom captain ring <UserAvatar user={user} border="captain" /> Gold ring is built in.

Sizes: tiny (16px) | xs (20px) | sm (24px) | md (32px) | lg (40px) | xl (48px). Don't invent intermediate sizes.

Anti-pattern Replace with Notes
Hand-built <nav><a>...</a></nav> breadcrumb <EntityBreadcrumb segments={[...]} /> Renders typed segment labels above each name.
Plain shadcn <Breadcrumb> on a detail page <EntityBreadcrumb> The shadcn primitive is for non-entity contexts (settings, etc.). Detail pages MUST use EntityBreadcrumb.

Required breadcrumb pages: /organizations/:id, /leagues/:id, /events/:id, /event-series/:id, /tournament/:pk, /rollcall/:eventId. Missing breadcrumb on a required page = block.

Dialogs

Anti-pattern Replace with Notes
Custom <div role="dialog"> shadcn <Dialog> / <AlertDialog> Brand surface (brandBg) is automatic.
<DialogContent className="bg-gradient-to-r ..."> Don't override the surface Tailwind-merge will strip bg-background and the dialog goes translucent. Use [background-image:var(--brand-bg)] arbitrary property if a custom overlay is genuinely needed.
<Dialog> without <DialogTitle> Always include title Use className="sr-only" if visually hidden — required for a11y.
Hand-rolled <AlertDialog> for any yes/no confirm — positive ("add user", "approve", "pick hero") OR negative ("delete", "restart") <ConfirmDialog> from ~/components/ui/dialogs. Auto-wires Enter/Backspace + brand button variants + variant-aware destructive/warning surfaces.
Hand-rolled name-match destructive flow (input "Type the X name to confirm" gating a delete) <DeleteDialog> from ~/components/ui/dialogs. Pass entityKind + entityName; the dialog inherits the destructive surface and gates Delete on strict equality.

Keyboard Hints

Anti-pattern Replace with Notes
<kbd>Enter</kbd> raw <Kbd>Enter</Kbd> from ~/components/ui/kbd Brand keycap styling (mono background, rounded). Raw <kbd> inherits browser defaults. Reserve direct <Kbd> use for prose (docs, help panes) and tooltip bodies — for buttons, use the hotkey prop below.
Inline keyboard hint without <Kbd> (e.g. (press Enter) plain text) Wrap the symbol/key name in <Kbd> Keeps the visual rhythm with hotkey-aware surfaces.
Brand button (<PrimaryButton> / <SecondaryButton> / <DestructiveButton> / <ConfirmButton> / <CancelButton>) with a hand-rolled <Kbd> child for a shortcut Pass the hotkey prop instead The brand button renders <HotkeyBadge> (a <Kbd> anchored top-left as a corner pill, plus a LazyTooltip saying "Press X for keyboard shortcut") and adds relative for you. Inline <Kbd> next to the label is a block review finding because it skips the tooltip + breaks the standardized corner-pill pattern.
<ConfirmDialog> confirm/cancel buttons rendered with inline keyboard hints The dialog already wires hotkey="↵" / hotkey="⌫" for you Use <ConfirmDialog> directly — never re-implement Enter/Backspace + corner badges by hand.
<FormLabel> with a hand-rolled <Kbd> keycap next to the label text Pass the hotkey prop on <FormLabel> <FormLabel hotkey="N">Nickname</FormLabel> renders the Kbd inline for you and applies the right flex layout. Wire the actual focus handler in the parent (e.g. modal useEffect listening for the matching keydown).

Action Dropdowns (Menu of Actions)

For a grouped set of related actions behind a single labeled trigger (admin actions, share targets, etc.) use <BrandDropdownMenu> from ~/components/ui/brand-dropdown-menu, not a hand-built <DropdownMenu> from ~/components/ui/dropdown-menu (the latter is the bare shadcn primitive).

import { BrandDropdownMenu, type BrandDropdownAction } from '~/components/ui/brand-dropdown-menu';

const actions: BrandDropdownAction[] = [
  { key: 'edit',   icon: <Pencil className="size-4" />, label: 'Edit',   variant: 'edit',       onClick: ... },
  { key: 'delete', icon: <Trash2 className="size-4" />, label: 'Delete', variant: 'destructive', onClick: ... },
];

<BrandDropdownMenu label="Admin" variant="admin" actions={actions} />
Anti-pattern Replace with Notes
<DropdownMenu><DropdownMenuTrigger asChild><Button>Admin</Button></DropdownMenuTrigger><DropdownMenuContent>…items…</DropdownMenuContent></DropdownMenu> hand-built <BrandDropdownMenu> Ships the brand bg-base-300 trigger + brand glow content surface + violet-tinted hairline separators between every item. Items keyed off actions array.
Edit action rendered as variant="success" variant="edit" The edit variant uses the brand-toxic violet→emerald gradient text matching <EditButton>; success is a flat emerald token used for true confirmation rows.
Destructive action rendered as variant="primary" or variant="default" variant="destructive" Maps to text-error so the row reads as a danger affordance.
Stacked items without separators Pass items normally — <BrandDropdownMenu> auto-inserts a hairline <DropdownMenuSeparator> between every entry A gap between items reads as "no relation"; the hairline reads as "ordered list of options", which is what action menus communicate.
Trigger styled with custom bg-violet-… variant="primary" / "secondary" / "admin" The component owns the trigger surface; pass variant to pick a brand-aligned skin.

Variants on BrandDropdownAction:

  • default → foreground text. Neutral.
  • primary → violet text + violet icon. Main CTA inside the menu.
  • edit → toxic gradient text (violet→emerald) + emerald icon. Brand edit affordance.
  • success → emerald text + emerald icon. Confirm / approve.
  • destructive → red text + red icon. Delete / cancel.

Selects (Dropdowns)

Anti-pattern Replace with Notes
Bare shadcn <Select> / <SelectTrigger> / <SelectContent> / <SelectItem> in a user-facing picker (role, draft order, MMR bracket, etc.) <BrandSelect> + <BrandSelectTrigger> + <BrandSelectContent> + <BrandSelectItem> from ~/components/ui/brand-select Renders the neon-cyber surface: bg-base-300 trigger with violet hairline + brand-violet ring on focus/open, popover content with brand glow shadow + violet border, items highlight with the brandSecondary gradient on focus and persist data-[state=checked]. Default size="default" (h-9); pass size="sm" (h-8) for tight action columns (e.g. UserStrip actionSlot).
<SelectTrigger className="bg-white text-black ..."> color overrides Drop the override — use <BrandSelectTrigger> The brand trigger already maps to the dark bg-base-300 family. Hand-rolled light triggers fight the theme on hover.
Hand-styled chevron icon inside the trigger Don't render one — <BrandSelectTrigger> injects the ChevronDownIcon for you Same as shadcn SelectTrigger, but the brand version themes the chevron color (text-violet-300) so it matches the ring.
Bare shadcn Select for non-form widgets that pick a discrete value (per-row order, sort key, etc.) <BrandSelect> Reserve the bare shadcn primitive for cases that genuinely need neutral styling (e.g. inside dialogs with their own surface treatment that the brand select would clash with — rare). Document the exception inline.

SelectValue, SelectGroup, SelectLabel, SelectScrollUpButton, SelectScrollDownButton, and SelectSeparator are re-exported from brand-select.tsx unchanged — they don't need a brand layer.

Status / Win-Loss Indicators

Anti-pattern Replace with Notes
<span className="text-green-500">WIN</span> <span className="text-success">WIN</span> Use the semantic token.
<span style={{ color: '#ef4444' }}>LOSS</span> <span className="text-error">LOSS</span> rose-500 token.
Custom ranking badge <Badge className="bg-warning text-warning-foreground">1st</Badge> shadcn Badge + status tokens.

Inline Styles & Hardcoded Values

Inline Styles & Hardcoded Values

DraftForge's brand is enforced through semantic tokens (bg-primary, bg-base-900, text-foreground, text-success). Inline styles, raw Tailwind colors (bg-violet-500, bg-slate-900), and hardcoded gradients bypass the token layer and drift over time. Block these in review.

Anti-patterns and Fixes

style={{ ... }} for static styling

// WRONG
<div style={{ background: '#020617', padding: '16px' }}>

// RIGHT
<div className="bg-base-950 p-4">

Acceptable only for dynamic computed values (width: ${pct}%, CSS variable injection). Static styling MUST be Tailwind. Exception: [background-image:var(--brand-bg)] on dialog surfaces (see THEMING-GUIDE.md §"Brand Surface Background").

Raw violet/indigo Tailwind classes

// WRONG
<button className="bg-violet-500 text-white">
// RIGHT
<PrimaryButton>...</PrimaryButton>

The brand primary is a gradient — use <PrimaryButton> or import brandGradient from ~/components/ui/buttons/styles.

Raw slate classes instead of bg-base-*

// WRONG
<div className="bg-slate-900">
<div className="bg-slate-800 border border-slate-700">

// RIGHT — use the base scale
<div className="bg-base-900">
<div className="bg-base-800 border border-border">

See THEMING-GUIDE.md §"Background Scale (Slate)".

Hand-rolled gradients

// WRONG
<button className="bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-400 hover:to-blue-400 text-white">

// RIGHT — import the constant
import { brandGradient } from '~/components/ui/buttons/styles';
<button className={brandGradient}>
// (or just use <PrimaryButton>)

brandGradient, brandSecondary, brandErrorBg, brandSuccessBg, brandBg, brandGlow, brandToxic — all exported from ~/components/ui/buttons. Hand-rolling is a block.

Hardcoded hex / oklch in className

// WRONG
<div className="bg-[#020617]">
<div className="text-[#7c3aed]">
<div className="ring-[oklch(0.541_0.251_293)]">

// RIGHT — use semantic tokens
<div className="bg-base-950">
<div className="text-primary">
<div className="ring-ring">

Hex/oklch literals in className are block. The [bg-image:var(--brand-bg)] arbitrary property is the only sanctioned arbitrary value, and only on dialog surfaces.

space-x-* / space-y-*

Use flex + gap-* instead. space-* doesn't honor RTL and breaks with conditional children.

w-N h-N for square dimensions

Use size-N (or <UserAvatar size="lg">).

Conditional classes without cn()

Use cn() (clsx + tailwind-merge) from ~/lib/utils — deduplicates conflicting classes when composing brand constants.

Ad-hoc glow / shadow

Use brandGlow / shadow-brand-glow. Text glow: text-glow-violet, text-glow-cyan. Radial: gradient-glow-violet / gradient-glow-cyan (all in app.css).

Body text on a colored brand surface

Use the variant-matched readable constant — not text-muted-foreground (fades) and never a text-shadow outline on body copy (block).

Constant Use over
brandReadableSuccess brandSuccessBg, emerald gradients
brandReadableWarning warning surfaces, orange/amber gradients
brandReadableDestructive destructive surfaces, red gradients

For display/title text use text-outline-black. All exported from ~/components/ui/buttons.

Inner panels on a colored dialog surface

Use brandDialogPanel (bg-black/40 ring-1 ring-white/10) for any nested block (UserStrip, info cards, recap rows) rendered inside a colored brand dialog.

import { brandDialogPanel } from '~/components/ui/buttons';
<UserStrip user={user} showBorder={false} className={brandDialogPanel} />

Destructive content-surface exception (the only sanctioned raw bg-red-*)

bg-red-950/95 border-red-800 is the documented destructive content-surface class and is allowed in exactly one place: the contentVariantStyles.destructive entry inside frontend/app/components/ui/dialogs/ConfirmDialog.tsx. This is the source of truth for the destructive dialog surface; <DeleteDialog> inherits it unchanged via composition.

Raw bg-red-* or border-red-* anywhere else in frontend/app/ (including new variants on ConfirmDialog or any feature component) remains a block finding. Use semantic tokens instead:

  • bg-destructive / border-destructive / ring-destructive for the destructive intent.
  • bg-base-900/80 for recessed surfaces inside the destructive dialog (see DeleteDialog's Input).

What's NOT an inline-style violation

  • style={{ width:${pct}%}} for a computed progress bar.
  • style={{ '--row-h':${rowH}px}} to feed a value into a CSS variable consumed by Tailwind utilities.
  • The arbitrary property [background-image:var(--brand-bg)] on dialog surfaces (sanctioned by THEMING-GUIDE.md).
  • dangerouslySetInnerHTML for sanitized content (separate concern).

The rule: static style → className with tokens; dynamic numeric value → style={{}} is fine.

Review Checklist

Review Checklist

Walk this list against any diff that touches an in-scope source tree. Every violation cites file:line and a section of THEMING-GUIDE.md for the why.

1. Brand Button Wrappers

  • [ ] No raw <button> with hand-rolled Tailwind. Use the matching brand wrapper. → substitutions
  • [ ] No raw <Button> (from ~/components/ui/button) with an onClick for a domain action. Reserve raw <Button> for dropdown / popover / combobox triggers and shadcn internals.
  • [ ] Form submission uses <SubmitButton>, not <Button type="submit">.
  • [ ] Dialog confirmation uses <ConfirmButton variant="...">, not <PrimaryButton> / <DestructiveButton>.
  • [ ] Page-level destructive action uses <DestructiveButton>, not <ConfirmButton variant="destructive">.
  • [ ] Edit affordance uses <EditButton> / <EditIconButton>, not a custom violet button.
  • [ ] Icon-only buttons live in frontend/app/components/ui/buttons/icons/ (not invented inline).

2. Mandatory Components

  • [ ] <UserAvatar> for every avatar render. No raw <img> with AvatarUrl(). → substitutions
  • [ ] <EntityBreadcrumb> on every detail page that requires it (organization, league, event, event-series, tournament, rollcall). → substitutions
  • [ ] No hand-built breadcrumb <nav> markup on those pages.

3. Tokens vs Literals

  • [ ] No bg-violet-* / bg-indigo-* for buttons — use <PrimaryButton> / <SecondaryButton> / brandGradient. → inline-styles
  • [ ] No bg-slate-* for surfaces — use bg-base-*.
  • [ ] No text-slate-100 / text-gray-* — use text-foreground, text-text-secondary, text-muted-foreground.
  • [ ] Status colors use semantic tokens: text-success / text-warning / text-error / text-info.
  • [ ] No hand-rolled gradients matching the brand — import the constant from ~/components/ui/buttons/styles.
  • [ ] No hex / oklch literals in className (bg-[#...], text-[#...]). Exception: [background-image:var(--brand-bg)] arbitrary property on dialog surfaces.

4. Inline Styles

  • [ ] No style={{}} for static styling. Dynamic computed values only. → inline-styles
  • [ ] No space-x-* / space-y-*. Use flex gap-*.
  • [ ] size-* for equal width/height, not w-N h-N.
  • [ ] Conditional classes use cn(), not template-literal ternaries.

5. Glow / Effects

  • [ ] Glow effects pull from utility classes (text-glow-violet, text-glow-cyan, border-glow, glow-hover, gradient-glow-*) or from brandGlow / shadow-brand-glow. Not hand-rolled shadow-[...].
  • [ ] gradient-button / gradient-button-glow for button gradient variants.
  • [ ] gradient-bg-subtle / gradient-hero for page surface gradients.

6. Dialog Surfaces

  • [ ] <Dialog> / <AlertDialog> content does NOT add bg-gradient-to-* overrides — brandBg is automatic and tailwind-merge will strip bg-background. → substitutions
  • [ ] Custom dialog overlays use the arbitrary property [background-image:var(--brand-bg)], not bg-gradient-*.
  • [ ] <Dialog> / <AlertDialog> always include <DialogTitle> / <AlertDialogTitle> (use className="sr-only" if visually hidden).
  • [ ] Confirmation flows use canonical wrappers from ~/components/ui/dialogs:
  • Yes/no confirm (positive OR negative) → <ConfirmDialog> with the appropriate variant.
  • Destructive with a typed-name gate (League, Organization, Event Series) → <DeleteDialog> with entityKind + entityName. Never hand-roll <AlertDialog> for these. Never window.confirm() in .tsx/.ts.
  • [ ] Keyboard hints use <Kbd> from ~/components/ui/kbd, never raw <kbd>.
  • [ ] Brand buttons exposing a page-level shortcut use the hotkey prop, never a hand-rolled <Kbd> child. The prop renders <HotkeyBadge> (corner pill + LazyTooltip) — bypassing it skips the tooltip and breaks the standardized pattern.

7. Component Placement

  • [ ] Generic, shape-agnostic buttons live at root of buttons/.
  • [ ] Icon-only variants live in buttons/icons/.
  • [ ] Domain-specific buttons (Steam, Dotabuff, Discord, etc.) live in buttons/<domain>/.
  • [ ] Page-specific buttons used in only one feature are co-located in the feature folder, NOT in buttons/.
  • [ ] No new files under frontend/app/components/ui/buttons/ without a barrel update in index.ts.

8. Accessibility

  • [ ] Every interactive <div> / <span> with onClick also has keyboard support — usually means it should be a button.
  • [ ] Icon-only buttons have aria-label.
  • [ ] Decorative icons inside text-bearing buttons have aria-hidden.
  • [ ] Forms use shadcn <Form> + <FormField> (not raw <input> + manual labels).
  • [ ] Error messages render in text-error / text-destructive AND with text content (not color-only).

Grep Recipes

Grep Recipes

Copy-paste rg recipes for finding brand violations across the in-scope tree. Run from the draftforge repo root.

Define the scope once

SCOPE=frontend/app

Most recipes glob --glob '!**/components/ui/**' to skip the bare shadcn primitives (those are out of scope for this skill).

Buttons

rg -n '<button[^>]*onClick' "$SCOPE" --glob '!**/components/ui/buttons/**' --glob '!**/components/ui/button.tsx'
rg -nB1 -A2 '<Button[^>]*onClick' "$SCOPE" --glob '!**/components/ui/**' \
  | rg -B1 -A2 -v 'DropdownMenu|Popover|Command|Combobox'
rg -n 'from-violet-[0-9]+\s+to-blue-[0-9]+' "$SCOPE" --glob '!**/components/ui/buttons/**'
rg -nB0 -A0 '<button[^>]*bg-primary|<Button[^>]*bg-primary' "$SCOPE"
rg -n '<Button[^>]*type=["\x27]submit' "$SCOPE" --glob '!**/components/ui/**'

Avatars

rg -n '<img[^>]*(src=\{[^}]*[Aa]vatar|src=\{[^}]*\.avatar|cdn\.discordapp)' "$SCOPE"
rg -n 'AvatarUrl\s*\(' "$SCOPE" --glob '!**/UserAvatar*'
rg -nB0 -A2 '<nav[^>]*>' "$SCOPE/routes/(organizations|leagues|events|event-series|tournament|rollcall)/"
rg -n '<Breadcrumb\b' "$SCOPE/routes/(organizations|leagues|events|event-series|tournament|rollcall)/"

Selects

# Bare shadcn Select primitive in a user-facing picker (should use BrandSelect family)
rg -n "from '~/components/ui/select'" "$SCOPE" \
  --glob '!**/components/ui/select.tsx' \
  --glob '!**/components/ui/brand-select.tsx'

# Hand-rolled color override on a SelectTrigger
rg -n '<SelectTrigger[^>]*className="[^"]*\b(bg-(slate|gray|zinc|neutral|stone)-|text-(black|white)\b)' "$SCOPE"

Tokens vs Literals

rg -n 'bg-violet-[0-9]+|bg-indigo-[0-9]+|text-violet-[0-9]+' "$SCOPE" \
  --glob '!**/components/ui/buttons/**' --glob '!**/app.css'
rg -n 'bg-slate-[0-9]+|border-slate-[0-9]+' "$SCOPE" \
  --glob '!**/components/ui/**' --glob '!**/app.css' --glob '!**/tailwind.config.js'
rg -n 'text-(gray|slate|zinc)-[0-9]+' "$SCOPE" \
  --glob '!**/components/ui/**' --glob '!**/app.css'
rg -n 'bg-\[#|text-\[#|border-\[#|ring-\[#' "$SCOPE"
rg -n '\[oklch\(' "$SCOPE"
rg -n 'from-violet-500\s+to-blue-500' "$SCOPE" --glob '!**/components/ui/buttons/styles.ts'

Inline styles

rg -n 'style=\{\{' "$SCOPE"
rg -n 'shadow-\[0[ _]0[ _]' "$SCOPE" --glob '!**/components/ui/buttons/**'
rg -n 'space-[xy]-' "$SCOPE"
rg -nP 'w-(\d+)\s+h-\1\b' "$SCOPE"
rg -n 'className=\{`[^`]*\$\{[^}]+\?' "$SCOPE"

Dialogs

# DialogContent with bg-gradient override (will strip bg-background)
rg -nB0 -A2 'DialogContent[^>]*bg-gradient' "$SCOPE"
rg -nB0 -A2 'AlertDialogContent[^>]*bg-gradient' "$SCOPE"

# <Dialog> / <AlertDialog> missing Title (manual review per hit)
for primitive in DialogContent AlertDialogContent; do
  rg -nB0 -A20 "<$primitive" "$SCOPE" \
    | rg -B1 -A20 "<$primitive" \
    | rg -L "${primitive%Content}Title"
done

# Form-heavy DialogContent without a max-w override (too narrow for multi-field forms)
rg -nB0 -A2 '<DialogContent' "$SCOPE" | rg -L 'max-w-'

# Raw overflow-y-auto inside DialogContent — should be <ScrollArea>
rg -nB0 -A8 '<DialogContent' "$SCOPE" | rg -B1 -A2 'overflow-y-auto'

# <ScrollArea> child but no overflow-hidden — Radix Viewport never enables scrollbar
rg -nB0 -A12 '<DialogContent' "$SCOPE" \
  | rg -B12 'ScrollArea' \
  | rg -L 'overflow-hidden'

Confirmation dialogs — anti-patterns

Every match below is a block finding.

# <AlertDialog> outside the canonical wrapper
rg '<AlertDialog\b' frontend/app -g '*.tsx' -g '*.ts' --glob '!**/components/ui/dialogs/**'

# window.confirm() in any .tsx/.ts
rg 'window\.confirm\(' frontend/app -g '*.tsx' -g '*.ts'

# <AlertDialogAction> / <AlertDialogCancel> outside components/ui/
rg '<AlertDialogAction\b|<AlertDialogCancel\b' frontend/app -g '*.tsx' --glob '!**/components/ui/**'

# Hand-rolled name-match destructive input (should use <DeleteDialog>)
rg '=== \w+\.name' frontend/app -g '*.tsx' --glob '!**/components/ui/dialogs/**'

Keyboard hints

rg -n '<kbd[\s>]' "$SCOPE" --glob '!**/components/ui/kbd.tsx'
rg -nB1 -A4 '<(Primary|Secondary|Destructive|Confirm|Cancel)Button\b' "$SCOPE" \
  --glob '!**/components/ui/buttons/**' | rg -B4 -A1 '<Kbd\b'

Component placement

fd -e tsx -e ts . "$SCOPE" | rg -v '/components/ui/' \
  | xargs -I{} rg -l '"@radix-ui/' {} 2>/dev/null
fd -g 'Button*.tsx' "$SCOPE" --exclude '**/components/ui/buttons/**'

Smoke check

rg -n '<button[^>]*onClick|bg-violet-|bg-slate-[0-9]|<img[^>]*[Aa]vatar|style=\{\{|window\.confirm\(' \
  frontend/app --glob '!**/components/ui/**'

Severity Rubric

Severity Rubric

Brand-review findings are graded so reviewers can triage quickly. Use these definitions when emitting findings as part of a /review pass.

Levels

block — Must fix before merge

User-visible affordance is wrong, accessibility is broken, or the brand contract is violated in a load-bearing way.

Examples: - Primary action wired as raw <button> (loses brand gradient, 3D depth, focus ring) — should be <PrimaryButton>. - <Button onClick={handleSave}> for a domain action (button policy violation) — should be <PrimaryButton> / <SubmitButton> / <ConfirmButton>. - Raw <img> rendering a user avatar (skips Discord CDN handling, fallback initials, memoization) — must be <UserAvatar>. - Manual breadcrumb <nav> on a required detail page — must be <EntityBreadcrumb>. - <DialogContent className="bg-gradient-to-r ..."> — silently strips bg-background. - <Dialog> / <AlertDialog> without a Title — fails screen-reader. - Hardcoded hex / oklch in className (bg-[#7c3aed]). - Hand-rolled brand gradient (from-violet-500 to-blue-500) — must import brandGradient or use <PrimaryButton>. - New shadcn primitive added outside frontend/app/components/ui/. - Color-only status indicator (no text content alongside the colored class). - Hand-rolled <AlertDialog> for a confirm flow (yes/no, positive or negative) outside components/ui/dialogs/. - window.confirm() called from any .tsx/.ts file under frontend/app/. - Hand-rolled <AlertDialogAction> / <AlertDialogCancel> outside components/ui/. - Hand-rolled name-match destructive input gating a delete (use <DeleteDialog> instead).

warn — Should fix, but won't block

Drift from convention; not user-breaking but compounds over time.

Examples: - Raw bg-slate-* on surfaces instead of bg-base-*. - Raw text-slate-* / text-gray-* instead of text-foreground / text-muted-foreground. - Raw text-green-500 / text-red-500 for status instead of text-success / text-error. - space-x-* / space-y-* instead of flex gap-*. - Template-literal conditional className instead of cn(). - Hand-rolled shadow-[0_0_...] instead of brandGlow / shadow-brand-glow. - New button file added outside the buttons/ folder convention (root for generic, icons/ for icon-only, <domain>/ for domain-specific). - <DestructiveButton> used inside a dialog (should be <ConfirmButton variant="destructive">).

nit — Cosmetic / future cleanup

Style consistency only.

Examples: - w-4 h-4 instead of size-4. - Sizing classes on icons inside a brand button wrapper (the wrapper handles it). - Missing aria-hidden on a decorative icon next to existing labeled text. - Comment narrating WHAT a className does (the className already does that). - Inconsistent button size choice (size="sm" where size="default" would match neighbors).

Output Format

When emitted as part of a review pass, format each finding as one block:

brand: <severity> · <file>:<line> — <one-line summary>
  why: <THEMING-GUIDE.md section or component contract>
  fix: <concrete code change, ideally a minimal diff>

Examples:

brand: block · frontend/app/routes/tournament/$pk.tsx:84 — raw <button> for "Start Tournament" CTA
  why: THEMING-GUIDE.md §"Button Policy" — user-facing actions must use a brand wrapper; raw <button> loses gradient, 3D depth, focus ring
  fix: replace with <PrimaryButton onClick={handleStart}>Start Tournament</PrimaryButton>

brand: block · frontend/app/features/teams/TeamRoster.tsx:42 — raw <img> for team-captain avatar
  why: THEMING-GUIDE.md §"User Avatars" — avatars must use <UserAvatar> for Discord CDN handling and fallbacks
  fix: <UserAvatar user={captain} size="md" border="captain" />

brand: warn · frontend/app/features/draft/DraftPanel.tsx:128 — bg-slate-900 on container
  why: THEMING-GUIDE.md §"Background Scale (Slate)" — surfaces use bg-base-* for semantic elevation
  fix: replace bg-slate-900 with bg-base-900

brand: nit · frontend/app/components/MatchHeader.tsx:18 — w-6 h-6 on icon
  why: shadcn convention — size-* for equal width/height
  fix: replace `w-6 h-6` with `size-6`

Counting & Triage

A diff with any block is not ready to merge — the reviewer should reply with the list and stop. warn and nit findings can be batched into a follow-up PR when there are too many to fix in-place.

Common cluster pattern: when a contributor adds a new feature folder, all the blocks tend to come from the same root cause (e.g. they didn't know about the brand button library). Surface that root cause in the review summary, not just the per-line findings.

ScrollArea inside Dialog

ScrollArea inside Dialog — clipping contract

Long forms inside <DialogContent> MUST scroll via shadcn <ScrollArea>, not raw overflow-y-auto. The Radix Viewport gives a brand-styled thin scrollbar; the OS-default scrollbar inside an otherwise-themed dialog reads as visual drift.

See THEMING-GUIDE.md §"Dialog Widths" for width conventions; this reference covers the clipping contract that makes the ScrollArea inside a Dialog actually work.

The rule

When <DialogContent> contains a <ScrollArea> (or any flex child that's expected to scroll), <DialogContent> MUST carry overflow-hidden.

// ✓ correct
<DialogContent className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-2xl">
  <DialogHeader></DialogHeader>
  <ScrollArea className="flex-1 min-h-0 -mx-6 px-6">
    <div className="flex flex-col gap-4 pb-4">{/* form body */}</div>
  </ScrollArea>
  <DialogFooter></DialogFooter>
</DialogContent>

// ✗ wrong — form grows past the dialog edge, ScrollArea never scrolls
<DialogContent className="flex max-h-[90vh] flex-col sm:max-w-2xl">
  <ScrollArea className="flex-1 min-h-0 -mx-6 px-6"></ScrollArea>
</DialogContent>

Decision dialogs (ConfirmDialog, plain AlertDialog with two buttons) do NOT need this — their content fits and never overflows. The rule applies only to form-heavy modals with a <ScrollArea> child.

Why

shadcn's default DialogContent has no overflow-hidden. Without it, the chain unravels:

  1. The form (flex-1) inside the dialog grows past max-h-[90vh] because nothing is clipping it.
  2. The <ScrollArea> Root with flex-1 min-h-0 inherits the unconstrained height — it has no definite parent height to bound against.
  3. The Radix <Viewport> inside uses height: 100% (size-full). With no definite parent height, that resolves to auto (= content height).
  4. Radix sees viewport.scrollHeight === viewport.clientHeight (no internal overflow), keeps the Viewport's overflow at hidden, and never enables the scrollbar.
  5. Content renders past the dialog's visual edge. Users see broken layout; Playwright reports "Element is outside of the viewport" on clicks below the fold.

The Radix Viewport IS the scrolling element (per radix-ui/primitives scroll-area source) — it sets overflow: scroll itself, conditional on detected overflow. Once <DialogContent> clips, all the height calculations resolve and Radix enables the scrollbar.

The -mx-6 px-6 cancel trick

<DialogContent> carries p-6 (24px padding). A naive <ScrollArea> inside that padding renders its scrollbar 24px in from the dialog edge — visible whitespace on the right of the scrollbar that looks unfinished. Cancel the padding on the ScrollArea and re-add it on the inner content wrapper:

<ScrollArea className="flex-1 min-h-0 -mx-6 px-6">  {/* cancel-then-readd */}
  <div className="flex flex-col gap-4 pb-4">{/* content lives here */}</div>
</ScrollArea>

This sits the scrollbar flush against the dialog's inner edge without chopping content horizontally.

Severity & review

Form-heavy <DialogContent> with a <ScrollArea> (or any flex-scroll child) but no overflow-hidden is a block finding. Symptoms reviewers will see:

  • Visual: content rendering past the dialog's visual edge (often only visible at certain viewport sizes).
  • Playwright: Element is outside of the viewport errors when clicking fields below the fold.
  • Bug history: PR #220 fixed exactly this on the EventSignupModal.

See grep-recipes.md §Dialogs for the rg recipe that flags this.


Updating This Guide

To add, change, or remove a brand-review rule, run the /brand-update slash command (.claude/commands/brand-update.md). It walks the lockstep edit of this guide, the AI references under docs/theming-guide/ai/references/, and the brand skill, and verifies that backlinks and snippet blocks stay intact. Hand-editing one side without the other is a known drift source.

  • Skill entry point: .claude/skills/brand/SKILL.md
  • AI references hub: docs/theming-guide/ai/index.md
  • Update command: .claude/commands/brand-update.md
  • Token SSOT: frontend/app/app.css
  • Brand style constants: frontend/app/components/ui/buttons/styles.ts
  • Buttons folder convention: frontend/app/components/ui/buttons/README.md
  • Sibling skills delegating here: .claude/skills/aesthetic/SKILL.md, .claude/skills/ui-styling/SKILL.md, .claude/skills/frontend-development/SKILL.md