Skip to content

Commit 7f63610

Browse files
Merge remote-tracking branch 'origin/dev' into consolidate-security-future-proofing
2 parents 375144d + b0c749f commit 7f63610

562 files changed

Lines changed: 36778 additions & 2299 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/design-system/references/card-walkthrough.md

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ Card <- parent wrapper, owns CSS vars + variant
2525
import {
2626
Card,
2727
CardBanner,
28+
CardButtonFake,
2829
CardContent,
2930
CardFooter,
3031
CardHeader,
3132
CardParagraph,
3233
CardTitle,
3334
} from "@/components/ui/card"
3435

35-
<Card href="/articles/proof-of-stake">
36+
{/* single CTA: href (whole card links) + CardButtonFake + hoverLift (carries an action) */}
37+
<Card href="/articles/proof-of-stake" hoverLift>
3638
<CardHeader>
3739
<CardBanner background="accent-a">
3840
<Image src={posIllustration} alt="Stylized validators staking ETH" />
@@ -43,7 +45,7 @@ import {
4345
<CardParagraph>How Ethereum secures the network.</CardParagraph>
4446
</CardContent>
4547
<CardFooter>
46-
<ButtonLink href="/learn-more">Learn more</ButtonLink>
48+
<CardButtonFake>Learn more</CardButtonFake>
4749
</CardFooter>
4850
</Card>
4951
```
@@ -52,13 +54,33 @@ import {
5254

5355
When you pass `href`, `Card` automatically wraps in `BaseLink` and adds a `group/link` class so descendants can react to card-level hover/focus. You don't need to nest your own `<a>` for "whole card clickable" cards.
5456

57+
**Two hover signals that can stack -- keep their meanings distinct:**
58+
59+
- **`hoverLift`** (the card raises on hover) means the card **carries an action** -- a CTA button or a text link. Add it to *any* card that holds an action, whether or not the card is also a link.
60+
- An **outline ring** (or, on a `ghost` card, a **`bg-background-highlight` fill**) means the **whole card is the click target**. An `href` `Card` renders this automatically (see Interaction props). It's *additive* to `hoverLift`, not an alternative.
61+
62+
**The number of actions decides the shape. This is the canonical rule -- follow it for every new/refactored card:**
63+
64+
- **One CTA -> the whole card is the link, and it carries an action.** Put `href` on the `Card` (automatic outline/fill), render the footer button as a **`CardButtonFake`** (not a `ButtonLink`/`Button` -- a real interactive `<a>`/`<button>` nested inside the card's own anchor is invalid HTML with broken keyboard/hit-target behavior; `CardButtonFake` is a presentational `<div>` mirror of `Button` via `asChild` that inherits the card's `group/link` hover), **and** add `hoverLift` so the card advertises the action it carries. (See the `InteractionPatterns` story.)
65+
- **Two or more CTAs -> the card is NOT a link, but carries actions.** Multiple independent actions can't collapse into one card-level link, so drop `href`, use real `ButtonLink`s (each its own target), and add `hoverLift`. Reach for `CardFooter buttons="compact"` when they should sit inline.
66+
- **No button, action is an inline link in the copy -> non-link card that carries an action.** Keep the card a plain `<div>` (no `href`), use an `InlineLink` in the copy, and add `hoverLift`. No outline ring, which would falsely imply the entire card is a single link.
67+
68+
(A **pure navigation card** -- an `href` card with no interior button or link, e.g. a media/thumbnail tile -- is the one case that skips `hoverLift`: there's no interior action to advertise, so the automatic outline/fill alone carries the "whole card is clickable" signal.)
69+
70+
**Rule: a link card needs interior padding, and its banner belongs in a `CardHeader`.** Because an `href` card renders a variant-aware hover state (fill or ring) tight to its outer edge, its contents must sit inset from that edge. In practice:
71+
72+
- If a link card contains a `CardBanner`, **wrap it in a `CardHeader` by default** so the `--card-pad` inset keeps the banner clear of the hover treatment and its corners concentric with the card. Don't drop a bare `CardBanner` straight into an `href` `Card`.
73+
- Avoid `size="xs"` (which zeroes `--card-pad`) on a link card unless the layout supplies padding another way.
74+
75+
The sanctioned exceptions are cards that arrange the banner differently and **bake the padding into the banner itself** rather than relying on a `CardHeader` -- e.g. `PathwayCard` (`src/components/cards/pathway-card.tsx`), whose banner sits beside the text in a container-query row layout and carries its own `p-*`. If you're building a new bespoke link-card arrangement, follow that model: inset the banner somehow, don't leave it flush.
76+
5577
## Step 2: Pick the `variant`
5678

5779
| Variant | When to use |
5880
|---|---|
5981
| `base` (default) | Standard card on the default page background. Gives `bg-background-highlight` (grey). |
6082
| `nested` | When the Card sits inside a section that already has a non-default background. Gives `bg-background` (white in light, black in dark) so it visually pops out from the colored container. |
61-
| `ghost` | No background. The Card behaves like an outlined container. `--banner-radius` is automatically widened (since the banner's edge IS the card's edge), so any `CardBanner` matches the outer corner radius. |
83+
| `ghost` | No background -- a transparent, outlined-style container. `--banner-radius` is automatically widened so an edge-to-edge `CardBanner` matches the outer corner radius. As a **link** (`href`), a ghost card fills with `bg-background-highlight` on hover instead of showing an outline ring (see Interaction props). |
6284
| `header-bar` | "Top bar" appearance: only the `CardHeader` region gets the highlight background, the rest is bordered, and the header gets row layout (icon + text) with a bottom border. The variant bakes in all of this — just drop a `CardHeader` inside, no extra props. |
6385

6486
## Step 3: Pick the `size`
@@ -75,18 +97,23 @@ When you pass `href`, `Card` automatically wraps in `BaseLink` and adds a `group
7597
| `sm` | 10px | 10px | Compact list cards |
7698
| `xs` | 0 | 4px | No padding; use when the banner image needs to extend to all edges of the Card |
7799

78-
### Interaction props: `border`, `hoverLift` (and the automatic `href` hover ring)
100+
### Interaction props: `border`, `hoverLift` (and the automatic `href` hover state)
79101

80102
Two independent booleans layer edge/interaction treatment on top of `variant`/`size` -- they're additive, so combine freely:
81103

82104
| Prop | Effect | When |
83105
|---|---|---|
84106
| `border` | Static hairline edge (`ring ring-border`). It's a `ring`, so it never shifts layout. | A resting outline on `nested`/`ghost` cards that need definition against their background. |
85-
| `hoverLift` | `hover:shadow-md` plus a subtle `hover:scale`. | Cards that hold their own actions instead of being a link themselves: multiple action buttons, or text-link actions in the body/footer. The lift signals "interactive content here" without implying the whole card is clickable. |
107+
| `hoverLift` | `hover:shadow-md` plus a subtle `hover:scale`. | Any card that carries an action: a single CTA (alongside the automatic `href` outline/fill), multiple action buttons, or a text link in the body. The lift signals "interactive content here." Skip it only on a pure navigation card (an `href` tile with no interior button/link), where the outline/fill alone carries the signal. |
108+
109+
**There is no `hoverOutline` prop anymore.** Every Card with an `href` gets a hover affordance automatically, chosen by `variant` -- you don't set it:
110+
111+
- **`ghost` link cards fill on hover** with `bg-background-highlight` and show *no* outline. A ghost card is transparent at rest, so the fill is the affordance; an offset ring would clip against a flush banner and read as noise. This is the treatment for the site's media/link cards (video, hackathon, story, and latest-article grids).
112+
- **`base`, `nested`, and `header-bar` link cards keep the outline ring** (`ring-transparent` at rest -> `ring-primary-hover` on hover). They already sit on a fill, so a hover fill would be invisible; the ring is what reads.
86113

87-
**There is no `hoverOutline` prop anymore.** Every Card with an `href` gets the hover ring automatically (transparent at rest, `ring-primary-hover` on hover) -- it's baked into the link render path, not a variant you set. It also can't be applied to non-link cards by hand; if a card isn't a link, use `hoverLift` (or nothing).
114+
It's implemented with `compoundVariants` keyed on an internal `interactive` flag (set from `href`, omitted from the public `CardProps`) -- not a variant you pass, and not a class you can hand-apply to a non-link card. The outline/fill is additive to `hoverLift`: a single-CTA link card gets both (ring/fill from `href`, lift from `hoverLift`); a non-link card that carries an action gets `hoverLift` alone.
88115

89-
`border` composes with the automatic ring: on an `href` Card, the `ring-border` shows at rest and `hover:ring-primary-hover` takes over on hover, reading as a resting border that brightens to primary. These props replaced the old `hoverEffect="lift"` prop.
116+
`border` composes with the ring on non-ghost link cards: the `ring-border` shows at rest and `hover:ring-primary-hover` takes over, reading as a resting border that brightens to primary. On a `ghost` link card there is no hover ring, so a `border` simply stays as the resting outline while the highlight fills behind it. These props replaced the old `hoverEffect="lift"` prop.
90117

91118
## Step 4: Border Radius "Just Works"
92119

@@ -130,17 +157,39 @@ When *should* you use `className`? Things that are genuinely outside the Card's
130157
### `CardFooter`
131158

132159
```tsx
160+
{/* single CTA on an href + hoverLift card */}
133161
<CardFooter>
134-
<ButtonLink href="...">CTA</ButtonLink>
162+
<CardButtonFake>CTA</CardButtonFake>
163+
</CardFooter>
164+
{/* two CTAs on a non-link, hoverLift card */}
165+
<CardFooter buttons="compact">
166+
<ButtonLink href="...">Primary</ButtonLink>
167+
<ButtonLink href="..." variant="outline">Secondary</ButtonLink>
135168
</CardFooter>
136-
<CardFooter buttons="compact">...</CardFooter>
137169
```
138170

139171
- Inherits `--card-pad` from the parent.
140172
- Defaults to `padding-top: 0` (mirrors `CardHeader`'s `padding-bottom: 0`); the gap between Content and Footer comes from Content's bottom-pad. The parent restores the top-pad automatically for `variant="header-bar"`.
141-
- `buttons="full"` (default): buttons and ButtonLinks stretch to full width and center their text. Use this when you want CTAs to span the card.
142-
- `buttons="compact"`: buttons size to fit their content. Use for trailing-link style or when the button shouldn't dominate.
173+
- `buttons="full"` (default): buttons, `ButtonLink`s, and `CardButtonFake`s stretch to full width and center their text. Use this when you want CTAs to span the card.
174+
- `buttons="compact"`: buttons size to fit their content. Use for trailing-link style, multi-button footers, or when the button shouldn't dominate.
143175
- `buttons="inherit"`: opt out of both — buttons render with their own intrinsic width.
176+
- The layout variants target both real buttons (`[button]`) and `CardButtonFake` (`[data-label=button-link]`), so they apply whichever you use. Which one to use is the single-vs-multiple-CTA rule under [`Card` with `href`](#card-with-href): one CTA -> `CardButtonFake` in an `href` card; two+ -> real `ButtonLink`s in a non-link card.
177+
178+
### `CardButtonFake`
179+
180+
```tsx
181+
<Card href="/proof-of-stake" hoverLift>
182+
...
183+
<CardFooter>
184+
<CardButtonFake>Learn more</CardButtonFake>
185+
</CardFooter>
186+
</Card>
187+
```
188+
189+
- The **only** correct way to render a footer CTA inside an `href` `Card`. It's a presentational `<div>` mirror of `Button` (rendered via `Button asChild`), so it reuses every Button style but is **not** an interactive element -- no nested `<a>`/`<button>` inside the card's anchor.
190+
- Accepts Button's `variant`, `size`, and `isSecondary`. It carries `data-label="button-link"`, so `CardFooter`'s `buttons` layout variants size it exactly like a real button.
191+
- Button's `hover-link` styling fires from the card's `group/link`, so hovering anywhere on the card animates the fake button identically to a real hover -- you don't (and can't) hand-wire hover styles onto it.
192+
- Never use it on a non-link card (there's nothing to click) and never use a real `Button`/`ButtonLink` as the sole CTA of an `href` card (invalid nested anchor). See the single-vs-multiple-CTA rule above.
144193

145194
### `CardBanner`
146195

@@ -158,8 +207,8 @@ When *should* you use `className`? Things that are genuinely outside the Card's
158207
- `fit="contain"` with a *single* `<Image>` child triggers an auto-blurred-backdrop effect: the same image is cloned, scaled, blurred, and placed behind to fill any letterboxing. If you pass two children, you lose this magic and need to provide your own backdrop.
159208
- `zoom`: `true` (default) propagates the parent `group/link` hover/focus into an image scale-up; pass `zoom={false}` when the art shouldn't move.
160209
- Placement:
161-
- Inside `CardHeader` (most common): the banner respects `--card-pad` and gets `--banner-radius`-rounded corners.
162-
- As a direct child of `Card` (no `CardHeader` wrapper): the banner extends to the card's edges (no padding). Pair with `Card size="xs"` for an edge-to-edge image card, or use `variant="ghost"` for an outlined edge-to-edge look.
210+
- **Inside `CardHeader` (the default -- prefer this):** the banner insets by `--card-pad` and its `--banner-radius` corners stay concentric with the card's outer radius. Required on **link** cards (`href`): the inset keeps the hover state clear of the image and the corner radii aligned.
211+
- As a bare direct child of `Card` (no `CardHeader`): the banner extends flush to the card's edges. Only safe for a true edge-to-edge image, and pair it with `Card size="xs"` (`--card-pad: 0`) so the banner radius and the card's outer radius match. A bare banner on a padded size (`sm`/`md`/`base`) clips at `--banner-radius` while the card corner is `--card-pad + --banner-radius` -- a visible mismatch, and on a link card the hover treatment has no room to breathe. (See the bare-banner gotcha.)
163212

164213
### `CardEmoji`
165214

@@ -253,10 +302,11 @@ import { Sparkles } from "lucide-react"
253302

254303
- [ ] Imports from `@/components/ui/card` (NOT `@/components/MarkdownCard` — that's the MDX shortcode wrapper)
255304
- [ ] If linkable, uses `Card href="..."` (not a wrapping `<a>`)
305+
- [ ] `hoverLift` on every card that carries an action (single CTA, multi-CTA, or text-link) -- skip it only on a pure navigation card with no interior button/link. Single CTA -> `href` + `CardButtonFake` + `hoverLift` (never a real `ButtonLink`/`Button` -- no nested anchor); two+ CTAs -> no `href`, real `ButtonLink`s + `hoverLift`; text-link-only -> no `href`, `InlineLink` + `hoverLift`
256306
- [ ] No `className` on `Card`/parts that adjusts padding, background, spacing, border-radius, or text color — those go through variants
257307
- [ ] Heading is `CardTitle`; uses `asChild` if `<h3>` would break heading outline
258308
- [ ] Description is `CardParagraph`
259-
- [ ] Image lives in a `CardBanner` (inside `CardHeader` for padded, or as a direct child of `Card` with `size="xs"` or `variant="ghost"` for edge-to-edge)
309+
- [ ] Image lives in a `CardBanner`, wrapped in `CardHeader` (the default -- keeps banner radius concentric and gives link-hover room). Bare banner only for a true edge-to-edge image, paired with `size="xs"` so radii match
260310
- [ ] If image needs containment, `fit="contain"` is used to get the auto-blur backdrop
261311
- [ ] If you added a new variant case to `card.tsx`, story coverage is updated
262312
- [ ] Tested in light AND dark mode

.claude/skills/design-system/references/components.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ The canonical card primitive. **Driven by CSS variables set on `Card`** (`--card
9292

9393
**`Card` variants**:
9494

95-
- `variant`: `base` (default, `bg-background-highlight` grey) | `nested` (`bg-background`, use when inside a colored section) | `ghost` (no bg; auto-widens `--banner-radius` for edge-to-edge banners) | `header-bar` (highlight only on the header, bordered card, header laid out as an icon+text row with bottom border — all baked in, just drop a `CardHeader` inside).
95+
- `variant`: `base` (default, `bg-background-highlight` grey) | `nested` (`bg-background`, use when inside a colored section) | `ghost` (no bg; auto-widens `--banner-radius`; as a link, fills with `bg-background-highlight` on hover instead of an outline ring) | `header-bar` (highlight only on the header, bordered card, header laid out as an icon+text row with bottom border — all baked in, just drop a `CardHeader` inside).
96+
- **Link hover is variant-aware** (auto, from `href`): `ghost` link cards fill with `bg-background-highlight` and drop the outline; `base`/`nested`/`header-bar` link cards keep the `ring-primary-hover` outline. Driven by an internal `interactive` compound variant, not a prop.
9697
- `size`: `lg | base (default) | md | sm | xs`. Controls `--card-pad` (between/around parts) and `--content-space` (within `CardContent`). `xs` = zero padding for edge-to-edge banner imagery.
9798
- `href`: pass to wrap in `BaseLink` and get whole-card-clickable behavior with `group/link` propagation.
9899
- Card is always vertical (`flex flex-col`); there is no `orientation` variant.
@@ -115,7 +116,7 @@ The canonical card primitive. **Driven by CSS variables set on `Card`** (`--card
115116
- `size`: `full | lg | base (default) | sm | thumbnail-lg | thumbnail`. Use these instead of `className="h-..."` to stay on-rhythm. `thumbnail-lg` is a 128px square; `thumbnail` is 64px — both `shrink-0` for small logo/icon placements.
116117
- `fit`: `cover (default) | contain`. With `fit="contain"` and a single `<Image>` child, the banner auto-clones the image as a blurred backdrop. Two children breaks the magic.
117118
- `zoom`: `true (default) | false`. Controls hover zoom propagation from a parent `group/link`.
118-
- Placement: inside `CardHeader` for padded; as a direct child of `Card` (pair with `Card size="xs"` or `variant="ghost"`) for edge-to-edge.
119+
- Placement: inside `CardHeader` (default; keeps banner radius concentric and gives link-hover room). Bare direct child of `Card` only for a true edge-to-edge image, paired with `size="xs"` so radii match (a bare banner on a padded size / link card mismatches corner radius — see gotchas).
119120

120121
**`CardTitle` variants**:
121122

@@ -670,6 +671,8 @@ import { Swiper, ... } from "@/components/ui/swiper"
670671

671672
`"use client"`, wraps swiper.js. On the deprecation track. Migrate existing consumers to `EdgeScrollContainer`.
672673

674+
**i18n requirement:** `Swiper` binds `useTranslations("component-swiper")` for its a11y slide announcements. Every page that renders one must ship that namespace to the client — register the route in `src/lib/utils/translations.ts` (`EXACT_PATH_ADDITIONAL_NAMESPACES` / `PREFIX_PATH_ADDITIONAL_NAMESPACES`) or add `"component-swiper"` to the page's `pick()` for its `I18nProvider`. Missing it means screen readers announce raw key tails (`swiper-next-slide`). Currently registered: `/` (hand-pick), `/developers/` (scoped providers), `/start/`, `/10years/`, `/apps/`.
675+
673676
### `List` / `OrderedList` / `UnorderedList` / `ListItem`
674677

675678
```tsx

.claude/skills/design-system/references/gotchas.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ In `Button.tsx` lines 67-69, there's an early-out: `["solid", "link"].includes(v
3838

3939
If you pass `<CardBanner fit="contain"><Image .../></CardBanner>` with **one** child, you get a blurred-bg + sharp-fg automatically (Card.tsx lines 95-119). Pass two children and you lose this magic.
4040

41+
### A bare `CardBanner` on a link card mismatches its corner radius
42+
43+
`Card`'s outer radius is `--card-pad + --banner-radius`, but a `CardBanner` rounds at only `--banner-radius`. Wrapping the banner in a `CardHeader` lets the `--card-pad` inset bridge the difference so the corners stay concentric. Drop the banner straight into `Card` (no header) on any padded size and the banner's squarer corners poke past the card outline; add an `href` and the variant-aware hover has no inset to breathe against. Only go bare with `size="xs"` (`--card-pad: 0`), where the two radii collapse to the same value. This bit the video / hackathon / story / latest card grids until their banners were wrapped in `CardHeader` (July 2026).
44+
4145
### `LinkBox` requires `LinkOverlay` somewhere inside
4246

4347
The whole-card-clickable pattern uses a `before:absolute` pseudo-element on `LinkOverlay`. Use `LinkBox` without `LinkOverlay` and the click-the-whole-card behavior breaks.

.claude/skills/design-system/references/tokens.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ This is where you get colors for Claude-driven work. Light mode in `:root`, dark
6565
- `--background-low`
6666
- `--background-medium`
6767
- `--background-high`
68+
- `--overlay` (modal/sheet backdrop dim; the one token with alpha baked in -- use `bg-overlay` as-is, no `/opacity` modifier, no `dark:` needed)
6869

6970
**Borders:**
7071
- `--border`

0 commit comments

Comments
 (0)