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 |
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--errortokens. This is intentional: error containers need deep wine/muted tones to maintain the "muted surfaces + bright accents" pattern. The semanticrose-500error 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, andConfirmButton 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¶
Breadcrumbs¶
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).
Links & Tabs¶
// 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.
Navbar Styling¶
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 theui-stylingorshadcnskills 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.tsxitself)
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.
Breadcrumbs¶
| 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¶
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-destructivefor the destructive intent.bg-base-900/80for recessed surfaces inside the destructive dialog (seeDeleteDialog'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 byTHEMING-GUIDE.md). dangerouslySetInnerHTMLfor 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 anonClickfor 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>withAvatarUrl(). → 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 — usebg-base-*. - [ ] No
text-slate-100/text-gray-*— usetext-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-*. Useflex gap-*. - [ ]
size-*for equal width/height, notw-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 frombrandGlow/shadow-brand-glow. Not hand-rolledshadow-[...]. - [ ]
gradient-button/gradient-button-glowfor button gradient variants. - [ ]
gradient-bg-subtle/gradient-herofor page surface gradients.
6. Dialog Surfaces¶
- [ ]
<Dialog>/<AlertDialog>content does NOT addbg-gradient-to-*overrides —brandBgis automatic and tailwind-merge will stripbg-background. → substitutions - [ ] Custom dialog overlays use the arbitrary property
[background-image:var(--brand-bg)], notbg-gradient-*. - [ ]
<Dialog>/<AlertDialog>always include<DialogTitle>/<AlertDialogTitle>(useclassName="sr-only"if visually hidden). - [ ] Confirmation flows use canonical wrappers from
~/components/ui/dialogs: - Yes/no confirm (positive OR negative) →
<ConfirmDialog>with the appropriatevariant. - Destructive with a typed-name gate (League, Organization, Event Series) →
<DeleteDialog>withentityKind+entityName. Never hand-roll<AlertDialog>for these. Neverwindow.confirm()in.tsx/.ts. - [ ] Keyboard hints use
<Kbd>from~/components/ui/kbd, never raw<kbd>. - [ ] Brand buttons exposing a page-level shortcut use the
hotkeyprop, 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 inindex.ts.
8. Accessibility¶
- [ ] Every interactive
<div>/<span>withonClickalso 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-destructiveAND 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¶
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*'
Breadcrumbs¶
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:
- The form (
flex-1) inside the dialog grows pastmax-h-[90vh]because nothing is clipping it. - The
<ScrollArea>Root withflex-1 min-h-0inherits the unconstrained height — it has no definite parent height to bound against. - The Radix
<Viewport>inside usesheight: 100%(size-full). With no definite parent height, that resolves toauto(= content height). - Radix sees
viewport.scrollHeight === viewport.clientHeight(no internal overflow), keeps the Viewport'soverflowathidden, and never enables the scrollbar. - 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 viewporterrors 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.
Backlinks¶
- 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