posthog/storybook-stories
Write Storybook stories for PostHog UI components. Covers the provider stack stories run inside, the key gotcha that tRPC/useHostTRPC queries never resolve in Storybook (so data-fetching components render empty), and the pure-presentational split that makes a component storyable. Use when adding or fixing a *.stories.tsx file under packages/ui.
npx skills add https://github.com/PostHog/posthog --skill storybook-stories
Stories live next to components as *.stories.tsx and are collected by
apps/code/.storybook/main.ts (its glob includes
packages/ui/src/**/*.stories.tsx). Run/build:
pnpm --filter code storybook # dev server on :6006
pnpm --filter code build-storybook # static build (also a good CI/typecheck gate)
apps/code/.storybook/preview.tsx applies two global decorators, so a story
should not add its own providers or <Theme>:
withAppProviders — a QueryClient, the host tRPC context, a DIServiceProvider, and a minimal TanStack Router. So useHostTRPC(),
useService(), useRouterState(), etc. render instead of throwing
"must be used within a Provider".
<Theme> (Radix) bound to the dark/light toolbar global. This root provider isthe one sanctioned Radix usage — it supplies the CSS tokens. Radix *components*
are banned: build stories and components from @posthog/quill plus div +
Tailwind (see UI Components).
Add a per-story decorator only to constrain layout (e.g. wrap in a
maxWidth div so a full-width component sizes realistically).
This is the thing that wastes time. In withAppProviders the tRPC ipcLink is
a no-op (apps/code/.storybook/mocks/electron-trpc.ts), so:
useHostTRPC() (and hooks built on it, likeuseClaudeCliSessions) stays pending forever — query.data is
undefined, permanently.
useService(TOKEN) returns an inert proxy stub for anything notexplicitly bound (service.foo().bar never throws, but calls are no-ops).
Only a few tokens resolve for real: HOST_TRPC_CLIENT (a no-op client with a
handful of stubbed methods), IMPERATIVE_QUERY_CLIENT, DIFF_WORKER_FACTORY.
So a component that fetches its own data renders its empty/loading branch in
Storybook — frequently null. Storying it directly shows nothing.
Separate the data/wiring from the rendering, and story the pure part — which
also satisfies the repo rule "components render; hooks wrap exactly one query"
(AGENTS.md). Keep both in the same file:
// Pure — takes data + handlers as props. This is what the story targets.
export function WidgetList({ items, onPick }: WidgetListProps) { … }
// Container — does the tRPC/useService wiring, renders <WidgetList/>.
export function Widget({ repoPath }: WidgetProps) {
const { data } = useSomeQuery(repoPath);
return <WidgetList items={data?.items ?? []} onPick={…} />;
}
Then each story is just args for WidgetList — one per visual state (empty,
single, over-limit, in-flight/disabled, fallback text, …). Real example:
packages/ui/src/features/task-detail/components/ContinueCliSessions.tsx +
.stories.tsx.
Filtering/branching that lives in the container (not the pure view) isn't
exercised by these visual stories — cover it with a small unit test if it's
worth pinning.
title groups in the sidebar, e.g. "Task Detail/ContinueCliSessions".session(overrides)) rather thanrepeating object literals across stories.
Date.now()/new Date() are fine in stories, but fixed ISO strings keeprelative-time output stable enough for visual review.
.tsx under the package); abuild-storybook additionally catches Storybook-specific breakage.
Take posthog/storybook-stories from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.