Frontend Component Style
If running interactively (human present), output "Read Frontend Component Style skill." to acknowledge. If running with --dangerously-skip-permissions (AFK/unattended), skip acknowledgement and proceed directly.
When to use
Use when CREATING a new component, REFACTORING an existing one, SPLITTING a large file, EXTRACTING data or logic, or DECIDING where a piece of code should live. Triggers on phrases like 'build a component', 'scaffold this', 'sketch a layout', 'prototype this', 'extract this into', 'split this component', 'is this file too big', 'where should this live', 'promote to production'. Do NOT use for small edits, bug fixes, or style tweaks — path-gated rules cover those.
This skill answers two structural questions: where does each piece of code live, and what is each piece named (including the names of related types and interfaces). Styling and runtime concerns (Tailwind tokens, dark-mode variants, server/client split, animation) and accessibility requirements (ARIA semantics, keyboard navigation, focus management, reduced-motion handling, CLS-safe variants) are owned by the relevant path-gated rules listed at the bottom — trust them; don't duplicate, but do preserve those requirements when creating or refactoring components. TypeScript typing patterns themselves remain in rules/typescript-conventions.md.
Step 1 — Determine the mode
Single self-contained TSX files and four-layer split files are opposite structures. Always pick mode FIRST.
Detection priority
Explicit word in user request
- "prototype" / "sketch" / "draft" / "throwaway" / "experiment" / "mock up" → Prototype
- "production" / "ship" / "real" / "extract" / "refactor" / "promote" → Production
Context signals (only if no explicit word)
- File is in
prototypes/, sandbox/, experiments/, demos/ → Prototype
- File is in
app/, src/components/, src/features/, lib/ → Production
- Repo already has separated
*-content.ts / format-*.ts files → Production
- Repo has only inline-JSON single-file components → Prototype
Ask (if neither signal is decisive — DO NOT GUESS)
"Is this a prototype/sketch (single file with inline data) or production code (separated into data, logic, primitives, composed)?"
Recording the choice
The first reply after invoking this skill MUST start with one line:
Mode: prototype
or
Mode: production
The choice persists for the rest of the conversation. The user overrides with one word ("make it production", "this is a prototype actually").
Mode: Prototype
When to use: sketching an idea, exploring a layout, throwaway experiments, components destined for CMS handoff where speed matters more than long-term maintainability.
File structure
- One self-contained
.tsx file
- All content data in a single
componentData JSON object at the top
- Helper functions inline below the data
- Subcomponents nested inside the same file
// user-dashboard-card.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// ===== Component Data =====
const componentData = {
title: "User Dashboard",
metrics: [
{ id: "users", label: "Active Users", value: "5,234", trend: "+12%" },
{ id: "revenue", label: "Monthly Revenue", value: "$12,345", trend: "+8%" },
],
};
// ===== Helper Functions =====
const formatTrend = (trend: string) => {
const isPositive = trend.startsWith("+");
return {
value: trend,
className: isPositive ? "text-green-500" : "text-red-500",
icon: isPositive ? "↑" : "↓",
};
};
// ===== Component =====
const UserDashboardCard = () => (
<Card>
<CardHeader><CardTitle>{componentData.title}</CardTitle></CardHeader>
<CardContent>
{componentData.metrics.map((metric) => (
<MetricRow key={metric.id} {...metric} />
))}
</CardContent>
</Card>
);
// ===== Subcomponents =====
interface MetricRowProps {
label: string;
value: string;
trend: string;
}
const MetricRow = ({ label, value, trend }: MetricRowProps) => {
const trendData = formatTrend(trend);
return (
<div>
<span>{label}</span>
<span>{value}</span>
<span className={trendData.className}>{trendData.icon} {trendData.value}</span>
</div>
);
};
export default UserDashboardCard;
Prototype guardrails
- If a prototype crosses ~250 lines or has 4+ subcomponents, mention it. Suggest promoting to production. Do not auto-promote.
- If an external dataset is already imported in the file, use it — don't refactor the data shape just to match the inline-JSON convention.
Mode: Production — The Four Layers
When to use: code going into the main app, components that will be tested, reused, or maintained by others.
The four layers
| Layer |
Job |
File suffix |
Example |
| Data |
Static content, CMS text, config |
*-content.ts |
dashboard-metrics-content.ts |
| Logic |
Pure functions, formatters, transformers |
verb-noun .ts |
format-metric-trend.ts |
| Primitive |
Single-element UI, no internal state |
descriptive .tsx |
trend-badge.tsx |
| Composed |
Assembles primitives into a section |
descriptive .tsx |
metric-card.tsx |
Dependency arrow (one direction only)
Page-level view → Composed → Primitive → Logic + Data
- Data files import nothing local; export typed content
- Logic files import nothing local; export pure functions
- Primitives may import Logic + types and shared UI primitives (e.g.
components/ui/*, framework helpers); never import other feature Primitives or Composed components
- Composed imports feature Primitives + types (and shared UI primitives if needed); owns no formatting
- Page-level views import Composed + Data; no logic, no formatting
What it looks like
// dashboard-metrics-content.ts
export type DashboardMetric = {
id: string;
label: string;
value: string;
trend: number;
};
export const dashboardMetrics: DashboardMetric[] = [
{ id: "active-users", label: "Active Users", value: "5,234", trend: 12 },
{ id: "monthly-revenue", label: "Monthly Revenue", value: "$12,345", trend: 8 },
];
// format-metric-trend.ts
export type TrendDirection = "up" | "down" | "flat";
export const getTrendDirection = (trend: number): TrendDirection => {
if (trend > 0) return "up";
if (trend < 0) return "down";
return "flat";
};
export const formatTrendLabel = (trend: number): string =>
trend === 0 ? "No change" : `${trend > 0 ? "+" : ""}${trend}%`;
// trend-badge.tsx
import { getTrendDirection, formatTrendLabel } from "@/lib/format-metric-trend";
interface TrendBadgeProps {
trend: number;
}
const TrendBadge = ({ trend }: TrendBadgeProps) => {
const direction = getTrendDirection(trend);
return <span data-direction={direction}>{formatTrendLabel(trend)}</span>;
};
export default TrendBadge;
// metric-card.tsx
import { Card, CardContent } from "@/components/ui/card";
import TrendBadge from "@/components/trend-badge";
import type { DashboardMetric } from "@/content/dashboard-metrics-content";
interface MetricCardProps {
metric: DashboardMetric;
}
const MetricCard = ({ metric }: MetricCardProps) => (
<Card>
<CardContent>
<span>{metric.label}</span>
<span>{metric.value}</span>
<TrendBadge trend={metric.trend} />
</CardContent>
</Card>
);
export default MetricCard;
// dashboard-metrics-section.tsx (page-level view)
import { dashboardMetrics } from "@/content/dashboard-metrics-content";
import MetricCard from "@/components/metric-card";
const DashboardMetricsSection = () => (
<section>
{dashboardMetrics.map((metric) => (
<MetricCard key={metric.id} metric={metric} />
))}
</section>
);
export default DashboardMetricsSection;
Production guardrails — when NOT to split
The four-layer rule is a constraint on dependencies, not a minimum file count. A 30-line component that passes the SRP test stays in one file. Don't extract:
- A formatter used in exactly one place that's three lines long
- A primitive used in exactly one place that doesn't need its own tests
- An EmptyState that appears exactly once and has no logic
Extract when there is a second consumer, non-trivial logic, or a real testing need.
Naming (both modes)
Files
- kebab-case always
.tsx for components, .ts for logic and data
- Named after what they render or do — never after where they live or how they're used
- No generic names:
card.tsx, utils.ts, helpers.ts, mgr.ts, widget.tsx
- No abbreviations unless universally understood (
url, id, api)
Components
- Specific noun phrase:
MetricTrendBadge, InvoiceLineItemRow, PlanUpgradeCallout
- A reviewer scanning a file tree should know what each component renders without opening the file
- Forbidden:
Card, Item, Widget, Section, DisplayComponent
Functions
- Returns, derives, formats, or builds a value → verb-led value name:
formatCurrency, getTrendDirection, buildInvoiceRows
- Performs a side effect or user/system action → verb phrase:
handlePlanUpgrade, submitBillingForm, downloadInvoicePdf
- Event handlers name the action, not the event:
handlePlanUpgrade not handleClick, handleInvoiceDownload not handleSubmit
Types and interfaces
- Props interface =
<ComponentName>Props
- Types named after the thing:
TrendDirection, PlanTier, InvoiceStatus
- Forbidden:
Props (collides), ICard (Hungarian), T (meaningless)
File layout (within every file, both modes)
1. Imports
2. Types and interfaces
3. Content data (only if this file owns data)
4. Helper functions / hooks (Prototype mode only)
5. Component or function body
6. Subcomponents (Prototype mode only)
7. Default export
A reviewer should always know where to look for each kind of thing.
SRP test (both modes)
Describe what this does in one sentence without using "and".
- ✅ "Displays a metric value with a trend indicator"
- ✅ "Renders a list of invoice line items"
- ❌ "Shows the metric, handles the click, and formats the trend" → split into three
If you can't, split.
Cross-cutting guardrails (both modes)
- Don't change website copy unless told to. This applies in Prototype and Production alike, including refactors and file splits.
Anti-patterns (both modes)
| Pattern |
Problem |
Fix |
const data = { ... } inline in production code |
Hides content inside presentation |
Move to <feature>-content.ts |
utils.ts with many unrelated functions |
Impossible to navigate |
One function (or one tightly related group) per file, named after what it does |
<Card /> as a component name |
No hint of what it renders |
<UserBillingCard />, <MetricSummaryCard /> |
handleClick / handleSubmit on a specific component |
No hint of what is being acted on |
handlePlanUpgrade, handleInvoiceDownload |
| Formatting logic inside JSX |
Mixes concerns, hard to test |
Extract to a named function in a logic file |
Mixed isLoading / isError / isEmpty in one render block |
Tangled conditional logic |
Each state gets its own named branch or component |
| Production component with inline JSON data |
Should have been promoted |
Run the Promote workflow below |
Promote: prototype → production
Triggered by phrases like "promote to production", "clean this up", "extract this properly", "this is going live".
- Extract content — move the inline
componentData object to <feature>-content.ts with a typed export.
- Extract logic — move formatting/transformation functions to
<verb>-<noun>.ts files (e.g. format-metric-trend.ts).
- Extract subcomponents — each nested subcomponent becomes its own file, named after what it renders.
- Re-aim the original file — it becomes a Composed or page-level view that imports content + primitives only. No formatting, no inline data.
- Apply path-gated rules — server/client split, dark-mode tokens, Tailwind grouping etc. (these auto-load when you edit the new files).
- Update mode — record
Mode: production in your next reply so the rest of the conversation uses production rules.
Path-gated rules already in effect
These auto-load when you edit matching files. Do not duplicate their content here. Trust them.
| Rule |
Scope |
rules/dark-mode.md |
**/*.{tsx,jsx,css,scss} — DMDS tokens, dark variants |
rules/tailwind-shadcn.md |
**/*.{tsx,jsx} — Tailwind grouping, shadcn imports, responsive |
rules/server-vs-client-components.md |
**/app/**/*.{tsx,jsx} — server-first, error handling |
rules/framer-motion.md |
**/*.{tsx,jsx} — animation philosophy, reduced motion |
rules/typescript-conventions.md |
**/*.{ts,tsx} — props/types, parameter style |
rules/frontend-conventions.md |
**/*.{ts,tsx,js,jsx,mjs,cjs,css,scss,html,svelte,vue} — browser baseline |
If a question is covered by a path-gated rule (Tailwind syntax, dark-mode tokens, when to use 'use client'), defer to the rule. This skill answers structure and naming only.
1---2name: frontend-component-style3description: Frontend component file structure, naming, and layer separation for new or refactored components.4---56# Frontend Component Style78If running interactively (human present), output "Read Frontend Component Style skill." to acknowledge. If running with --dangerously-skip-permissions (AFK/unattended), skip acknowledgement and proceed directly.910## When to use1112Use when CREATING a new component, REFACTORING an existing one, SPLITTING a large file, EXTRACTING data or logic, or DECIDING where a piece of code should live. Triggers on phrases like 'build a component', 'scaffold this', 'sketch a layout', 'prototype this', 'extract this into', 'split this component', 'is this file too big', 'where should this live', 'promote to production'. Do NOT use for small edits, bug fixes, or style tweaks — path-gated rules cover those.1314This skill answers two structural questions: **where does each piece of code live**, and **what is each piece named** (including the names of related types and interfaces). Styling and runtime concerns (Tailwind tokens, dark-mode variants, server/client split, animation) and accessibility requirements (ARIA semantics, keyboard navigation, focus management, reduced-motion handling, CLS-safe variants) are owned by the relevant path-gated rules listed at the bottom — trust them; don't duplicate, but do preserve those requirements when creating or refactoring components. TypeScript typing patterns themselves remain in `rules/typescript-conventions.md`.1516---1718## Step 1 — Determine the mode1920Single self-contained TSX files and four-layer split files are **opposite** structures. Always pick mode FIRST.2122### Detection priority23241. **Explicit word in user request**25 - "prototype" / "sketch" / "draft" / "throwaway" / "experiment" / "mock up" → **Prototype**26 - "production" / "ship" / "real" / "extract" / "refactor" / "promote" → **Production**27282. **Context signals** (only if no explicit word)29 - File is in `prototypes/`, `sandbox/`, `experiments/`, `demos/` → Prototype30 - File is in `app/`, `src/components/`, `src/features/`, `lib/` → Production31 - Repo already has separated `*-content.ts` / `format-*.ts` files → Production32 - Repo has only inline-JSON single-file components → Prototype33343. **Ask** (if neither signal is decisive — DO NOT GUESS)35 > "Is this a prototype/sketch (single file with inline data) or production code (separated into data, logic, primitives, composed)?"3637### Recording the choice3839The first reply after invoking this skill MUST start with one line:4041```42Mode: prototype43```44or45```46Mode: production47```4849The choice persists for the rest of the conversation. The user overrides with one word ("make it production", "this is a prototype actually").5051---5253## Mode: Prototype5455**When to use:** sketching an idea, exploring a layout, throwaway experiments, components destined for CMS handoff where speed matters more than long-term maintainability.5657### File structure5859- One self-contained `.tsx` file60- All content data in a single `componentData` JSON object at the top61- Helper functions inline below the data62- Subcomponents nested inside the same file6364```tsx65// user-dashboard-card.tsx6667import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";6869// ===== Component Data =====70const componentData = {71 title: "User Dashboard",72 metrics: [73 { id: "users", label: "Active Users", value: "5,234", trend: "+12%" },74 { id: "revenue", label: "Monthly Revenue", value: "$12,345", trend: "+8%" },75 ],76};7778// ===== Helper Functions =====79const formatTrend = (trend: string) => {80 const isPositive = trend.startsWith("+");81 return {82 value: trend,83 className: isPositive ? "text-green-500" : "text-red-500",84 icon: isPositive ? "↑" : "↓",85 };86};8788// ===== Component =====89const UserDashboardCard = () => (90 <Card>91 <CardHeader><CardTitle>{componentData.title}</CardTitle></CardHeader>92 <CardContent>93 {componentData.metrics.map((metric) => (94 <MetricRow key={metric.id} {...metric} />95 ))}96 </CardContent>97 </Card>98);99100// ===== Subcomponents =====101interface MetricRowProps {102 label: string;103 value: string;104 trend: string;105}106107const MetricRow = ({ label, value, trend }: MetricRowProps) => {108 const trendData = formatTrend(trend);109 return (110 <div>111 <span>{label}</span>112 <span>{value}</span>113 <span className={trendData.className}>{trendData.icon} {trendData.value}</span>114 </div>115 );116};117118export default UserDashboardCard;119```120121### Prototype guardrails122123- If a prototype crosses ~250 lines or has 4+ subcomponents, mention it. Suggest promoting to production. Do not auto-promote.124- If an external dataset is already imported in the file, use it — don't refactor the data shape just to match the inline-JSON convention.125126---127128## Mode: Production — The Four Layers129130**When to use:** code going into the main app, components that will be tested, reused, or maintained by others.131132### The four layers133134| Layer | Job | File suffix | Example |135|---|---|---|---|136| **Data** | Static content, CMS text, config | `*-content.ts` | `dashboard-metrics-content.ts` |137| **Logic** | Pure functions, formatters, transformers | verb-noun `.ts` | `format-metric-trend.ts` |138| **Primitive** | Single-element UI, no internal state | descriptive `.tsx` | `trend-badge.tsx` |139| **Composed** | Assembles primitives into a section | descriptive `.tsx` | `metric-card.tsx` |140141### Dependency arrow (one direction only)142143```144Page-level view → Composed → Primitive → Logic + Data145```146147- **Data files** import nothing local; export typed content148- **Logic files** import nothing local; export pure functions149- **Primitives** may import Logic + types and shared UI primitives (e.g. `components/ui/*`, framework helpers); never import other feature Primitives or Composed components150- **Composed** imports feature Primitives + types (and shared UI primitives if needed); owns no formatting151- **Page-level views** import Composed + Data; no logic, no formatting152153### What it looks like154155```ts156// dashboard-metrics-content.ts157export type DashboardMetric = {158 id: string;159 label: string;160 value: string;161 trend: number;162};163164export const dashboardMetrics: DashboardMetric[] = [165 { id: "active-users", label: "Active Users", value: "5,234", trend: 12 },166 { id: "monthly-revenue", label: "Monthly Revenue", value: "$12,345", trend: 8 },167];168```169170```ts171// format-metric-trend.ts172export type TrendDirection = "up" | "down" | "flat";173174export const getTrendDirection = (trend: number): TrendDirection => {175 if (trend > 0) return "up";176 if (trend < 0) return "down";177 return "flat";178};179180export const formatTrendLabel = (trend: number): string =>181 trend === 0 ? "No change" : `${trend > 0 ? "+" : ""}${trend}%`;182```183184```tsx185// trend-badge.tsx186import { getTrendDirection, formatTrendLabel } from "@/lib/format-metric-trend";187188interface TrendBadgeProps {189 trend: number;190}191192const TrendBadge = ({ trend }: TrendBadgeProps) => {193 const direction = getTrendDirection(trend);194 return <span data-direction={direction}>{formatTrendLabel(trend)}</span>;195};196197export default TrendBadge;198```199200```tsx201// metric-card.tsx202import { Card, CardContent } from "@/components/ui/card";203import TrendBadge from "@/components/trend-badge";204import type { DashboardMetric } from "@/content/dashboard-metrics-content";205206interface MetricCardProps {207 metric: DashboardMetric;208}209210const MetricCard = ({ metric }: MetricCardProps) => (211 <Card>212 <CardContent>213 <span>{metric.label}</span>214 <span>{metric.value}</span>215 <TrendBadge trend={metric.trend} />216 </CardContent>217 </Card>218);219220export default MetricCard;221```222223```tsx224// dashboard-metrics-section.tsx (page-level view)225import { dashboardMetrics } from "@/content/dashboard-metrics-content";226import MetricCard from "@/components/metric-card";227228const DashboardMetricsSection = () => (229 <section>230 {dashboardMetrics.map((metric) => (231 <MetricCard key={metric.id} metric={metric} />232 ))}233 </section>234);235236export default DashboardMetricsSection;237```238239### Production guardrails — when NOT to split240241The four-layer rule is a **constraint on dependencies**, not a **minimum file count**. A 30-line component that passes the SRP test stays in one file. Don't extract:242243- A formatter used in exactly one place that's three lines long244- A primitive used in exactly one place that doesn't need its own tests245- An EmptyState that appears exactly once and has no logic246247Extract when there is **a second consumer**, **non-trivial logic**, or **a real testing need**.248249---250251## Naming (both modes)252253### Files254- kebab-case always255- `.tsx` for components, `.ts` for logic and data256- Named after what they render or do — never after where they live or how they're used257- No generic names: `card.tsx`, `utils.ts`, `helpers.ts`, `mgr.ts`, `widget.tsx`258- No abbreviations unless universally understood (`url`, `id`, `api`)259260### Components261- Specific noun phrase: `MetricTrendBadge`, `InvoiceLineItemRow`, `PlanUpgradeCallout`262- A reviewer scanning a file tree should know what each component renders without opening the file263- Forbidden: `Card`, `Item`, `Widget`, `Section`, `DisplayComponent`264265### Functions266- **Returns, derives, formats, or builds a value** → verb-led value name: `formatCurrency`, `getTrendDirection`, `buildInvoiceRows`267- **Performs a side effect or user/system action** → verb phrase: `handlePlanUpgrade`, `submitBillingForm`, `downloadInvoicePdf`268- Event handlers name the action, not the event: `handlePlanUpgrade` not `handleClick`, `handleInvoiceDownload` not `handleSubmit`269270### Types and interfaces271- Props interface = `<ComponentName>Props`272- Types named after the thing: `TrendDirection`, `PlanTier`, `InvoiceStatus`273- Forbidden: `Props` (collides), `ICard` (Hungarian), `T` (meaningless)274275---276277## File layout (within every file, both modes)278279```2801. Imports2812. Types and interfaces2823. Content data (only if this file owns data)2834. Helper functions / hooks (Prototype mode only)2845. Component or function body2856. Subcomponents (Prototype mode only)2867. Default export287```288289A reviewer should always know where to look for each kind of thing.290291---292293## SRP test (both modes)294295> Describe what this does in one sentence without using "and".296297- ✅ "Displays a metric value with a trend indicator"298- ✅ "Renders a list of invoice line items"299- ❌ "Shows the metric, handles the click, and formats the trend" → split into three300301If you can't, split.302303---304305## Cross-cutting guardrails (both modes)306307- Don't change website copy unless told to. This applies in Prototype and Production alike, including refactors and file splits.308309---310311## Anti-patterns (both modes)312313| Pattern | Problem | Fix |314|---|---|---|315| `const data = { ... }` inline in production code | Hides content inside presentation | Move to `<feature>-content.ts` |316| `utils.ts` with many unrelated functions | Impossible to navigate | One function (or one tightly related group) per file, named after what it does |317| `<Card />` as a component name | No hint of what it renders | `<UserBillingCard />`, `<MetricSummaryCard />` |318| `handleClick` / `handleSubmit` on a specific component | No hint of what is being acted on | `handlePlanUpgrade`, `handleInvoiceDownload` |319| Formatting logic inside JSX | Mixes concerns, hard to test | Extract to a named function in a logic file |320| Mixed `isLoading` / `isError` / `isEmpty` in one render block | Tangled conditional logic | Each state gets its own named branch or component |321| Production component with inline JSON data | Should have been promoted | Run the Promote workflow below |322323---324325## Promote: prototype → production326327Triggered by phrases like *"promote to production"*, *"clean this up"*, *"extract this properly"*, *"this is going live"*.3283291. **Extract content** — move the inline `componentData` object to `<feature>-content.ts` with a typed export.3302. **Extract logic** — move formatting/transformation functions to `<verb>-<noun>.ts` files (e.g. `format-metric-trend.ts`).3313. **Extract subcomponents** — each nested subcomponent becomes its own file, named after what it renders.3324. **Re-aim the original file** — it becomes a Composed or page-level view that imports content + primitives only. No formatting, no inline data.3335. **Apply path-gated rules** — server/client split, dark-mode tokens, Tailwind grouping etc. (these auto-load when you edit the new files).3346. **Update mode** — record `Mode: production` in your next reply so the rest of the conversation uses production rules.335336---337338## Path-gated rules already in effect339340These auto-load when you edit matching files. **Do not duplicate their content here.** Trust them.341342| Rule | Scope |343|---|---|344| `rules/dark-mode.md` | `**/*.{tsx,jsx,css,scss}` — DMDS tokens, dark variants |345| `rules/tailwind-shadcn.md` | `**/*.{tsx,jsx}` — Tailwind grouping, shadcn imports, responsive |346| `rules/server-vs-client-components.md` | `**/app/**/*.{tsx,jsx}` — server-first, error handling |347| `rules/framer-motion.md` | `**/*.{tsx,jsx}` — animation philosophy, reduced motion |348| `rules/typescript-conventions.md` | `**/*.{ts,tsx}` — props/types, parameter style |349| `rules/frontend-conventions.md` | `**/*.{ts,tsx,js,jsx,mjs,cjs,css,scss,html,svelte,vue}` — browser baseline |350351If a question is covered by a path-gated rule (Tailwind syntax, dark-mode tokens, when to use `'use client'`), defer to the rule. This skill answers structure and naming only.