Skip to content

Commit 58c2236

Browse files
peterzimonclaude
andcommitted
Added Shade conventions skills for AI agents
ref https://linear.app/ghost/issue/DES-1394 - 10 auto-triggering skills under .agents/skills/shade-* encode Shade design system rules so AI agents follow them at the moment of edit, without needing to discover apps/shade/AGENTS.md. - Tier 1 (broad triggers): shade-imports, shade-tokens-not-hex, shade-no-dark-variants, shade-use-primitives, shade-component-decision. - Tier 2 (narrow, high-payoff triggers): shade-new-component, shade-shadcn-install, shade-page-templates, shade-input-surface-recipe, shade-dropdown-surface-contract. - Triggers scoped to actual Shade-consuming apps (shade, admin, admin-x-settings, admin-x-framework, posts, stats, activitypub). Public apps and the legacy admin-x-design-system are excluded to avoid false positives. - AGENTS.md: fixed stale ListPage import (now from /page-templates), removed ListPage from Patterns examples, documented page-templates and posts-stats as additional barrels. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d2163f5 commit 58c2236

21 files changed

Lines changed: 820 additions & 3 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
name: Shade component decision
3+
description: Decide which Shade layer (Token, Primitive, Component, Recipe, Pattern) a new piece of UI belongs in — and whether it should be added to Shade at all. Trigger when creating new files in apps/shade/src/components, or proposing to add a new Shade component.
4+
autoTrigger:
5+
- fileEdit: "apps/shade/src/components/**/*.{ts,tsx}"
6+
---
7+
8+
# Shade — pick the right layer (and decide whether to add to Shade at all)
9+
10+
Before adding anything to Shade, walk the decision flow and the promotion checklist. The default is **keep code local first** — premature design system additions lock in the wrong API.
11+
12+
## The five layers
13+
14+
| Layer | Lives in | Examples |
15+
|---|---|---|
16+
| **Token** | `theme-variables.css`, `tailwind.theme.css` | `--background`, `--text-base`, `--radius-md` |
17+
| **Primitive** | `src/components/primitives/` | `Stack`, `Inline`, `Box`, `Grid`, `Container`, `Text` |
18+
| **Component** | `src/components/ui/` | `Button`, `Input`, `Dialog`, `Tabs`, `Card` |
19+
| **Recipe** | `src/components/ui/<name>.ts` (no JSX) | `inputSurface` |
20+
| **Pattern** | `src/components/patterns/` | `PageHeader`, `ListPage`, `KpiCard`, `Filters` |
21+
22+
Plus two additional barrels:
23+
24+
- **`page-templates/`** (`@tryghost/shade/page-templates`) — top-level page wrappers (`ListPage` today). Composes Patterns + Components + Primitives. If you're building a new shape that wraps a whole admin page, this is where it goes. See `shade-page-templates`.
25+
- **`posts-stats/`** (`@tryghost/shade/posts-stats`) — transitional layer for components shared between `apps/posts` and `apps/stats` until those merge. Don't add to it unless the file is genuinely posts-or-stats-only and shared between both apps.
26+
27+
## Decision flow (top-to-bottom, stop at first match)
28+
29+
1. **Just a colour, size, radius, duration?****Token**. Add to `theme-variables.css` (semantic) or `tailwind.theme.css` (raw `@theme`).
30+
2. **Layout-only (spacing, alignment, structure)?****Primitive**. Reuse an existing one; only add a new one if the structural shape is genuinely novel.
31+
3. **Generic, accessible UI control with no Ghost-specific knowledge?****Component**. Reuse first; only add if it doesn't exist and the promotion checklist below passes.
32+
4. **The same chrome / focus / density rule shared across ≥ 2 components?****Recipe**. A class-string function (no JSX, no React) next to the components in `src/components/ui/`.
33+
5. **Knows about Ghost (KPIs, members, posts, newsletters, analytics)?****Pattern**.
34+
35+
**Quick gut check: generic name → Component; Ghost-shaped name → Pattern.** `Button` is web-y; `KpiCard` is Ghost-y.
36+
37+
## Allowed dependencies (one-way)
38+
39+
Each layer can use anything **below** it. The reverse is forbidden.
40+
41+
- A Button can't reach into a Pattern.
42+
- A Primitive can't import a Component.
43+
- A Recipe is pure CSS classes — no React, no JSX.
44+
45+
## Promotion checklist — add to Shade only if ALL are true
46+
47+
1. **Reused at least twice in different surfaces.** Actual second use, not "we might reuse this."
48+
2. **It's generic.** A `<MembersTable>` that's just `<Table>` with three preset columns belongs in the app, not Shade.
49+
3. **The shape has settled.** Slots and composition have been stable across both local copies for at least one iteration.
50+
4. **It has a generic name.** `PageHeader`, `KpiCard`, `PostShareModal` — not `MembersFilterBar` or `PostAnalyticsHero`.
51+
5. **The API is slots, not props.** 3–6 named subcomponents (`.Title`, `.Actions`, `.Body`) — not `<ListPage title="..." onAdd={...} columns={...} />`.
52+
6. **State stays with the consumer.** No `useQuery`, no routing, no app-context reads inside Shade.
53+
54+
**Fail any of these? Keep it local.** Build it again somewhere else first, then promote.
55+
56+
## Common misclassifications
57+
58+
| Tempting | Actually | Why |
59+
|---|---|---|
60+
| Add a `variant="kpi"` to `Card` | New Pattern `KpiCard` | Product-specific shape, generic Component shouldn't know about it |
61+
| Add `<MembersFilterBar>` to patterns | Keep local in `apps/admin-x-settings` | Single-surface name |
62+
| Add a one-off class string as a recipe | Inline it in the one component | Recipes are for shared rules across ≥ 2 components |
63+
| Add a `useQuery`-driven `<MembersList>` to patterns | Keep local — patterns are state-free | Bring-your-own state |
64+
| Wrap a `<div className="flex gap-3">` as a new primitive | Use `Inline gap="md"` | Already covered |
65+
66+
## When you've decided
67+
68+
- New **Component** or **Pattern** → also see the `shade-new-component` skill for the file/story/state checklist.
69+
- New **Recipe** → no JSX, follow the `recipeClasses` + `recipe()` shape (see `apps/shade/src/components/ui/input-surface.ts`).
70+
- New **Token** → add to `apps/shade/theme-variables.css` (semantic + dark-mode override) or `apps/shade/tailwind.theme.css` (raw `@theme`). Never an ad-hoc CSS variable in a component file.
71+
72+
## Source of truth
73+
74+
Full rules: `apps/shade/AGENTS.md`. Human-facing: Storybook → Overview / Layers.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
name: Shade dropdown surface contract
3+
description: DropdownMenu, Select, and Popover share one visual recipe (bg-surface-elevated-2 + border-border/60 dark:border-border/30 + shadow-md). Change them together. Trigger when editing any of those three Shade files.
4+
autoTrigger:
5+
- fileEdit: "apps/shade/src/components/ui/{dropdown-menu,select,popover}.tsx"
6+
---
7+
8+
# Shade — dropdown surface contract
9+
10+
`DropdownMenu`, `Select`, and `Popover` are the three Radix-backed floating menus that `Filters` and the rest of admin use. They share **one visual recipe**:
11+
12+
```
13+
bg-surface-elevated-2
14+
border border-border/60 dark:border-border/30
15+
shadow-md
16+
```
17+
18+
Treat them as one component for any surface decision. **If you change the surface for one, change all three.**
19+
20+
## Why this exists
21+
22+
Floating menus open ON TOP of an already-elevated surface (sidebars, cards, top bars). They need to read as floating above their trigger. `--surface-elevated-2` is reserved for exactly this — popovers, dropdown content, and the sidebar user menu.
23+
24+
In light mode the three surface tokens (`background` / `surface-elevated` / `surface-elevated-2`) flatten to near-white; the border + `shadow-md` carry elevation. In dark mode they're three distinct colours so layered surfaces stay legible without relying on borders alone.
25+
26+
## The contract
27+
28+
| Concern | Class |
29+
|---|---|
30+
| Background | `bg-surface-elevated-2` |
31+
| Border | `border border-border/60 dark:border-border/30` |
32+
| Shadow | `shadow-md` baseline. **`DropdownMenuContent` is the exception — it uses `shadow-lg`.** `DropdownMenuSubContent`, `SelectContent`, and `PopoverContent` all use `shadow-md`. |
33+
| Radius | `rounded-md` (component default) |
34+
| Animation | Standard Radix `data-[state=open]:` enter/exit set |
35+
36+
The shadow split is intentional: the top-level `DropdownMenuContent` opens straight out of an unelevated trigger (a button on the page canvas) and needs the stronger drop. `DropdownMenuSubContent` already pops from inside a floating menu, so it uses the lighter `shadow-md` to avoid double-stacking elevation. `Select` and `Popover` also open from low-elevation triggers but their content sits closer to the trigger surface, so `shadow-md` is enough.
37+
38+
The `dark:border-border/30` is a legitimate exception to the no-`dark:`-for-colour rule — it's an opacity modifier on an existing semantic token. See `shade-no-dark-variants`.
39+
40+
## Changing the surface
41+
42+
If a design change touches the floating-menu surface (background, border, shadow, elevation), edit **all three** files:
43+
44+
- `apps/shade/src/components/ui/dropdown-menu.tsx` (`DropdownMenuContent` and `DropdownMenuSubContent`)
45+
- `apps/shade/src/components/ui/select.tsx` (`SelectContent`)
46+
- `apps/shade/src/components/ui/popover.tsx` (`PopoverContent`)
47+
48+
If a fourth Radix-backed surface needs the same recipe, extract it as a `floatingSurface` recipe under `apps/shade/src/components/ui/`. Two consumers is too thin to justify a Recipe; three is the current count and just covered by mirroring.
49+
50+
## Items inside the menu
51+
52+
Hover state on menu items uses `--interactive-hover` (`bg-interactive-hover`) — the generic hover token. **Don't** use raw greys or apply ad-hoc opacity modifiers (`hover:bg-gray-900/30`). The interactive tokens already handle dark-mode translucency so they composite over the floating surface correctly.
53+
54+
## Don't
55+
56+
```tsx
57+
// BAD — changing dropdown surface but not Select / Popover
58+
// In dropdown-menu.tsx:
59+
'bg-surface-elevated border border-border/40 shadow-xl' // <-- drifted from contract
60+
61+
// BAD — raw greys for hover instead of the interactive token
62+
'hover:bg-gray-100 dark:hover:bg-gray-800'
63+
64+
// BAD — surface-elevated (not -2) on a floating menu
65+
// loses the visual elevation above already-elevated triggers
66+
'bg-surface-elevated'
67+
```
68+
69+
## Related skill
70+
71+
Hover/active/selected state tokens (`--interactive-hover`, `--button-hover`, `--tab-hover`, `--table-row-hover`): see `shade-tokens-not-hex`.
72+
73+
## Source of truth
74+
75+
`apps/shade/AGENTS.md` (Tokens & dark mode → Dropdown surface contract). Storybook → Tokens / Tokens Guide.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
name: Shade imports
3+
description: Import Shade from layer-specific subpaths (primitives, components, patterns, page-templates, utils), never the root barrel. Trigger when editing TSX/TS in Shade-consuming apps.
4+
autoTrigger:
5+
- fileEdit: "apps/{shade,admin,admin-x-settings,admin-x-framework,posts,stats,activitypub}/**/*.{ts,tsx}"
6+
---
7+
8+
# Shade imports
9+
10+
Always import Shade from its **layer-specific subpath**. Never from the root barrel `@tryghost/shade`.
11+
12+
## Correct
13+
14+
```ts
15+
import {Stack, Inline, Box, Grid, Container, Text} from '@tryghost/shade/primitives';
16+
import {Button, Input, Dialog} from '@tryghost/shade/components';
17+
import {PageHeader, KpiCard, Filters} from '@tryghost/shade/patterns';
18+
import {ListPage} from '@tryghost/shade/page-templates';
19+
import {PostShareModal} from '@tryghost/shade/posts-stats';
20+
import {cn, formatNumber} from '@tryghost/shade/utils';
21+
import {ShadeApp} from '@tryghost/shade/app';
22+
```
23+
24+
**Note:** `ListPage` is in `@tryghost/shade/page-templates`, not `/patterns`. Page templates are top-level page wrappers; patterns are the building blocks they compose.
25+
26+
## Incorrect
27+
28+
```ts
29+
// BAD — root barrel
30+
import {Button, Stack} from '@tryghost/shade';
31+
32+
// BAD — deep path into source
33+
import {Button} from '@tryghost/shade/src/components/ui/button';
34+
```
35+
36+
## Inside Shade itself
37+
38+
When editing files under `apps/shade/src/**`, use the `@/` alias for cross-file imports:
39+
40+
```ts
41+
import {cn} from '@/lib/utils';
42+
import {inputSurface} from '@/components/ui/input-surface';
43+
```
44+
45+
## Why
46+
47+
Subpath imports make the layer (primitive/component/pattern) visible at the call site, which is the first piece of information a reader needs. The root barrel hides that and encourages cross-layer drift.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
name: Shade inputSurface recipe
3+
description: Use the inputSurface() recipe for form-control chrome (border, background, radius, focus ring, invalid state) — don't roll your own. Trigger when editing form-control-shaped files in Shade.
4+
autoTrigger:
5+
- fileEdit: "apps/shade/src/components/ui/{input,textarea,input-group,select,combobox,multi-select-combobox,dropzone,calendar,command}.tsx"
6+
---
7+
8+
# Shade — `inputSurface` recipe
9+
10+
Form controls share one visual rule: border, background, radius, transition, focus ring, invalid state. That rule lives in **`inputSurface`** (`apps/shade/src/components/ui/input-surface.ts`) and is used by `Input`, `Textarea`, `InputGroup`, and the `Select` trigger. Don't duplicate the chrome — compose the recipe.
11+
12+
```ts
13+
import {inputSurface, inputSurfaceClasses} from '@/components/ui/input-surface';
14+
import {cn} from '@/lib/utils';
15+
```
16+
17+
## Two common modes
18+
19+
### `inputSurface('self')` — directly on the focusable element
20+
21+
Use on `<input>`, `<textarea>`, or any element that itself receives focus.
22+
23+
```tsx
24+
<input
25+
className={cn(
26+
inputSurface('self'),
27+
'flex h-9 w-full px-3 py-1 text-control placeholder:text-muted-foreground',
28+
className
29+
)}
30+
{...props}
31+
/>
32+
```
33+
34+
Covers: base chrome + `focus-visible:` ring + `aria-[invalid=true]` styling + `disabled:` opacity.
35+
36+
### `inputSurface('within')` — on a wrapper containing a focusable child
37+
38+
Use when the styled element is the wrapper (e.g. `InputGroup`) and focus state should react to **any** focusable descendant via `:has()`.
39+
40+
```tsx
41+
<div className={cn(
42+
inputSurface('within'),
43+
'flex h-9 items-center gap-2 px-3',
44+
className
45+
)}>
46+
<Icon />
47+
<input className="bg-transparent outline-hidden focus:outline-hidden" />
48+
</div>
49+
```
50+
51+
Covers: base chrome + `has-[:focus-visible]:` ring + `has-[[aria-invalid=true]]:` styling. (No disabled atom — wrappers don't get a `disabled` HTML attribute.)
52+
53+
## Edge case — only one specific child triggers focus state
54+
55+
When `'within'` is too broad (e.g. a wrapper with multiple focusables but only one should drive chrome), compose atoms manually so Tailwind's JIT can statically detect the class string:
56+
57+
```tsx
58+
import {inputSurfaceClasses} from '@/components/ui/input-surface';
59+
60+
<div className={cn(
61+
inputSurfaceClasses.base,
62+
inputSurfaceClasses.invalidWithin,
63+
// Literal class string — Tailwind needs to see it as text
64+
'has-[[data-slot=control]:focus-visible]:border-focus-ring',
65+
'has-[[data-slot=control]:focus-visible]:ring-2',
66+
'has-[[data-slot=control]:focus-visible]:ring-focus-ring/25'
67+
)} />
68+
```
69+
70+
Available atoms:
71+
72+
- `inputSurfaceClasses.base` — border, background, radius, transition
73+
- `inputSurfaceClasses.focusSelf` — focus chrome for self mode
74+
- `inputSurfaceClasses.focusWithin` — focus chrome for within mode
75+
- `inputSurfaceClasses.invalidSelf``aria-[invalid=true]` styling on self
76+
- `inputSurfaceClasses.invalidWithin``aria-[invalid=true]` styling on a descendant
77+
- `inputSurfaceClasses.disabledSelf` — disabled styling for self mode
78+
79+
## What the recipe owns vs. what you add
80+
81+
| Recipe owns | You add |
82+
|---|---|
83+
| Border (`border-control-border`) | Height, padding |
84+
| Background (`bg-surface-elevated`, `dark:bg-transparent`) | Typography (`text-control`, `text-sm`) |
85+
| Radius (`rounded-md`) | Layout (`flex`, `items-center`) |
86+
| Transition (`transition-colors`) | Placeholder styling |
87+
| Focus ring (`focus-visible:ring-focus-ring/25`) | Component-specific tweaks |
88+
| Invalid state (`aria-[invalid=true]:border-destructive`) | Icons / slot positioning |
89+
| Disabled (`disabled:opacity-50`) — self only ||
90+
91+
## Don't
92+
93+
```tsx
94+
// BAD — rolling your own focus chrome on a form control
95+
<input className="
96+
rounded-md border border-input bg-background
97+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
98+
aria-[invalid=true]:border-red-500
99+
disabled:opacity-50
100+
" />
101+
102+
// BAD — using inputSurface('within') on a self-focused element (over-broad)
103+
<input className={cn(inputSurface('within'), 'h-9')} />
104+
105+
// BAD — non-literal class concatenation that Tailwind JIT can't see
106+
const dyn = `has-[[data-slot=control]:focus-visible]:${ringColor}`;
107+
```
108+
109+
## Source of truth
110+
111+
`apps/shade/src/components/ui/input-surface.ts` — the JSDoc on `inputSurface` is canonical. Human docs: Storybook → Recipes / inputSurface.

0 commit comments

Comments
 (0)