mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
Add Ladle component preview workflow + Claude Code skill (#2027)
* feat: Add Ladle component preview setup and Claude Code skill - Configure Ladle with MantineProvider, Tailwind, and ~/path alias - Add component-preview skill for screenshot-based UI review workflow - Include example story for challenge Eligible Models accordion - Install @ladle/react as dev dependency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: Add example component preview screenshots Sample output from the Ladle + browser automation preview workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: component-preview
|
||||
description: Preview React components with real Mantine + Tailwind styling using Ladle. Use when modifying UI components, fixing visual bugs, or when the user asks to see what a component looks like. Creates Ladle stories, captures screenshots in dark/light mode, and presents them for review. Use proactively after UI changes.
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Task
|
||||
---
|
||||
|
||||
# Component Preview
|
||||
|
||||
Preview React components in isolation using Ladle (lightweight Storybook alternative) with real Mantine v7 + Tailwind styling. No dev server needed.
|
||||
|
||||
## When to Use
|
||||
|
||||
- **After modifying a UI component** — proactively offer to preview it
|
||||
- **When the user asks** "show me what it looks like" or "generate a preview"
|
||||
- **When debugging visual issues** — create a story to reproduce and iterate
|
||||
- **When reviewing component changes** before committing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ladle is configured in the project root:
|
||||
- `.ladle/components.tsx` — Global provider with MantineProvider + theme
|
||||
- `.ladle/config.mjs` — Story discovery config
|
||||
- `.ladle/vite.config.ts` — Vite config with `~/` path alias + PostCSS
|
||||
|
||||
If these don't exist in the current worktree, copy them from main or create them. See [Setup Reference](#setup-reference) below.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Create/Update the Story
|
||||
|
||||
Create a `.stories.tsx` file near the component being previewed:
|
||||
|
||||
```
|
||||
src/components/MyComponent/MyComponent.stories.tsx
|
||||
src/pages/challenges/EligibleModels.stories.tsx
|
||||
```
|
||||
|
||||
**Story structure:**
|
||||
```tsx
|
||||
import { /* Mantine components */ } from '@mantine/core';
|
||||
// Import the component or recreate the relevant JSX
|
||||
|
||||
// Mock data that represents realistic API responses
|
||||
const mockData = [ ... ];
|
||||
|
||||
// Render the component with different states
|
||||
function Preview({ data }) {
|
||||
return (
|
||||
<div style={{ width: 320 }}> {/* Constrain to realistic width */}
|
||||
<MyComponent data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Default state */
|
||||
export const Default = () => <Preview data={mockData} />;
|
||||
|
||||
/** Empty state */
|
||||
export const Empty = () => <Preview data={[]} />;
|
||||
|
||||
/** Loading or edge case states */
|
||||
export const LongList = () => <Preview data={longMockData} />;
|
||||
```
|
||||
|
||||
**Important patterns:**
|
||||
- Set a realistic `width` on the wrapper (e.g., 320px for sidebar, 600px for main content)
|
||||
- Copy the exact Mantine component props and Tailwind classes from the real component
|
||||
- Copy any inline `styles` props from the parent context (e.g., Accordion styles)
|
||||
- Use `useComputedColorScheme` and `useMantineTheme` if the component uses them
|
||||
- Create 2-4 variants showing different states (default, empty, single item, overflow)
|
||||
|
||||
### 2. Start Ladle
|
||||
|
||||
```bash
|
||||
# Check if Ladle is already running
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:61111/
|
||||
|
||||
# If not running, start it (from project root or worktree root)
|
||||
cd <worktree-path>
|
||||
npx ladle serve --port 61111 &
|
||||
# Wait for it to be ready (~3-5 seconds)
|
||||
```
|
||||
|
||||
Ladle auto-discovers stories matching `src/**/*.stories.tsx`.
|
||||
|
||||
### 3. Capture Screenshots
|
||||
|
||||
Use the browser-automation skill to capture cropped, padded screenshots:
|
||||
|
||||
```bash
|
||||
# Create a browser session
|
||||
node ~/.claude/skills/browser-automation/cli.mjs session http://localhost:61111 --name ladle
|
||||
|
||||
# Capture all story variants in dark and light themes
|
||||
node ~/.claude/skills/browser-automation/cli.mjs run "
|
||||
const stories = [
|
||||
{ name: 'default', path: 'my-component--default' },
|
||||
{ name: 'empty', path: 'my-component--empty' },
|
||||
];
|
||||
const themes = ['dark', 'light'];
|
||||
const dir = '<session-screenshots-dir>';
|
||||
|
||||
for (const theme of themes) {
|
||||
for (const story of stories) {
|
||||
await page.goto('http://localhost:61111/?story=' + story.path + '&theme=' + theme + '&mode=preview');
|
||||
await page.waitForTimeout(800);
|
||||
const wrapper = page.locator('.ladle-story-wrapper');
|
||||
await wrapper.screenshot({ path: dir + '/crop-' + theme + '-' + story.name + '.png' });
|
||||
}
|
||||
}
|
||||
" --label "Component preview screenshots" -s ladle
|
||||
```
|
||||
|
||||
**Story path format:** The story path is derived from the file name and export name:
|
||||
- File: `EligibleModels.stories.tsx`, Export: `Default` -> path: `eligible-models--default`
|
||||
- File: `ModelCard.stories.tsx`, Export: `WithBadge` -> path: `model-card--with-badge`
|
||||
|
||||
Pattern: kebab-case filename + `--` + kebab-case export name.
|
||||
|
||||
### 4. Present to User
|
||||
|
||||
1. **Show screenshots inline** using the Read tool on the PNG files
|
||||
2. **Open for the user** if they want to see them in their image viewer:
|
||||
```bash
|
||||
start "" "<path-to-screenshot>"
|
||||
```
|
||||
3. **Ask for feedback** — "Does this look right? Want me to adjust anything?"
|
||||
4. **Iterate** — if they want changes, modify the component, re-capture, re-present
|
||||
|
||||
## Handling Complex Components
|
||||
|
||||
Some components depend heavily on app context. When this happens:
|
||||
|
||||
### Easy (just do it)
|
||||
- Presentational components (badges, cards, lists, accordions)
|
||||
- Components that only use Mantine + Tailwind
|
||||
- Components with simple props
|
||||
|
||||
### Medium (mock the data)
|
||||
- Components that use tRPC data — extract the type and create mock objects
|
||||
- Components with images — use placeholder divs or null image fallbacks
|
||||
- Components with links — use `<div>` or `<a href="#">` instead of Next.js `<Link>`
|
||||
|
||||
### Hard (raise to user)
|
||||
- Components deeply coupled to multiple providers (auth, router, tRPC context)
|
||||
- Components using complex hooks that call APIs
|
||||
- Components with heavy CSS module dependencies
|
||||
|
||||
**When encountering hard cases, tell the user:**
|
||||
> "This component depends on [auth/router/tRPC context]. I can either:
|
||||
> 1. Mock out the dependencies (more setup, more accurate)
|
||||
> 2. Extract just the visual parts into the story (faster, close enough)
|
||||
> 3. Skip the preview and we can check it on the dev server instead
|
||||
>
|
||||
> What would you prefer?"
|
||||
|
||||
## Setup Reference
|
||||
|
||||
If Ladle isn't configured in the worktree, create these files:
|
||||
|
||||
### `.ladle/components.tsx`
|
||||
```tsx
|
||||
import { MantineProvider, createTheme, Modal } from '@mantine/core';
|
||||
import type { GlobalProvider } from '@ladle/react';
|
||||
|
||||
import '@mantine/core/styles.layer.css';
|
||||
import '../src/styles/globals.css';
|
||||
|
||||
// Theme subset from src/providers/ThemeProvider.tsx
|
||||
const theme = createTheme({
|
||||
components: {
|
||||
Badge: {
|
||||
styles: { leftSection: { lineHeight: 1 } },
|
||||
defaultProps: { radius: 'sm', variant: 'light' },
|
||||
},
|
||||
ActionIcon: {
|
||||
defaultProps: { color: 'gray', variant: 'subtle' },
|
||||
},
|
||||
Tooltip: {
|
||||
defaultProps: { withArrow: true },
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
dark: ['#C1C2C5','#A6A7AB','#8c8fa3','#5C5F66','#373A40','#2C2E33','#25262B','#1A1B1E','#141517','#101113'],
|
||||
blue: ['#E7F5FF','#D0EBFF','#A5D8FF','#74C0FC','#4DABF7','#339AF0','#228BE6','#1C7ED6','#1971C2','#1864AB'],
|
||||
},
|
||||
white: '#fefefe',
|
||||
black: '#222',
|
||||
});
|
||||
|
||||
export const Provider: GlobalProvider = ({ children, globalState }) => (
|
||||
<MantineProvider
|
||||
theme={theme}
|
||||
defaultColorScheme={globalState.theme === 'dark' ? 'dark' : 'light'}
|
||||
forceColorScheme={globalState.theme === 'dark' ? 'dark' : 'light'}
|
||||
>
|
||||
<div className="ladle-story-wrapper" style={{ padding: 24, width: 'fit-content' }}>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>
|
||||
);
|
||||
```
|
||||
|
||||
### `.ladle/config.mjs`
|
||||
```js
|
||||
/** @type {import('@ladle/react').UserConfig} */
|
||||
export default {
|
||||
stories: 'src/**/*.stories.tsx',
|
||||
defaultStory: '',
|
||||
viteConfig: '.ladle/vite.config.ts',
|
||||
};
|
||||
```
|
||||
|
||||
### `.ladle/vite.config.ts`
|
||||
```ts
|
||||
import { defineConfig } from 'vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '~': path.resolve(__dirname, '../src') },
|
||||
},
|
||||
css: {
|
||||
postcss: path.resolve(__dirname, '..'),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Ensure Ladle is installed
|
||||
```bash
|
||||
pnpm add -D @ladle/react
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Dark theme first** — Civitai defaults to dark mode, so capture dark first
|
||||
- **Constrain width** — always set a width matching the real context (sidebar = ~320px, main content = ~600px, full page = ~1200px)
|
||||
- **Copy parent styles** — if the component lives inside an Accordion, Card, or other container, replicate those parent styles in the story
|
||||
- **Keep stories temporary** — stories for one-off reviews can be deleted after; stories for reusable components can stay
|
||||
- **Ladle port** — always use 61111 to avoid conflicts with dev server (3000) and other services
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MantineProvider, createTheme, Modal } from '@mantine/core';
|
||||
import type { GlobalProvider } from '@ladle/react';
|
||||
|
||||
import '@mantine/core/styles.layer.css';
|
||||
import '../src/styles/globals.css';
|
||||
|
||||
// Subset of the app theme (from src/providers/ThemeProvider.tsx)
|
||||
const theme = createTheme({
|
||||
components: {
|
||||
Modal: Modal.extend({
|
||||
styles: {
|
||||
content: { maxWidth: '100%', overflowX: 'hidden' },
|
||||
inner: { paddingLeft: 0, paddingRight: 0 },
|
||||
},
|
||||
}),
|
||||
Badge: {
|
||||
styles: { leftSection: { lineHeight: 1 } },
|
||||
defaultProps: { radius: 'sm', variant: 'light' },
|
||||
},
|
||||
ActionIcon: {
|
||||
defaultProps: { color: 'gray', variant: 'subtle' },
|
||||
},
|
||||
Tooltip: {
|
||||
defaultProps: { withArrow: true },
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#C1C2C5',
|
||||
'#A6A7AB',
|
||||
'#8c8fa3',
|
||||
'#5C5F66',
|
||||
'#373A40',
|
||||
'#2C2E33',
|
||||
'#25262B',
|
||||
'#1A1B1E',
|
||||
'#141517',
|
||||
'#101113',
|
||||
],
|
||||
blue: [
|
||||
'#E7F5FF',
|
||||
'#D0EBFF',
|
||||
'#A5D8FF',
|
||||
'#74C0FC',
|
||||
'#4DABF7',
|
||||
'#339AF0',
|
||||
'#228BE6',
|
||||
'#1C7ED6',
|
||||
'#1971C2',
|
||||
'#1864AB',
|
||||
],
|
||||
},
|
||||
white: '#fefefe',
|
||||
black: '#222',
|
||||
});
|
||||
|
||||
export const Provider: GlobalProvider = ({ children, globalState }) => (
|
||||
<MantineProvider
|
||||
theme={theme}
|
||||
defaultColorScheme={globalState.theme === 'dark' ? 'dark' : 'light'}
|
||||
forceColorScheme={globalState.theme === 'dark' ? 'dark' : 'light'}
|
||||
>
|
||||
<div className="ladle-story-wrapper" style={{ padding: 24, width: 'fit-content' }}>{children}</div>
|
||||
</MantineProvider>
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('@ladle/react').UserConfig} */
|
||||
export default {
|
||||
stories: 'src/**/*.stories.tsx',
|
||||
defaultStory: '',
|
||||
viteConfig: '.ladle/vite.config.ts',
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(__dirname, '../src'),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
postcss: path.resolve(__dirname, '..'),
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -260,6 +260,7 @@
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
|
||||
"@faker-js/faker": "^9.0.3",
|
||||
"@ladle/react": "^5.1.1",
|
||||
"@next/eslint-plugin-next": "^12.1.4",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@prisma/generator-helper": "^5.22.0",
|
||||
|
||||
Generated
+1780
-52
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconBrush, IconCube } from '@tabler/icons-react';
|
||||
|
||||
// Mock model data representing what the API returns
|
||||
const mockModels = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Pony Diffusion V6 XL',
|
||||
versionId: 101,
|
||||
versionName: 'V6 XL',
|
||||
baseModel: 'Pony',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Illustrious XL',
|
||||
versionId: 102,
|
||||
versionName: 'v0.1',
|
||||
baseModel: 'Illustrious',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Realistic Vision',
|
||||
versionId: 103,
|
||||
versionName: 'V6.0 B1',
|
||||
baseModel: 'SDXL 1.0',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Flux-Realism',
|
||||
versionId: 104,
|
||||
versionName: 'v1.0',
|
||||
baseModel: 'Flux.1 D',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'NoobAI-XL',
|
||||
versionId: 105,
|
||||
versionName: 'v-pred 1.0',
|
||||
baseModel: 'NoobAI',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'DreamShaper XL',
|
||||
versionId: 106,
|
||||
versionName: 'v2.1 Turbo',
|
||||
baseModel: 'SDXL 1.0',
|
||||
image: null,
|
||||
},
|
||||
];
|
||||
|
||||
function EligibleModelsList({ models }: { models: typeof mockModels }) {
|
||||
const colorScheme = useComputedColorScheme('dark');
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<div style={{ width: 320 }}>
|
||||
<Accordion
|
||||
variant="separated"
|
||||
multiple
|
||||
defaultValue={['models']}
|
||||
styles={() => ({
|
||||
content: { padding: 0 },
|
||||
item: {
|
||||
overflow: 'hidden',
|
||||
borderColor: colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3],
|
||||
boxShadow: theme.shadows.sm,
|
||||
},
|
||||
control: {
|
||||
padding: theme.spacing.sm,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="models">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between">Eligible Models</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{models.map((m) => (
|
||||
<div
|
||||
key={m.versionId}
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-gray-1 dark:hover:bg-dark-5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 no-underline">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-gray-2 dark:bg-dark-3">
|
||||
<IconCube size={20} className="text-dimmed" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{m.name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light">
|
||||
{m.baseModel}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{m.versionName}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
<ActionIcon variant="subtle" color="blue" size="md" aria-label="Generate">
|
||||
<IconBrush size={16} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Default view with all mock models */
|
||||
export const Default = () => <EligibleModelsList models={mockModels} />;
|
||||
|
||||
/** Single model */
|
||||
export const SingleModel = () => <EligibleModelsList models={[mockModels[0]]} />;
|
||||
|
||||
/** Long list to test scroll */
|
||||
export const LongList = () => (
|
||||
<EligibleModelsList
|
||||
models={[
|
||||
...mockModels,
|
||||
...mockModels.map((m, i) => ({
|
||||
...m,
|
||||
versionId: m.versionId + 200 + i,
|
||||
name: `${m.name} (Copy)`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
Reference in New Issue
Block a user