supabase
Handles the full Supabase workflow from schema changes to deployment, with built-in security guardrails that catch common traps like RLS...
worldcrafter-feature-builder
Build complete features with Server Actions, forms, Zod validation, database CRUD operations, and comprehensive tests. Use when user requests "add a feature", "build a [feature]", "create [feature] with forms", or needs end-to-end implementation with validation and testing. Scaffolds pages, actio...
Full skill instructions
Version: 2.0.0 Last Updated: 2025-01-15
This skill provides a systematic approach to implementing new features in the WorldCrafter codebase following established architectural patterns, best practices, and testing standards.
Related Skills:
worldcrafter-database-setup - Use first if feature needs new database tablesworldcrafter-auth-guard - Use to add authentication to generated featuresworldcrafter-test-generator - Alternative for adding tests to existing featuresworldcrafter-route-creator - Alternative for simple pages without formsExample Use Cases:
Use this skill when the user wants to:
Understand the feature requirements
Plan the database changes (if needed)
references/feature-patterns.md for database patternsUse the scripts/scaffold_feature.py script to generate the complete feature structure:
python .claude/skills/worldcrafter-feature-builder/scripts/scaffold_feature.py <feature-name>
This creates:
src/app/<feature-name>/page.tsx - Client component with formsrc/app/<feature-name>/actions.ts - Server Actionssrc/app/<feature-name>/loading.tsx - Loading statesrc/app/<feature-name>/error.tsx - Error boundarysrc/lib/schemas/<feature-name>.ts - Zod validation schemasrc/app/__tests__/<feature-name>.integration.test.ts - Integration teste2e/<feature-name>.spec.ts - E2E testAlternatively, manually copy templates from assets/templates/ to customize the structure.
Update Prisma schema (prisma/schema.prisma):
@relation@@map for table namesCreate migration:
npx prisma migrate dev --name add_<feature_name>
Apply RLS policies:
prisma/migrations/<timestamp>_add_<feature_name>/references/feature-patterns.md for RLS policy patternsnpm run db:rls to apply policiesUpdate test database:
npm run db:test:sync
Create Zod schema in src/lib/schemas/<feature-name>.ts:
export type FeatureFormValues = z.infer<typeof featureSchema>assets/templates/schema.ts for examplesCommon validation patterns:
z.string().email()z.string().min(1, "Field is required")z.string().optional()z.enum(["option1", "option2"])z.number().min(0).max(100)z.date() or z.string().datetime()Create Server Actions in src/app/<feature-name>/actions.ts:
"use server" directiveassets/templates/actions.ts for the standard patternServer Action pattern:
"use server"
import { revalidatePath } from "next/cache"
import { prisma } from "@/lib/prisma"
import { createClient } from "@/lib/supabase/server"
import { featureSchema, type FeatureFormValues } from "@/lib/schemas/feature"
export async function submitFeature(values: FeatureFormValues) {
try {
// 1. Validate
const validated = featureSchema.parse(values)
// 2. Authenticate (if needed)
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { success: false, error: "Unauthorized" }
// 3. Database operation
const result = await prisma.feature.create({
data: { ...validated, user_id: user.id }
})
// 4. Revalidate
revalidatePath("/feature")
// 5. Return
return { success: true, data: result }
} catch (error) {
return { success: false, error: "Operation failed" }
}
}
Security checklist:
Create form component in src/app/<feature-name>/page.tsx:
zodResolver@/components/uiassets/templates/page.tsx for examplesForm handling pattern:
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { featureSchema, type FeatureFormValues } from "@/lib/schemas/feature"
import { submitFeature } from "./actions"
export default function FeaturePage() {
const form = useForm<FeatureFormValues>({
resolver: zodResolver(featureSchema),
defaultValues: { /* ... */ }
})
async function onSubmit(values: FeatureFormValues) {
const result = await submitFeature(values)
if (result.success) {
// Handle success
} else {
// Handle error
}
}
return <form onSubmit={form.handleSubmit(onSubmit)}>...</form>
}
Add shadcn/ui components if needed:
npx shadcn@latest add [component-name]
Create loading state (loading.tsx):
assets/templates/loading.tsxCreate error boundary (error.tsx):
"use client")assets/templates/error.tsxReference references/testing-guide.md for detailed testing patterns.
Integration test (src/app/__tests__/<feature-name>.integration.test.ts):
src/test/factories/afterAll hooksassets/templates/integration.test.tsE2E test (e2e/<feature-name>.spec.ts):
assets/templates/e2e.spec.tsRun tests:
npm test # Unit tests
npm run test:coverage # With coverage
npm run test:e2e # E2E tests
npm run test:all # All tests
Type checking:
npm run build
Linting and formatting:
npm run lint
npm run format
Test database sync (if schema changed):
npm run db:test:sync
Verify tests pass:
npm run test:all
For features requiring authentication:
Check auth in Server Action:
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { success: false, error: "Unauthorized" }
Optionally protect the route in middleware or layout
In Server Components (preferred):
In Client Components:
useQuery, useMutation)WorldCrafter applications often need sophisticated entity creation and management workflows for worldbuilding. This section covers advanced patterns for character creators, location builders, item editors, and other complex entity forms.
For complex entity creation (e.g., character creator with Basics → Appearance → Personality → Backstory → Custom Attributes):
State Management:
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
type WizardStep = "basics" | "appearance" | "personality" | "backstory" | "attributes"
export default function CharacterWizard() {
const [currentStep, setCurrentStep] = useState<WizardStep>("basics")
const [formData, setFormData] = useState({})
const form = useForm({
resolver: zodResolver(stepSchemas[currentStep]),
defaultValues: formData,
})
const steps: WizardStep[] = ["basics", "appearance", "personality", "backstory", "attributes"]
const currentIndex = steps.indexOf(currentStep)
const progress = ((currentIndex + 1) / steps.length) * 100
async function handleNext(values: any) {
// Merge values into accumulated form data
setFormData(prev => ({ ...prev, ...values }))
if (currentIndex < steps.length - 1) {
setCurrentStep(steps[currentIndex + 1])
form.reset() // Reset for next step
} else {
// Final step - submit all data
const result = await submitCharacter({ ...formData, ...values })
if (result.success) {
// Handle success
}
}
}
function handleBack() {
if (currentIndex > 0) {
setCurrentStep(steps[currentIndex - 1])
}
}
return (
<div>
{/* Progress indicator */}
<div className="mb-8">
<div className="flex justify-between mb-2">
{steps.map((step, i) => (
<div
key={step}
className={i <= currentIndex ? "text-primary" : "text-muted-foreground"}
>
{step}
</div>
))}
</div>
<div className="h-2 bg-secondary rounded-full">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Step content */}
<form onSubmit={form.handleSubmit(handleNext)}>
{currentStep === "basics" && <BasicsStep form={form} />}
{currentStep === "appearance" && <AppearanceStep form={form} />}
{/* ... other steps ... */}
<div className="flex justify-between mt-6">
<Button
type="button"
variant="outline"
onClick={handleBack}
disabled={currentIndex === 0}
>
Back
</Button>
<Button type="submit">
{currentIndex === steps.length - 1 ? "Save Character" : "Next"}
</Button>
</div>
</form>
</div>
)
}
Key Implementation Details:
See template: assets/templates/multi-step-wizard.tsx
For entity images (character portraits, location maps, item icons):
Component Pattern:
"use client"
import { useState } from "react"
import { createClient } from "@/lib/supabase/client"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import Image from "next/image"
export function ImageUpload({
value,
onChange,
bucket = "entity-images",
folder = "characters"
}: {
value?: string
onChange: (url: string) => void
bucket?: string
folder?: string
}) {
const [uploading, setUploading] = useState(false)
const [preview, setPreview] = useState<string | null>(value || null)
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
try {
const supabase = createClient()
// Generate unique filename
const fileExt = file.name.split('.').pop()
const fileName = `${folder}/${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`
// Upload to Supabase Storage
const { data, error } = await supabase.storage
.from(bucket)
.upload(fileName, file, {
cacheControl: '3600',
upsert: false
})
if (error) throw error
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(fileName)
setPreview(publicUrl)
onChange(publicUrl) // Update form field
} catch (error) {
console.error('Upload error:', error)
alert('Failed to upload image')
} finally {
setUploading(false)
}
}
return (
<div className="space-y-4">
<Label htmlFor="image-upload">Image</Label>
{preview && (
<div className="relative w-48 h-48 border rounded-lg overflow-hidden">
<Image
src={preview}
alt="Preview"
fill
className="object-cover"
/>
</div>
)}
<Input
id="image-upload"
type="file"
accept="image/*"
onChange={handleImageUpload}
disabled={uploading}
/>
{uploading && <p className="text-sm text-muted-foreground">Uploading...</p>}
</div>
)
}
Integration with Form:
<FormField
control={form.control}
name="imageUrl"
render={({ field }) => (
<FormItem>
<ImageUpload
value={field.value}
onChange={field.onChange}
folder="characters"
/>
<FormMessage />
</FormItem>
)}
/>
Schema:
export const characterSchema = z.object({
name: z.string().min(1),
imageUrl: z.string().url().optional(),
// ... other fields
})
Storage Setup:
See template: assets/templates/image-upload.tsx
For genre-specific or dynamic fields stored in a JSON column (e.g., Fantasy characters have "Mana Points", Sci-Fi characters have "Tech Level"):
Database Schema:
model Character {
id String @id @default(uuid())
name String
worldId String
world World @relation(fields: [worldId], references: [id])
attributes Json? // Custom fields based on world genre
@@map("characters")
}
model World {
id String @id @default(uuid())
name String
genre String // "fantasy", "scifi", "modern", etc.
@@map("worlds")
}
Zod Schema:
export const characterSchema = z.object({
name: z.string().min(1),
worldId: z.string().uuid(),
// Flexible attributes - validated at runtime based on genre
attributes: z.record(z.any()).optional(),
})
// Genre-specific schemas
export const fantasyAttributesSchema = z.object({
manaPoints: z.number().min(0).max(100),
magicSchool: z.enum(["fire", "water", "earth", "air"]),
spellSlots: z.number().min(0),
})
export const scifiAttributesSchema = z.object({
techLevel: z.number().min(1).max(10),
cybernetics: z.array(z.string()),
faction: z.string(),
})
Dynamic Form Component:
"use client"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
export default function CharacterForm({ worldId }: { worldId: string }) {
const [world, setWorld] = useState<any>(null)
const form = useForm()
useEffect(() => {
// Fetch world to get genre
fetch(`/api/worlds/${worldId}`)
.then(res => res.json())
.then(data => setWorld(data))
}, [worldId])
function renderAttributeFields() {
if (!world) return null
switch (world.genre) {
case "fantasy":
return (
<>
<FormField
control={form.control}
name="attributes.manaPoints"
render={({ field }) => (
<FormItem>
<FormLabel>Mana Points</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="attributes.magicSchool"
render={({ field }) => (
<FormItem>
<FormLabel>Magic School</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fire">Fire</SelectItem>
<SelectItem value="water">Water</SelectItem>
<SelectItem value="earth">Earth</SelectItem>
<SelectItem value="air">Air</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
</>
)
case "scifi":
return (
<>
<FormField
control={form.control}
name="attributes.techLevel"
render={({ field }) => (
<FormItem>
<FormLabel>Tech Level (1-10)</FormLabel>
<FormControl>
<Input type="number" min="1" max="10" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="attributes.faction"
render={({ field }) => (
<FormItem>
<FormLabel>Faction</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
</>
)
default:
return null
}
}
return (
<form>
{/* Standard fields */}
<FormField name="name" ... />
{/* Dynamic genre-specific fields */}
{renderAttributeFields()}
</form>
)
}
Server Action Validation:
export async function createCharacter(values: any) {
// Validate base schema
const validated = characterSchema.parse(values)
// Fetch world to validate attributes
const world = await prisma.world.findUnique({ where: { id: validated.worldId } })
if (!world) throw new Error("World not found")
// Validate genre-specific attributes
if (world.genre === "fantasy" && validated.attributes) {
fantasyAttributesSchema.parse(validated.attributes)
} else if (world.genre === "scifi" && validated.attributes) {
scifiAttributesSchema.parse(validated.attributes)
}
// Create character
const character = await prisma.character.create({ data: validated })
return { success: true, data: character }
}
See template: assets/templates/custom-attributes.tsx
For long-form text fields (backstories, descriptions, lore):
Installation:
npm install @uiw/react-md-editor
Component:
"use client"
import dynamic from "next/dynamic"
import { useState } from "react"
import "@uiw/react-md-editor/markdown-editor.css"
import "@uiw/react-markdown-preview/markdown.css"
// Import dynamically to avoid SSR issues
const MDEditor = dynamic(
() => import("@uiw/react-md-editor"),
{ ssr: false }
)
export function MarkdownField({
value,
onChange,
label,
height = 300,
}: {
value?: string
onChange: (value: string) => void
label: string
height?: number
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<MDEditor
value={value}
onChange={(val) => onChange(val || "")}
height={height}
preview="edit" // "edit" | "live" | "preview"
/>
</div>
)
}
Integration with React Hook Form:
<FormField
control={form.control}
name="backstory"
render={({ field }) => (
<FormItem>
<MarkdownField
value={field.value}
onChange={field.onChange}
label="Backstory"
height={400}
/>
<FormDescription>
Use Markdown formatting for rich text
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
Schema:
export const characterSchema = z.object({
name: z.string().min(1),
backstory: z.string().optional(),
// Markdown is stored as plain text in database
})
Display Markdown:
import ReactMarkdown from "react-markdown"
export function CharacterDetail({ character }) {
return (
<div>
<h1>{character.name}</h1>
<div className="prose dark:prose-invert">
<ReactMarkdown>{character.backstory}</ReactMarkdown>
</div>
</div>
)
}
See template: assets/templates/markdown-editor.tsx
For managing connections between entities (character-to-character relationships, location hierarchies):
Database Schema (from worldcrafter-database-setup):
model CharacterRelationship {
id String @id @default(uuid())
fromCharacterId String
toCharacterId String
relationshipType String // "friend", "enemy", "family", "ally", etc.
description String?
fromCharacter Character @relation("RelationshipsFrom", fields: [fromCharacterId], references: [id], onDelete: Cascade)
toCharacter Character @relation("RelationshipsTo", fields: [toCharacterId], references: [id], onDelete: Cascade)
@@map("character_relationships")
}
model Character {
id String @id @default(uuid())
name String
relationshipsFrom CharacterRelationship[] @relation("RelationshipsFrom")
relationshipsTo CharacterRelationship[] @relation("RelationshipsTo")
@@map("characters")
}
Relationships Panel Component:
"use client"
import { useState } from "react"
import { Plus, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select } from "@/components/ui/select"
import { addRelationship, removeRelationship } from "./actions"
export function RelationshipsPanel({
characterId,
relationships
}: {
characterId: string
relationships: any[]
}) {
const [isAddingRelationship, setIsAddingRelationship] = useState(false)
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Relationships</h3>
<Button
size="sm"
onClick={() => setIsAddingRelationship(true)}
>
<Plus className="w-4 h-4 mr-1" />
Add Relationship
</Button>
</div>
{/* List existing relationships */}
<div className="space-y-2">
{relationships.map((rel) => (
<div
key={rel.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">{rel.toCharacter.name}</p>
<p className="text-sm text-muted-foreground">
{rel.relationshipType}
</p>
{rel.description && (
<p className="text-sm mt-1">{rel.description}</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeRelationship(rel.id)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{relationships.length === 0 && (
<p className="text-muted-foreground text-sm">
No relationships yet
</p>
)}
</div>
{/* Add relationship modal */}
<AddRelationshipModal
open={isAddingRelationship}
onClose={() => setIsAddingRelationship(false)}
characterId={characterId}
/>
</div>
)
}
Add Relationship Modal:
function AddRelationshipModal({
open,
onClose,
characterId
}: {
open: boolean
onClose: () => void
characterId: string
}) {
const form = useForm({
resolver: zodResolver(relationshipSchema),
defaultValues: {
fromCharacterId: characterId,
toCharacterId: "",
relationshipType: "",
description: "",
}
})
async function onSubmit(values: any) {
const result = await addRelationship(values)
if (result.success) {
onClose()
form.reset()
}
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Relationship</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="toCharacterId"
render={({ field }) => (
<FormItem>
<FormLabel>Character</FormLabel>
<CharacterSelect
value={field.value}
onChange={field.onChange}
excludeId={characterId}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="relationshipType"
render={({ field }) => (
<FormItem>
<FormLabel>Relationship Type</FormLabel>
<Select onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="friend">Friend</SelectItem>
<SelectItem value="enemy">Enemy</SelectItem>
<SelectItem value="family">Family</SelectItem>
<SelectItem value="ally">Ally</SelectItem>
<SelectItem value="rival">Rival</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (Optional)</FormLabel>
<Textarea {...field} />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit">Add Relationship</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
Server Actions:
"use server"
export async function addRelationship(values: any) {
const validated = relationshipSchema.parse(values)
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { success: false, error: "Unauthorized" }
// Verify user owns the character
const character = await prisma.character.findUnique({
where: { id: validated.fromCharacterId },
select: { userId: true }
})
if (!character || character.userId !== user.id) {
return { success: false, error: "Forbidden" }
}
const relationship = await prisma.characterRelationship.create({
data: validated
})
revalidatePath(`/characters/${validated.fromCharacterId}`)
return { success: true, data: relationship }
}
export async function removeRelationship(id: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { success: false, error: "Unauthorized" }
// Verify ownership
const relationship = await prisma.characterRelationship.findUnique({
where: { id },
include: { fromCharacter: { select: { userId: true } } }
})
if (!relationship || relationship.fromCharacter.userId !== user.id) {
return { success: false, error: "Forbidden" }
}
await prisma.characterRelationship.delete({ where: { id } })
revalidatePath(`/characters/${relationship.fromCharacterId}`)
return { success: true }
}
See template: assets/templates/relationships-panel.tsx
references/feature-patterns.md - Detailed architectural patternsreferences/testing-guide.md - Testing conventions and examplesreferences/advanced-features-guide.md - NEW v2.0: Multi-step wizards, image uploads, custom attributes, markdown, relationshipsreferences/related-skills.md - How this skill works with other WorldCrafter skillsassets/templates/ - Complete template files for quick scaffolding
multi-step-wizard.tsx - NEW v2.0: Character creation wizard exampleimage-upload.tsx - NEW v2.0: Supabase Storage image upload componentcustom-attributes.tsx - NEW v2.0: Dynamic genre-specific attributes formmarkdown-editor.tsx - NEW v2.0: Rich text editing with @uiw/react-md-editorrelationships-panel.tsx - NEW v2.0: Entity relationship management UIThis skill works seamlessly with other WorldCrafter skills to provide complete feature implementation.
Complete Feature with Authentication:
Quick Feature (No Database):
Database-First Approach:
Claude will naturally orchestrate multiple skills when:
Example orchestration:
User: "Add a comments feature for blog posts with user authentication"
Claude's workflow:
1. worldcrafter-database-setup: Create Comment model with RLS policies
2. worldcrafter-feature-builder: Create comment form, Server Actions, UI
3. worldcrafter-auth-guard: Add auth checks to ensure only logged-in users can comment
4. Tests are automatically included by feature-builder
Choose this skill when:
Choose worldcrafter-route-creator when:
Choose worldcrafter-database-setup when:
A complete feature implementation includes:
npm run build)npm run test:all)Handles the full Supabase workflow from schema changes to deployment, with built-in security guardrails that catch common traps like RLS...
Comprehensive guides and best practices for Neon Serverless Postgres, covering setup, connection methods, authentication, and platform APIs.
Guide for setting up and using Firebase Authentication. Use this skill when the user's app requires user sign-in, user management, or secure data access using auth rules.
A skill to evaluate how secure Firestore security rules are. Use this when Firestore security rules are updated to ensure that the generated rules are extremely secure and robust.
Official skill for integrating Firebase AI Logic (Gemini API) into web applications. Covers setup, multimodal inference, structured output, and security.
Complete Better Auth server and client setup with database adapters, session management, plugins, and security configuration.
Deploy and manage projects on Vercel using token-based authentication. Use when working with Vercel CLI using access tokens rather than interactive login — e.g. "deploy to vercel", "set up vercel", "add environment variables to vercel".
Complete Cloudflare platform integration with decision trees for compute, storage, AI, networking, security, and infrastructure-as-code.
Run Azure compliance and security audits with azqr plus Key Vault expiration checks. Covers best-practice assessment, resource review, policy/compliance validation, and security posture checks. WHEN: compliance scan, security audit, BEFORE running azqr (compliance cli tool), Azure best practices, Key Vault expiration check, expired certificates, expiring secrets, orphaned resources, compliance assessment.
Send, read, and manage Gmail messages, drafts, labels, and account settings.
Comprehensive website auditing across 230+ rules in 21 categories including SEO, performance, security, and accessibility.
Shared authentication, CLI syntax, and output formatting patterns for gws Google Workspace commands.
Discover your celebrity doppelgänger with StarByFace!
GeoSpy: Pricing, Features, FAQs, and Alternatives for AI Teams
Detect AI-generated voices to protect against audio fraud.
Ensure Your Content's Originality with AI Plagiarism Checker
Upscale images by 400% without quality loss
Protect your social media from copyright disputes
Accurately Detect AI-Generated Content with TheChecker.AI
Chrome extension that detects and flags AI-generated content