apps/shade/src/docs/contributing.mdx
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Contributing" /> <div className="sb-doc">The first decision is always which layer your change belongs in. The Architecture page has the full breakdown, but the quick rule:
If you're not sure between Components and Features, the smell test is: does the thing's name sound like Ghost or like the open web? KpiCardHeader is Ghost-y, Button is web-y.
The second decision is whether the change belongs in Shade at all. The default is to keep code local to your app first. Build it where you need it, ship it, and only consider promoting it to Shade once you find yourself building the same thing again somewhere else. Premature design system additions are expensive — once an abstraction has consumers, changing it gets harder fast.
apps/shade/
├── .storybook/ Storybook config
└── src/
├── components/
│ ├── ui/ Generic controls + recipes
│ ├── layout/ Page shells (Page, ListHeader, etc.)
│ └── features/ Product compositions
└── docs/ These MDX pages
Entrypoint barrels live one level up: components.ts, primitives.ts, patterns.ts, etc. They re-export from the folders above.
Use a layer-specific subpath, not the root barrel:
// Good
import {Button} from '@tryghost/shade/components';
import {Stack} from '@tryghost/shade/primitives';
import {createFilter} from '@tryghost/shade/patterns';
import {cn} from '@tryghost/shade/utils';
import {ShadeApp} from '@tryghost/shade/app';
// Avoid (compatibility lane only)
import {Button, Stack} from '@tryghost/shade';
The full migration story is on the Root Imports Migration page.
Inside Shade itself, use the @/ path alias for cross-file imports. Don't worry about the alias leaking into emitted .d.ts files — tsc-alias rewrites them to relative paths at build time so consumer apps resolve them correctly.
File names are kebab-case (dropdown-menu.tsx), matching what the ShadCN CLI generates. Components are PascalCase (DropdownMenu), hooks and functions are camelCase.
Each component file ships alongside its story:
button.tsx The component
button.stories.tsx Stories (CSF format)
button.mdx Optional component-specific docs
Storybook titles follow the layer convention so the sidebar groups make sense:
| Layer | Title prefix |
|---|---|
| Recipe / visual rule | Foundations / X |
| Layout primitive | Primitives / X |
| Generic control | Components / X |
| Page shell | Layout / X |
| Product composition | Features / X |
Every documented component needs a .stories.tsx file with:
title under the right sidebar group.tags: ['autodocs'] so Storybook generates the docs page.parameters.docs.description.component.parameters.docs.description.story explaining when to use that variant.A typical Button story:
import type {Meta, StoryObj} from '@storybook/react-vite';
import {Button} from './button';
const meta: Meta<typeof Button> = {
title: 'Components / Button',
component: Button,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Reusable button for actions. Pick a variant and size based on context.'
}
}
}
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {
args: {children: 'Continue'},
parameters: {docs: {description: {story: 'Primary call to action with default styling.'}}}
};
export const Destructive: Story = {
args: {variant: 'destructive', children: 'Delete'},
parameters: {docs: {description: {story: 'Use for dangerous or irreversible actions.'}}}
};
The general pattern: prefer many small focused stories with a one-line "when to use" description over long prose. Storybook is a gallery, not a textbook.
When the thing you're building maps onto a ShadCN primitive (Button, Dialog, Select, etc.), install it via the CLI:
npx shadcn@latest add button
A few guardrails when running the CLI:
pnpm lint, pnpm test, and verify in Storybook.Use cva() for variants and cn() for class merging. They're not optional — they're the reason classes don't conflict in unexpected ways.
import {cn} from '@/lib/utils';
import {cva, type VariantProps} from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors',
{
variants: {
variant: {
solid: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground'
},
size: {
sm: 'h-9 px-3',
md: 'h-10 px-4',
lg: 'h-11 px-8'
}
},
defaultVariants: {
variant: 'solid',
size: 'md'
}
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({className, variant, size, ...props}: ButtonProps) {
return (
<button
className={cn(buttonVariants({variant, size}), className)}
{...props}
/>
);
}
Three things to notice: the base styles go in the first cva argument, variants in the object, defaults in defaultVariants. The component always merges its computed className with the consumer's via cn() so consumers can extend without wrapping.
When a component has multiple meaningful regions (a title, a meta line, an actions row), prefer compound subcomponents over a forest of props. It keeps the API composable and the consumer code readable:
function Header(props) { /* ... */ }
function HeaderTitle(props) { /* ... */ }
function HeaderMeta(props) { /* ... */ }
function HeaderActions(props) { /* ... */ }
Header.Title = HeaderTitle;
Header.Meta = HeaderMeta;
Header.Actions = HeaderActions;
export {Header};
Used like:
<Header>
<Header.Title>Members</Header.Title>
<Header.Meta>42,102 total</Header.Meta>
<Header.Actions>
<Button>New member</Button>
</Header.Actions>
</Header>
Each part stays small, accepts and forwards className, and gets demonstrated in stories ("With actions", "With meta").
For things that don't have a ShadCN starting point, the structure is the same — just simpler:
import {cn} from '@/lib/utils';
interface CustomComponentProps {
className?: string;
}
export function CustomComponent({className, ...props}: CustomComponentProps) {
return (
<div className={cn('base-styles', className)} {...props} />
);
}
Before you mark a component done:
.stories.tsx covers the key variants and states.className is forwarded.A few situations should make you stop and reconsider before pushing through:
components and features. The decision is probably real — ask before guessing.For broader conventions across the Ghost monorepo (build commands, test expectations, commit and PR style), see AGENTS.md in the repo root.