Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .claude/skills/design-system/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
name: design-system
description: Use when building, refactoring, or styling any UI in the ethereum.org Next.js site (`src/components/`, `app/`, `src/styles/`, `public/content/`, or any `.tsx`/`.mdx`/`.css` change that affects the rendered UI). Provides canonical component choices, design tokens, RTL/i18n rules, server/client guidance, and the "use a variant, not a new component" pattern for the project's Tailwind v4 + Radix + shadcn-style design system.
---

# ethereum.org Design System

Tailwind v4 (CSS-first config, no `tailwind.config.ts`) + React 18 / Next.js App Router + Radix UI primitives + shadcn-style component layer. Tokens live in CSS. Read this file fully on activation; pull from `references/` only when the listed trigger applies.

## The Core Habit: Reuse Over Reinvent

The single highest-leverage habit for keeping this codebase consistent: **when you need new UI, look for a primitive or variant first, only invent if nothing fits.** Most "new component" instincts are actually "new variant" instincts in disguise.

Before you write any UI code, ask:
- Is there a primitive that already does this? (`Card`, `Button`, `Alert`, `Tag`, `Hero/*`)
- Is the difference small enough to express as a *variant* on an existing primitive?
- Can I compose existing primitives instead of inlining a long Tailwind class chain?

If you find yourself writing `flex items-center gap-X rounded-Y border bg-... p-Z` for a card-like thing, you're reinventing `<Card>`. If you write `<p className="text-4xl font-bold">N</p>` for a stat, you're reinventing `<BigNumber>`. If you write `<div className="text-5xl font-bold">Title</div>`, you're reinventing `<h1>` (which is already styled by `base.css`). **Compose, don't inline.**

When the existing primitive doesn't quite fit, the answer is usually "add a variant," not "create a new file." See `references/variant-vs-new.md`.

## Top Rules

1. **No raw `<a>` or `<button>`.** Use `<Button>`/`<ButtonLink>` from `@/components/ui/buttons/Button` and `InlineLink`/`BaseLink`/`LinkWithArrow` from `@/components/ui/Link`. These primitives handle event tracking, external-link safety, locale routing, and focus rings.
2. **No raw color values.** Use semantic tokens (`text-body`, `bg-background`, `border-border`, `text-primary`). Hex literals and `rgb()` calls bypass dark mode.
3. **Prefer adding a variant** to an existing primitive over creating a new component. Card, Button, Alert, Tag are the most common targets.
4. **Server Components by default.** Only `"use client"` when you need state, effects, browser APIs, or inline event handlers.
5. **All text is translatable.** `getTranslations` (server) or `useTranslations` (client) from `next-intl`. Never hard-code user-facing English.
6. **Logical CSS for direction.** Use `ms-`/`me-`/`ps-`/`pe-`/`inset-s-`/`inset-e-`/`border-s`/`border-e`/`text-start`/`text-end`. The site supports Arabic and Urdu (RTL). Hard-coded `left-`/`right-`/`ml-`/`mr-`/`pl-`/`pr-` breaks RTL.
7. **Locale-aware formatters.** `numberFormat()` from `@/lib/utils/numbers`, `dateTimeFormat()` from `@/lib/utils/date`. Never `toLocaleString` / `Intl.NumberFormat` directly.
8. **`useRtlFlip()` for directional icons** (right-pointing arrows/chevrons). Or use `ChevronNext`/`ChevronPrev` from `@/components/Chevron`.
9. **Markdown content goes through `MdComponents`.** The legacy `@/components/Card` (default export) is reserved for markdown shortcodes -- never import it from app code; use `@/components/ui/card`.
10. **Storybook stories ship with new UI components.** No automated unit tests; Storybook + Chromatic + types are the verification layer.

## Highest-Value Gotchas (read these now)

These are landmines where the code looks reasonable but the pattern is wrong. The full set is in `references/gotchas.md`; these are the ones that come up most often.

### Imports that look right but aren't

- **Cards**: `import { Card } from "@/components/ui/card"` is canonical. **Not** `import Card from "@/components/Card"` (default export of that file is reserved for markdown shortcodes).
- **Tooltips**: `import Tooltip from "@/components/Tooltip"` (mobile-aware, Matomo-tracked, scroll-close). **Not** `import { Tooltip } from "@/components/ui/tooltip"` (that's the bare Radix primitive used internally).
- **Modals**: `import Modal from "@/components/ui/dialog-modal"` (default export, the high-level convenience) for typical modal needs. `@/components/ui/dialog` is the vanilla shadcn-style primitive for fine-grained Radix control. Same names exported from both files; **do not mix sources within a feature**.
- **Heroes**: import from `@/components/Hero` (`ContentHero`, `SimpleHero`, `HubHero`, `MdxHero`, `HomeHero`). **Not** `@/components/PageHero` (deprecation track).

### Stale shadcn token names that don't resolve

`bg-popover`, `text-popover-foreground`, `bg-accent`, `text-accent-foreground`, `bg-muted`, `text-muted-foreground`, `focus:ring-ring`, `ring-offset-background` appear in `ui/select.tsx`, `ui/dialog.tsx`, `ui/dropdown-menu.tsx`, `ui/tabs.tsx` but are **NOT defined** in this project's tokens. They render incorrectly. If you touch these files, replace with project semantic tokens (`bg-background-highlight`, `text-body`, `bg-accent-a`, `text-body-medium`, etc.). Don't introduce new uses.

### `useColorModeValue` is a Chakra leftover

Used in 5 places. Don't introduce new uses. Use Tailwind `dark:` variant + semantic tokens.

### Subtle component behaviors

- `<Button isSecondary>` only takes effect on `outline` and `ghost` variants. Silent no-op on `solid`/`link`.
- `<CardBanner fit="contain">` with a single `<Image>` child auto-clones it as a blurred backdrop. Pass two children and you lose this magic.
- `LinkBox` requires a `LinkOverlay` somewhere inside; without it, the whole-card-clickable pattern doesn't work.
- `commonControlClasses` in `ui/checkbox.tsx` is shared by `Switch`. Editing it changes both.

### No `Heading` primitive -- use semantic tags

`base.css` styles `<h1>`-`<h6>` with the right sizes and `font-bold`. Just write `<h1>Title</h1>`. Override the size class on the heading element when really needed (`<h2 className="text-4xl">`). Reinventing with `<div className="text-5xl font-bold">` loses semantics and screen-reader navigation.

### One stray `toLocaleString` in `ui/chart.tsx:241`

Don't add more. Use `numberFormat()`.

## Quick "Where Do I Import From?" Cheatsheet

| I need... | Import |
|---|---|
| Card | `import { Card, CardBanner, CardContent, CardTitle, CardParagraph } from "@/components/ui/card"` |
| Modal/Dialog (typical) | `import Modal from "@/components/ui/dialog-modal"` (default export) |
| Side sheet | `import { Sheet, ... } from "@/components/ui/sheet"` |
| Tooltip | `import Tooltip from "@/components/Tooltip"` (NOT `@/components/ui/tooltip`) |
| Button | `import { Button, ButtonLink } from "@/components/ui/buttons/Button"` |
| Anchor (in prose) | `import InlineLink from "@/components/ui/Link"` (default) |
| Anchor (CTA with arrow) | `import { LinkWithArrow } from "@/components/ui/Link"` |
| Page hero | `import { ContentHero, SimpleHero, HubHero, MdxHero } from "@/components/Hero"` |
| Inline alert | `import { Alert, AlertContent, AlertDescription } from "@/components/ui/alert"` |
| Top-of-page banner | `import BannerNotification from "@/components/Banners/BannerNotification"` |
| Big numeric display | `import BigNumber from "@/components/BigNumber"` |
| Layout | `import { Stack, HStack, VStack, Flex, Center } from "@/components/ui/flex"` |
| Number formatting | `import { numberFormat } from "@/lib/utils/numbers"` |
| Date formatting | `import { dateTimeFormat } from "@/lib/utils/date"` |
| RTL flip helper | `import { useRtlFlip } from "@/hooks/useRtlFlip"` |

For full decision trees with all the look-alike landmines, see `references/canonical-imports.md`.

## When to Load Each Reference

Pull these in only when the trigger applies. Don't read them all upfront.

- **`references/canonical-imports.md`** -- Load when you're about to import a component and aren't sure which file is canonical (Card, Modal, Tooltip, Hero, Tabs all have multiple plausible imports).
- **`references/components.md`** -- Load when you need the full inventory: what each component is for, its variants, its canonical usage example.
- **`references/tokens.md`** -- Load when you need to add a new token, define a gradient, choose a z-index, or are unsure which semantic token applies. Also: when working in `src/styles/`.
- **`references/spacing-typography.md`** -- Load when laying out a page or section, deciding heading sizes, choosing spacing rhythms, or working with text density.
- **`references/gotchas.md`** -- Load when you hit unexpected behavior in a primitive (auto-blur backdrop, slot-prop coupling, hidden client boundary, etc.) or want the long-tail confusion patterns beyond what's inline above.
- **`references/variant-vs-new.md`** -- Load when you're tempted to create a new component file. Read this first to confirm whether a variant is the right answer.
- **`references/cleanup-playbook.md`** -- Load when refactoring existing code that has anti-patterns (one-off styling, raw `<a>`/`<button>`, hex colors, hard-coded English, etc.). The "old pattern -> new pattern" map.
- **`references/i18n-rtl.md`** -- Load when adding user-facing text, formatting numbers/dates, working with directional spacing, or writing translation keys.
- **`references/server-vs-client.md`** -- Load when deciding whether to mark a component `"use client"`, structuring a page that mixes static and interactive parts, or refactoring across the SSR boundary.
- **`references/a11y.md`** -- Load when adding interactive elements (modals, dropdowns, custom click targets), building forms, or working with images and headings.
- **`references/card-walkthrough.md`** -- Load when starting any card-shaped UI work; an end-to-end worked example.
- **`references/page-hero-walkthrough.md`** -- Load when starting a new page that needs a hero; an end-to-end worked example.
- **`references/new-component-checklist.md`** -- Load before opening a PR for a new component. The pre-merge checklist.

## Other Project Skills That May Apply

- **`data-layer`** -- For data fetching/sources. UI work that needs data should compose with this.

## Pre-Merge Smoke Test

Before opening a PR for any UI work:

- [ ] No raw `<a>` or `<button>`
- [ ] No hard-coded colors (`#hex`, `rgb()`, `hsla()`); semantic tokens only
- [ ] No `left-`/`right-`/`ml-`/`mr-`/`pl-`/`pr-` (use logical equivalents)
- [ ] All user-facing strings translatable
- [ ] `numberFormat()`/`dateTimeFormat()` for formatting (not native APIs)
- [ ] Server Components wherever possible
- [ ] New UI primitives have a `.stories.tsx`
- [ ] Headings use `<h1>`-`<h6>` (not `<div className="text-5xl font-bold">`)
- [ ] If introducing a new component, justify why it isn't a variant of an existing one
76 changes: 76 additions & 0 deletions .claude/skills/design-system/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"skill_name": "design-system",
"notes": "Output evals for the design-system skill. Each prompt exercises a distinct high-leverage rule (variant-over-new, canonical imports, i18n/RTL, hero family + server/client, modal disambiguation + raw-element ban).",
"evals": [
{
"id": 1,
"name": "variant-over-new-stat-block",
"prompt": "hey can you add a little stat block to the bottom of app/[locale]/staking/page.tsx? something like \"$45.2B\" on top in big bold text and \"ETH staked\" underneath. I want it centered. probably just spin up a new component for it, call it StakingStat or something",
"expected_output": "Skill should resist the 'new component' framing and steer toward the existing BigNumber primitive (and/or Card composition). Should also flag that user-facing strings need translation and that the number should go through numberFormat() if it isn't a static literal.",
"files": [],
"assertions": [
{"text": "Output uses BigNumber primitive (imports from @/components/BigNumber)"},
{"text": "Output does NOT create a new top-level component named StakingStat (or similar) in outputs"},
{"text": "All visible English strings ('ETH staked', any label) routed through a next-intl translation function (getTranslations or t())"},
{"text": "Output does NOT inline a div with `text-5xl font-bold` (or similar manual size+weight) for the stat number"},
{"text": "notes.md explicitly names BigNumber (and/or Card) as the chosen primitive and explains the pushback on the user's 'new component' framing"}
]
},
{
"id": 2,
"name": "tooltip-canonical-import",
"prompt": "the \"Bridge\" button in src/components/Nav/Mobile/index.tsx needs a little info tooltip on hover explaining what bridging is. can you wire that up? grab whatever tooltip the project uses",
"expected_output": "Skill should pick the high-level Tooltip default export from @/components/Tooltip (mobile-aware, Matomo-tracked, scroll-close) and explicitly NOT import { Tooltip } from @/components/ui/tooltip (which is the bare Radix primitive). Translatable copy expected.",
"files": [],
"assertions": [
{"text": "Output imports Tooltip from `@/components/Tooltip` (the mobile-aware default export)"},
{"text": "Output does NOT import from `@/components/ui/tooltip` (which is the bare Radix primitive)"},
{"text": "Tooltip content text is routed through a next-intl translation function (not a hard-coded English literal)"},
{"text": "notes.md justifies the Tooltip import choice and references the mobile-aware/Matomo-tracked rationale"}
]
},
{
"id": 3,
"name": "i18n-rtl-and-number-format",
"prompt": "on the contributors page (app/[locale]/contributing/page.tsx) i want to show \"There are 3,847 open issues right now\" with the number in bold. put it in a div with ml-4 mt-6 so it sits indented under the section heading. the count comes from a `count` variable thats already in scope",
"expected_output": "Skill should: (a) replace ml-4 with logical ms-4 (RTL safety), (b) replace the hard-coded English string with a getTranslations call and translation key, (c) format the count with numberFormat() rather than toLocaleString or a raw interpolation, (d) NOT recommend Intl.NumberFormat directly.",
"files": [],
"assertions": [
{"text": "Output uses `ms-4` (logical inline-start) instead of `ml-4`"},
{"text": "Output does NOT contain `ml-4`, `pl-`, `pr-`, `mr-`, `left-`, or `right-` Tailwind classes"},
{"text": "Output imports and uses `numberFormat()` from `@/lib/utils/numbers`"},
{"text": "Output does NOT call `count.toLocaleString()` or `new Intl.NumberFormat(...)` directly"},
{"text": "User-facing English ('There are', 'open issues right now', 'open issues') is routed through `getTranslations` / `t()` / `t.rich()` rather than appearing as a bare JSX literal"}
]
},
{
"id": 4,
"name": "hero-family-and-server-component",
"prompt": "we're adding a new landing page at app/[locale]/community/online/page.tsx. it needs a hero up top with a title, a one-paragraph blurb, an illustration on the right (use public/images/community.png), and two CTA buttons. should i build the hero from scratch or is there a pattern? just give me the file",
"expected_output": "Skill should recommend a hero from the @/components/Hero family (likely ContentHero or SimpleHero depending on shape) and explicitly avoid @/components/PageHero (deprecation track). Should keep the page as a Server Component (no 'use client'), use ButtonLink for CTAs (not raw <a>), and use getTranslations for copy.",
"files": [],
"assertions": [
{"text": "Output imports a hero from the `@/components/Hero` family (ContentHero, SimpleHero, HubHero, or MdxHero)"},
{"text": "Output does NOT import from `@/components/PageHero` (deprecation track)"},
{"text": "Output does NOT include `'use client'` at the top of page.tsx (Server Component default)"},
{"text": "CTA buttons use `ButtonLink` (or are passed via the hero's `buttons` prop), not raw `<a>` or `<button>` elements"},
{"text": "Page-level copy (title, blurb, CTA labels) is routed through `getTranslations` from `next-intl/server`"}
]
},
{
"id": 5,
"name": "modal-import-and-no-raw-button",
"prompt": "add a \"Delete account\" button to src/components/Profile/Settings.tsx that pops a confirm dialog (\"are you sure? this cant be undone\"). on confirm it calls deleteAccount(). use a plain html button for the trigger and a basic radix dialog, im not sure what this repo prefers",
"expected_output": "Skill should refuse the raw <button> in favor of <Button> from @/components/ui/buttons/Button, and pick the high-level Modal default export from @/components/ui/dialog-modal rather than wiring up @/components/ui/dialog from scratch. Should mark the file 'use client' (interactivity) and use translations for the prompts.",
"files": [],
"assertions": [
{"text": "Output imports the default-export `Modal` from `@/components/ui/dialog-modal`"},
{"text": "Output does NOT import named primitives directly from `@/components/ui/dialog` or `@radix-ui/react-dialog`"},
{"text": "Trigger uses `<Button>` from `@/components/ui/buttons/Button` (NOT a raw `<button>` element)"},
{"text": "File begins with `'use client'` (needed for useState + handlers)"},
{"text": "All user-facing copy ('Delete account' label, 'are you sure?' warning, confirm/cancel) routed through `useTranslations` (no hard-coded English literals in JSX)"},
{"text": "notes.md explicitly notes the pushback on the user's 'plain html button' and 'basic radix dialog' framing"}
]
}
]
}
Loading
Loading