Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```text
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```text
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```text
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,159 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```text
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```text
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```text
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```text
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,181 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```text
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,116 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,109 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "suaveplan-plugins",
"version": "1.0.0",
"description": "Suaveplan project-local plugins tailored for React 19 + TypeScript + Tailwind CSS + shadcn/ui monorepo",
"owner": {
"name": "SuavePlan"
},
"plugins": [
{
"name": "suaveplan-frontend-design",
"description": "Suaveplan-specific frontend design skill for React + Tailwind + shadcn/ui. Enforces project conventions and available @suaveplan/* libraries.",
"version": "1.0.0",
"source": "./plugins/suaveplan-frontend-design",
"category": "development"
},
{
"name": "suaveplan-webapp-testing",
"description": "Playwright E2E testing toolkit for Suaveplan browser packages. TypeScript .spec.ts tests, Storybook visual testing, shadcn/ui component testing.",
"version": "1.0.0",
"source": "./plugins/suaveplan-webapp-testing",
"category": "testing"
},
{
"name": "suaveplan-web-artifacts",
"description": "Build production-grade React artifacts and demos using Suaveplan's component library. React 19 + Vite + Tailwind + shadcn/ui.",
"version": "1.0.0",
"source": "./plugins/suaveplan-web-artifacts",
"category": "development"
},
{
"name": "suaveplan-mcp-builder",
"description": "Create MCP servers that expose Suaveplan plugin functionality. Maps to definePlugin() architecture with Hono routes and SurrealDB.",
"version": "1.0.0",
"source": "./plugins/suaveplan-mcp-builder",
"category": "development"
},
{
"name": "suaveplan-doc-coauthoring",
"description": "Structured workflow for co-authoring Suaveplan package documentation. Enforces .md template, simple+advanced examples, and 90%+ coverage.",
"version": "1.0.0",
"source": "./plugins/suaveplan-doc-coauthoring",
"category": "documentation"
},
{
"name": "suaveplan-skill-creator",
"description": "Guide for creating project-local skills in the suaveplan-plugins marketplace. Covers plugin structure, SKILL.md authoring, and registration.",
"version": "1.0.0",
"source": "./plugins/suaveplan-skill-creator",
"category": "development"
},
{
"name": "suaveplan-theme-factory",
"description": "Tailwind CSS + shadcn/ui theming toolkit with 10 curated themes. HSL color system, dark mode, CSS variables for consistent UI styling.",
"version": "1.0.0",
"source": "./plugins/suaveplan-theme-factory",
"category": "development"
},
{
"name": "suaveplan-threejs",
"description": "React Three Fiber (R3F) development skills for Suaveplan's 3D packages. Covers animation, geometry, interaction, lighting, loaders, materials, post-processing, shaders, textures, and fundamentals.",
"version": "1.0.0",
"source": "./plugins/suaveplan-threejs",
"category": "development"
},
{
"name": "suaveplan-webgpu",
"description": "WebGPU rendering with Three.js TSL in React Three Fiber. GPU compute shaders, node materials, post-processing, and WGSL integration for the Suaveplan R3F ecosystem.",
"version": "1.0.0",
"source": "./plugins/suaveplan-webgpu",
"category": "development"
},
{
"name": "suaveplan-package-structure",
"description": "Library package structure patterns, code organization rules, documentation requirements, and Storybook conventions for the Suaveplan monorepo.",
"version": "1.0.0",
"source": "./plugins/suaveplan-package-structure",
"category": "development"
},
{
"name": "suaveplan-testing-workflow",
"description": "Testing commands, CI/CD integration, advanced turborepo filtering, E2E/Storybook testing, development workflow, and troubleshooting.",
"version": "1.0.0",
"source": "./plugins/suaveplan-testing-workflow",
"category": "testing"
},
{
"name": "suaveplan-dependency-management",
"description": "Bun monorepo dependency management with version overrides, catalogs, workspace protocol, tree-shaking, and React version conflict resolution.",
"version": "1.0.0",
"source": "./plugins/suaveplan-dependency-management",
"category": "development"
},
{
"name": "suaveplan-bun-runtime",
"description": "Bun v1.3.11 runtime APIs, CLI commands, bundler, test runner, database clients, and performance patterns for the Suaveplan monorepo.",
"version": "1.0.0",
"source": "./plugins/suaveplan-bun-runtime",
"category": "development"
},
{
"name": "suaveplan-bitpacker",
"description": "Design and create BitPacker binary serialization schemas for compact data encoding in the Suaveplan monorepo using @suaveplan/bitpacker.",
"version": "1.0.0",
"source": "./plugins/suaveplan-bitpacker",
"category": "development"
}
]
}

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-bitpacker",
"version": "1.0.0",
"description": "BitPacker binary serialization schema design for Suaveplan"
}

View File

@@ -0,0 +1,714 @@
---
name: bitpacker-schemas
description: Design and create BitPacker binary serialization schemas for compact data encoding in the Suaveplan monorepo using @suaveplan/bitpacker
---
# BitPacker Schemas for Suaveplan
Define compact binary serialization schemas using `@suaveplan/bitpacker`. Schemas describe data shapes declaratively -- the library handles bit-level packing, unpacking, validation, delta compression, and checksums. Use this skill when designing network protocols, game state sync, compact storage formats, or any scenario requiring minimal payload size.
## Architecture
| Layer | Technology |
|-------|-----------|
| Schema Definition | Plain TypeScript objects with `as const` |
| Serialization | `BitPacker` class from `@suaveplan/bitpacker` |
| Delta Compression | Field-level, presence-based, or none |
| Integrity | CRC32, Adler32, XXHash32 checksums |
| Validation | Built-in schema matching and error reporting |
## Schema Type Reference
Every value in a schema object tells BitPacker how to encode that field. The type is inferred from the value syntax.
### Primitives
| Syntax | Type | Bits | Example | Notes |
|--------|------|------|---------|-------|
| `true` or `false` | Boolean | 1 | `{ active: true }` | Single bit |
| `null` | Null | 0 | `{ empty: null }` | Marker only |
| `N` (number) | Unsigned integer | ceil(log2(N+1)) | `{ age: 255 }` | Range 0..N |
| `"uuid"` | UUID | 128 | `{ id: "uuid" }` | Standard UUID string |
### Strings
| Syntax | Type | Example | Notes |
|--------|------|---------|-------|
| `"N"` | Fixed-length string | `{ code: "5" }` | Exactly N chars, padded |
| `"-N"` | Variable-length string | `{ name: "-20" }` | Up to N chars, length-prefixed |
Variable-length strings (`"-N"`) are almost always preferred -- fixed-length wastes bits on short values.
### Numbers with Decimals (Fixed-Point)
| Syntax | Type | Example | Encoded as |
|--------|------|---------|------------|
| `"W.D"` | Unsigned fixed-point | `{ price: "999.2" }` | Integer with D decimal digits, range 0..W |
| `"-W.D"` | Signed fixed-point | `{ x: "-100.3" }` | Signed integer, range -W..W, D decimals |
Fixed-point is more compact than floats when range and precision are known. `"100.2"` stores values like `42.85` using far fewer bits than `f32`.
### IEEE Floats
| Syntax | Bits | Example |
|--------|------|---------|
| `"f16"` | 16 | `{ ratio: "f16" }` |
| `"f32"` | 32 | `{ temperature: "f32" }` |
| `"f64"` | 64 | `{ precise: "f64" }` |
Use only when range or precision is unbounded. Prefer fixed-point when possible.
### BigInt
| Syntax | Bits | Example |
|--------|------|---------|
| `"bigint-u64"` | 64 | `{ snowflake: "bigint-u64" }` |
| `"bigint-i64"` | 64 | `{ offset: "bigint-i64" }` |
### TypedArrays
Format: `[-)][length][type]` where type is `u8`, `u16`, `u32`, `i8`, `i16`, `i32`, `f32`, `f64`.
| Syntax | Type | Example | Notes |
|--------|------|---------|-------|
| `"Nu8"` | Fixed Uint8Array | `{ rgb: "3u8" }` | Exactly N elements |
| `"-Nu8"` | Variable Uint8Array | `{ buf: "-100u8" }` | Up to N elements |
| `"Nf32"` | Fixed Float32Array | `{ coords: "3f32" }` | Exactly N elements |
| `"-Nf32"` | Variable Float32Array | `{ verts: "-100f32" }` | Up to N elements |
### Enums
| Syntax | Type | Bits | Example | Notes |
|--------|------|------|---------|-------|
| `"enum:a\|b\|c"` | String literal union | `ceil(log2(N))` | `{ team: "enum:red\|green\|blue" }` | Values split on `\|`, order-sensitive |
`EnumPacker` selects the index, not the string. Do not reorder values across protocol versions without migration support.
### Date / Time
| Syntax | Type | Bits | Notes |
|--------|------|------|-------|
| `"timestamp"` | ms since epoch | 42 | ~139 years from epoch |
| `"timestamp-ms"` | ms since epoch | 53 | full JS safe integer |
| `"timestamp-s"` | seconds since epoch | 32 | standard Unix timestamp |
| `"timespan"` | duration in ms | 42 | long durations |
| `"timespan-ms"` | duration in ms | 32 | up to ~49.7 days |
| `"date-only"` | calendar date | 21 | 12-bit year + 4-bit month + 5-bit day |
Unpack returns a `number` (ms since epoch) for all tokens; `date-only` also returns a ms timestamp (midnight local time). You may pack a `Date` instance or a `number`.
### Variable-Length Integers
| Syntax | Payload | Notes |
|--------|---------|-------|
| `"varint"` | `number` unsigned | 1–5 bytes; tight for small values |
| `"varint-signed"` | `number` signed | ZigZag-encoded |
| `"varint-u32"` | `number` unsigned, range-checked to 2³²−1 | |
| `"varint-u64"` | `bigint` unsigned | 1–10 bytes |
### Bitsets
| Syntax | Type | Notes |
|--------|------|-------|
| `"bitset-N"` | `boolean[]` length N | fixed, no length prefix |
| `"bitset--M"` | `boolean[]`, max M | variable; `ceil(log2(M+1))`-bit length prefix |
One bit per element. `"bitset-16"` is 16 bits on the wire.
### Maps and Sets
| Syntax | Type | Notes |
|--------|------|-------|
| `"map<K,V>"` | `Map<K, V>` | `maxEntries` defaults to 65535 |
| `"map<K,V,N>"` | `Map<K, V>` | explicit `maxEntries = N` |
| `"set<V,N>"` | `Set<V>` | `N` sizes the length prefix (required) |
`K` and `V` follow the same disambiguation as other tokens:
- bare digits (`255`) → integer-range schema (number)
- leading `-` or non-numeric (`-20`, `uuid`) → string schema
So `"map<-20,255,1024>"` = map from variable-length string ≤20 chars to integer 0..255, up to 1024 entries.
### Custom Types
Register a handler and reference it by name:
```typescript
import {
BitPacker,
CustomTypeRegistry,
DateTypeHandler,
} from '@suaveplan/bitpacker';
CustomTypeRegistry.getInstance().register(new DateTypeHandler());
const schema = { createdAt: { _customType: 'date' } } as const;
const packer = new BitPacker(schema);
```
The strategy length-prefixes the handler's `Uint8Array` output with 32 bits so handlers with variable frames stay self-delimiting.
### BigInt Arrays
| Syntax | Example |
|--------|---------|
| `"bigint-u64[]"` | `{ ids: "bigint-u64[]" }` |
| `"bigint-i64[]"` | `{ offsets: "bigint-i64[]" }` |
### Binary Blobs
| Syntax | Type | Example | Notes |
|--------|------|---------|-------|
| `"~"` | Adaptive blob | `{ data: "~" }` | Uint8Array with 2-bit width-tagged length prefix |
Uses a 2-bit tag to self-describe the length prefix size (2-5 bytes). The packer picks the smallest tag that fits:
| Tag | Length bytes | Max size |
|-----|-------------|----------|
| 0 | 2 | 64 KiB |
| 1 | 3 | 16 MiB |
| 2 | 4 | 4 GiB |
| 3 | 5 | 1 TiB |
Use for binary payloads with unpredictable sizes (file contents, images, buffers). No max declaration needed in the schema.
### Nested Objects
Nest schemas arbitrarily deep using plain objects:
```typescript
const schema = {
player: {
name: "-20",
position: {
x: "-1000.2",
y: "-1000.2",
z: "-1000.2"
}
}
} as const;
```
### Arrays
Three equivalent notations for arrays. Choose based on readability.
**Notation 1: String shorthand** -- `-maxLength[elementSchema]`
```typescript
{ scores: "-10[255]" } // Up to 10 integers 0-255
{ names: "-5[-20]" } // Up to 5 variable strings
{ flags: "-8[true]" } // Up to 8 booleans
{ matrix: "-4[-3[255]]" } // Nested: 4 arrays of 3 ints
{ items: '-3[{"id":"uuid","name":"-20"}]' } // Up to 3 objects
```
**Notation 2: `_bp` config** -- preferred in TypeScript for type safety
```typescript
{
scores: {
_bp: {
schema: {
type: "array",
elementType: "integer", // "integer" | "float" | "string" | "boolean" | "schema"
elementMax: 255, // For integer/float: max value
maxLength: 10
}
}
}
}
```
**Notation 3: Direct config object**
```typescript
{
scores: {
type: "array",
elementType: "integer",
elementMax: 255,
maxLength: 10
}
}
```
### Optional Fields
Three forms, each valid pure JSON:
```typescript
// 1. Primitive "?" suffix — one presence bit, then the base token.
{ nickname: "-20?", id: "uuid?", lastSeen: "timestamp-ms?" }
// 2. Object-level _optional marker, with optional `_default` on absence.
{
profile: {
_optional: true,
_schema: { bio: "-200", avatar: "uuid" },
_default: null
}
}
// 3. Legacy `_bp` wrapper (still supported).
{ nickname: { _bp: { schema: { type: "-20" }, value: "" } } }
```
### Discriminated Unions (`_oneOf`)
```typescript
{
_oneOf: {
tag: "kind",
variants: {
move: { x: 255, y: 255 },
chat: { text: "-128" },
spawn: { id: "uuid" }
}
}
}
// accepts { kind: "move", x, y } | { kind: "chat", text } | { kind: "spawn", id }
```
Packs `ceil(log2(N))` bits of variant index + the matching variant's
payload. The tag field is reconstructed on unpack.
### Tuples (Heterogeneous Arrays)
```typescript
// Array literal with different per-index schemas.
["uuid", 255, "-20"] // [string-uuid, u8, var-string]
```
Each index is packed against its own schema; tuple length is fixed by
the schema itself.
### Shared Sub-Schemas (`_defs` / `$ref`)
```typescript
{
_defs: {
position: { x: 255, y: 255, z: 255 }
},
_schema: {
from: { $ref: "position" },
to: { $ref: "position" }
}
}
```
`$ref` is resolved eagerly — the referenced sub-schema is inlined before
packing. Cycles throw.
### Schema Version (`_version`)
```typescript
{ _version: 3, _schema: { id: "uuid", name: "-40" } }
```
Packs a 16-bit version tag in front of the payload. On unpack, a
mismatch throws so callers can dispatch to a `VersionedSchema`
migration.
### Conditional Fields (`_if` / `_then` / `_else`)
```typescript
{
weapon: 15,
ammo: {
_if: { field: "weapon", op: "!=", value: 0 },
_then: 255,
_else: null
}
}
```
The predicate is a pure JSON data structure — no callbacks. Supported
ops: `==`, `!=`, `<`, `<=`, `>`, `>=`, `in` (value must be an array),
`has` (field must be an array). The referenced field must appear
earlier in its containing object so the predicate can evaluate at
unpack time.
## Usage Patterns
### Basic Pack/Unpack
```typescript
import { BitPacker } from '@suaveplan/bitpacker';
const schema = {
id: "uuid",
name: "-30",
score: 1000,
active: true
} as const;
const packer = new BitPacker(schema);
const packed: Uint8Array = packer.pack({
id: "123e4567-e89b-12d3-a456-426614174000",
name: "Alice",
score: 847,
active: true
});
const unpacked = packer.unpack(packed);
```
### Delta Compression
Send only changed fields after the first full state. Three modes:
```typescript
// No delta (default) -- full state every time
new BitPacker(schema);
// Presence-based -- bit flag per field, skips unchanged
new BitPacker(schema, { delta: true });
// Field-level -- XOR diffs for numbers, granular tracking
new BitPacker(schema, {
delta: {
mode: 'field-level',
xorNumbers: true,
maxFieldCount: 64,
stateKey: 'player-42'
}
});
```
Delta usage:
```typescript
const packer = new BitPacker(schema, { delta: { mode: 'field-level' } });
const state1 = { x: 100, y: 200, health: 100, name: "Alice" };
packer.pack(state1); // Full state (baseline)
const state2 = { x: 150, y: 200, health: 100, name: "Alice" };
const delta = packer.pack(state2); // Only x changed -- much smaller
const restored = packer.unpack(delta); // Full object restored
```
### Checksums
```typescript
const packer = new BitPacker(schema, {
checksum: {
algorithm: 'crc32', // 'crc32' | 'adler32' | 'xxhash32' | 'none'
verifyOnUnpack: true,
throwOnError: true
}
});
// 4-byte checksum auto-appended on pack, auto-verified on unpack
```
### Validation
```typescript
const packer = new BitPacker(schema);
// Quick check
if (packer.validate(data)) {
packer.pack(data);
}
// Detailed errors
const errors = packer.getValidationErrors(data);
// [{ path: "score", code: "OUT_OF_RANGE", message: "..." }]
// Schema matching -- find structural differences
const result = packer.match(schema, data);
// { additions: [...], subtractions: [...], mismatches: [...] }
```
### Configuration Options
```typescript
new BitPacker(schema, {
floatsAsStrings: false, // true: unpack fixed-point as "2.85" strings
validateOnPack: true, // Validate data before packing
validateOnUnpack: true, // Validate data after unpacking
strictValidation: true, // Warnings become errors
safetyLimits: {
maxBufferSize: 1_048_576, // 1MB max packed size
maxRecursionDepth: 100, // Nesting depth limit
maxStringLength: 10_000, // Per-string char limit
maxArrayLength: 10_000 // Per-array element limit
}
});
```
### Events
```typescript
packer.on('beforePack', (data) => { /* inspect */ });
packer.on('afterPack', (packed) => { /* log size */ });
packer.on('packError', (error) => { /* handle */ });
packer.on('beforeUnpack', (packed) => { /* inspect */ });
packer.on('afterUnpack', (data) => { /* log */ });
packer.on('unpackError', (error) => { /* handle */ });
```
## Schema Design Guidelines
### 1. Choose the tightest type
Pick the smallest encoding that covers your value range:
| Value Range | Best Type | Bits |
|-------------|-----------|------|
| true/false | `true` | 1 |
| 0-1 | `1` | 1 |
| 0-255 | `255` | 8 |
| 0-65535 | `65535` | 16 |
| -100.00 to 100.00 | `"-100.2"` | ~15 |
| Any 32-bit float | `"f32"` | 32 |
| UUID | `"uuid"` | 128 |
### 2. Prefer fixed-point over floats
`"100.2"` uses ~14 bits. `"f32"` always uses 32 bits. If you know the range and precision, fixed-point wins.
### 3. Use variable-length strings
`"-20"` stores a 3-char name in ~27 bits (length prefix + 3 chars). `"20"` always uses ~140 bits (20 chars padded).
### 4. Use delta for real-time state
Game state, sensor data, or any frequently-updated structure benefits from field-level delta. Only changed fields are transmitted.
### 5. Size arrays conservatively
`"-10[255]"` allocates bits for the length prefix based on 10. If your array rarely exceeds 4 items, use `"-4[255]"` instead.
### 6. Use `as const` on schema objects
TypeScript const assertions enable full type inference on pack/unpack:
```typescript
const schema = { name: "-20", score: 255 } as const;
const packer = new BitPacker(schema);
// packer.pack() expects { name: string; score: number }
// packer.unpack() returns { name: string; score: number }
```
## Complete Example: Game State Schema
```typescript
import { BitPacker } from '@suaveplan/bitpacker';
const playerSchema = {
id: "uuid",
name: "-24",
position: {
x: "-5000.2",
y: "-500.2",
z: "-5000.2"
},
rotation: {
yaw: "-180.1",
pitch: "-90.1"
},
velocity: {
x: "-50.1",
y: "-50.1",
z: "-50.1"
},
health: 100,
armor: 100,
weapon: 15,
ammo: 999,
crouching: true,
sprinting: true,
team: 3,
inventory: "-8[255]",
statusEffects: "-4[15]"
} as const;
const packer = new BitPacker(playerSchema, {
delta: { mode: 'field-level', xorNumbers: true, stateKey: 'player' },
checksum: { algorithm: 'crc32', verifyOnUnpack: true }
});
```
### Registering a Custom Strategy
`BitPacker.registerStrategy(strategy)` slots a new `PackingStrategy` into the chain immediately before the catch-all `InvalidSchemaStrategy`. Use this for protocol-specific notations you don't want to fold into the core library:
```typescript
import type { PackingStrategy } from '@suaveplan/bitpacker';
class RoundedPercentStrategy implements PackingStrategy {
canHandle(schema) { return schema === 'pct'; }
pack(data, _schema, ctx) { ctx.bitOps.packBits(Math.round(Number(data)), 7); }
unpack(_schema, ctx) { return ctx.bitOps.unpackBits(7); }
}
const packer = new BitPacker({ progress: 'pct' });
packer.registerStrategy(new RoundedPercentStrategy());
```
## Standalone Packers
These packers are exported from `@suaveplan/bitpacker` and are ALSO routed through the declarative schema notation (enum / datetime / varint / bit-array / map / set). You can still use them directly on `BitOperations` when you need to hand-compose a bit stream outside of `new BitPacker(schema)`.
### VarInt (Variable-Length Integers)
Protobuf-style encoding: small values use fewer bytes (1 byte for 0-127). Each byte uses 7 data bits + 1 continuation bit. This IS the dynamic-size encoding mechanism in BitPacker.
```typescript
import { VarIntPacker, BitOperations } from '@suaveplan/bitpacker';
const bitOps = new BitOperations();
const packer = new VarIntPacker(bitOps);
// Schema types: 'varint' | 'varint-signed' | 'varint-u32' | 'varint-u64'
packer.pack(300, 'varint'); // 2 bytes (vs fixed 4 bytes)
packer.pack(-42, 'varint-signed'); // ZigZag: small negatives stay compact
packer.packBigInt(9007199254740993n, 'varint-u64'); // 64-bit BigInt
// Standalone (no BitOperations needed)
const { bytes, byteCount } = VarIntPacker.encode(300); // 2 bytes
const { value } = VarIntPacker.decode(bytes); // 300
const needed = VarIntPacker.bytesNeeded(300); // 2
```
Bytes needed per value range:
| Range | Bytes |
|-------|-------|
| 0-127 | 1 |
| 128-16383 | 2 |
| 16384-2097151 | 3 |
| 2097152-268435455 | 4 |
| 268435456+ | 5 |
### Enum
Automatic minimum-bit encoding. `ceil(log2(N))` bits for N values.
```typescript
import { EnumPacker, BitOperations } from '@suaveplan/bitpacker';
const bitOps = new BitOperations();
const packer = new EnumPacker(bitOps);
const schema = { type: 'enum' as const, values: ['red', 'green', 'blue'] };
packer.pack('green', schema); // 2 bits (ceil(log2(3)))
const value = packer.unpack(schema); // 'green'
```
Config options: `includeValidityBit` (nullable enums), `allowUnknown` (forward-compatible).
### DateTime
Compact timestamp/timespan encoding with multiple precision levels.
```typescript
import { DateTimePacker, BitOperations } from '@suaveplan/bitpacker';
const bitOps = new BitOperations();
const packer = new DateTimePacker(bitOps, { epoch: 0 });
packer.pack(Date.now(), 'timestamp'); // 42-bit ms (~139 years)
packer.pack(Date.now(), 'timestamp-ms'); // 53-bit ms (full JS safe int)
packer.pack(Date.now(), 'timestamp-s'); // 32-bit seconds (Unix)
packer.pack(60000, 'timespan'); // 42-bit ms duration
packer.pack(60000, 'timespan-ms'); // 32-bit ms duration (~49.7 days)
packer.pack(new Date(), 'date-only'); // 21-bit year/month/day
```
### Map and Set
```typescript
import { MapPacker, SetPacker, BitOperations } from '@suaveplan/bitpacker';
// Map<string, number>
const mapPacker = new MapPacker(bitOps, { sortKeys: true });
const mapSchema = { type: 'map', keySchema: '-20', valueSchema: 255, maxEntries: 10 };
mapPacker.pack(new Map([['hp', 100], ['mp', 50]]), mapSchema);
// Set<number>
const setPacker = new SetPacker(bitOps, { sortValues: true });
const setSchema = { type: 'set', valueSchema: 255, maxElements: 10 };
setPacker.pack(new Set([1, 2, 3]), setSchema);
```
### BitArray
1 bit per boolean (8:1 compression vs byte-per-boolean). Fixed or variable length.
```typescript
import { BitArrayPacker, BitOperations } from '@suaveplan/bitpacker';
const bitOps = new BitOperations();
const packer = new BitArrayPacker(bitOps);
// Fixed-length: exactly 8 booleans = 8 bits
packer.pack([true, false, true, true, false, false, true, false],
{ type: 'bit-array', length: 8, variable: false });
// Variable-length: up to 32 booleans, length-prefixed
packer.pack([true, false, true],
{ type: 'bit-array', length: 32, variable: true });
```
## Declarative vs. Imperative
The library has two API surfaces:
1. **Declarative schema** (`new BitPacker(schema)`) — the schema notation described above. Handles pack/unpack/validate/delta/checksum/custom types automatically. Use this for almost everything.
2. **Standalone packers** (`VarIntPacker`, `EnumPacker`, `DateTimePacker`, `MapPacker`, `SetPacker`, `BitArrayPacker`) — operate directly on `BitOperations` for hand-composed bit streams outside of the schema engine.
The standalone packers are now also reachable as schema tokens (enum / datetime / varint / bit-array / map / set) and via `_customType` + `CustomTypeRegistry`. Drop to imperative only when you explicitly need to share a single `BitOperations` buffer across multiple packers.
## Packing Strategy Precedence
`BitPacker` tests strategies in this order and uses the first whose `canHandle` returns true:
1. Null (`null`)
2. Optional (`_bp` wrapper)
3. Array config (object with `type: "array"`)
4. Array notation (`"-N[...]"` string)
5. Boolean (`true`/`false`)
6. BigInt array (`"bigint-u64[]"`)
7. BigInt (`"bigint-u64"`)
8. Blob (`"~"`)
9. Enum token (`"enum:..."`)
10. DateTime tokens (`"timestamp"`, `"timestamp-ms"`, `"timestamp-s"`, `"timespan"`, `"timespan-ms"`, `"date-only"`)
11. VarInt tokens (`"varint"`, `"varint-signed"`, `"varint-u32"`, `"varint-u64"`)
12. Bitset tokens (`"bitset-N"`, `"bitset--M"`)
13. Map tokens (`"map<K,V>"`, `"map<K,V,N>"`)
14. Set tokens (`"set<V,N>"`)
15. String (remaining string values including UUID, float notation, typed arrays)
16. Number (numeric values)
17. Array literal (`[]`)
18. Custom type marker (`{ _customType: "..." }`)
19. Object (nested schema)
20. User-registered strategies via `registerStrategy`
21. Invalid schema (catch-all error)
## Constraints
- Always use `as const` on schema objects for type inference
- Integer max values must be non-negative (range is 0..N for unsigned)
- Signed types use `-` prefix: `"-100.2"` for signed fixed-point, `"-Nu8"` for variable typed arrays
- Array string notation uses JSON for object elements: `'-3[{"id":255}]'`
- Delta compression requires consistent schema between sender and receiver
- Safety limits default to 1MB buffer, 100 depth, 10k string/array length
- Schema objects are immutable after BitPacker construction
## Checklist
When designing a new BitPacker schema:
1. List every field and its value range
2. Choose the tightest type for each field (fixed-point over float, variable over fixed string)
3. Use `as const` assertion on the schema object
4. Decide on delta mode based on update frequency (real-time: field-level, occasional: none)
5. Add checksum if data crosses trust boundaries (network, storage)
6. Set safety limits appropriate to your use case
7. Validate with `getValidationErrors()` during development
8. Test pack/unpack round-trip with edge case values (0, max, empty strings, full arrays)
9. Measure packed size against alternatives (JSON, MessagePack) to confirm savings

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-bun-runtime",
"version": "1.0.0",
"description": "Bun v1.3.11 runtime APIs, CLI commands, bundler, test runner, database clients, and performance patterns for the Suaveplan monorepo."
}

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-dependency-management",
"version": "1.0.0",
"description": "Bun monorepo dependency management, version overrides, catalogs, workspace protocol, and tree-shaking"
}

View File

@@ -0,0 +1,198 @@
---
name: dependency-management
description: Bun monorepo dependency management with version overrides, catalogs, workspace protocol, tree-shaking configuration, and React version conflict resolution
---
# Dependency Management
Use this skill when adding dependencies, resolving version conflicts, configuring tree-shaking, or troubleshooting build issues related to package versions in the Suaveplan monorepo.
## Bun Monorepo Dependency Management
### Critical Rules
- **Always use exact versions** in `overrides` and `catalog` (not ranges like `^19.0.0`)
- Use Bun's `overrides` field (NOT `resolutions` -- that's npm/yarn)
- Bun only supports **top-level overrides** (no nested overrides)
### Example Configuration
```json
{
"overrides": {
"react": "19.0.0",
"react-dom": "19.0.0"
},
"workspaces": {
"catalog": {
"react": "19.0.0",
"react-dom": "19.0.0"
}
}
}
```
### Nuclear Clean (Required After Override Changes)
After changing `overrides` or `catalog`, do a complete nuclear clean:
```bash
rm -rf node_modules bun.lockb
find . -type d -name "node_modules" -exec rm -rf {} +
bun install --force
```
### Verify Single Version
```bash
find . -path "*/node_modules/react/package.json" -exec grep "version" {} \; | sort -u
# Should show only ONE version
```
## Check Framework Dependencies First
Before using any library:
1. Review `package.json` for existing dependencies
2. Follow existing import patterns in the package
3. Use workspace protocol (`workspace:*`) for internal deps
4. Use Bun catalogs for centralized version management
## Workspace Protocol
Internal package dependencies use `workspace:*`:
```json
{
"dependencies": {
"@suaveplan/core": "workspace:*",
"@suaveplan/types": "workspace:*"
}
}
```
## Bun Workspaces
Defined in root `package.json`:
```json
{
"workspaces": ["packages/*", "apps/*"]
}
```
## Bun Catalogs
Centralized dependency versions in root `package.json`:
```json
{
"catalogs": {
"react": "^18.2.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
}
}
```
Use in packages:
```json
{
"dependencies": {
"react": "catalog:react",
"typescript": "catalog:build"
}
}
```
## Tree-Shaking Requirement
**All library packages MUST declare side effects.**
### Pure Libraries
```json
{
"name": "@suaveplan/package-name",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
```
### Packages with CSS/Styles
```json
{
"sideEffects": ["**/*.css", "**/*.scss"]
}
```
### Verification Checklist
Verify no actual side effects in entry points:
- No top-level function calls
- No global object mutations
- No immediate IIFEs
- Only `export` statements allowed
Required for optimal bundle sizes (30-50% reduction).
## Troubleshooting: React Version Conflicts
### Symptoms
- `Error: Minified React error #31`
- `TypeError: Cannot read properties of null (reading 'useContext')`
- Build fails during static page generation
### Root Cause
Multiple React versions in monorepo.
### Solution
```bash
# 1. Add exact version overrides to root package.json
# {
# "overrides": {
# "react": "19.0.0",
# "react-dom": "19.0.0",
# "@types/react": "19.0.0",
# "@types/react-dom": "19.0.0"
# }
# }
# 2. Update catalog to match (exact versions, no ^)
# {
# "workspaces": {
# "catalog": {
# "react": "19.0.0",
# "react-dom": "19.0.0"
# }
# }
# }
# 3. Nuclear clean install (REQUIRED!)
rm -rf node_modules bun.lockb
find . -type d -name "node_modules" -exec rm -rf {} +
bun install --force
# 4. Verify single React version
find . -path "*/node_modules/react/package.json" -exec grep version {} \; | sort -u
# Should show only ONE version
```
### Key Points
- Use Bun `overrides` (NOT `resolutions`)
- Use **exact versions** (no `^` or `~`)
- Complete clean install is REQUIRED
- Verify workspace catalog matches overrides

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-doc-coauthoring",
"version": "1.0.0",
"description": "Structured workflow for co-authoring Suaveplan package documentation"
}

View File

@@ -0,0 +1,274 @@
---
name: doc-coauthoring
description: Co-author module documentation for the Suaveplan monorepo following the module-docs template with simple + advanced examples, 200-word minimum, and 90%+ documentation coverage
---
# Documentation Co-Authoring for Suaveplan
Write and maintain co-located `.md` documentation files for every implementation module in the Suaveplan monorepo. Documentation is a first-class artifact -- every `.ts` or `.tsx` implementation file must have a corresponding `.md` file in the same directory. This skill follows a 3-stage workflow: context gathering, iterative refinement, and reader testing.
## Documentation Architecture
### File Mapping
```text
MyClass.ts -> MyClass.md (required)
my-hook.ts -> my-hook.md (required)
component.tsx -> component.md (required)
types.ts -> types.md (required -- for shared type files)
utils.ts -> utils.md (required)
index.ts -> (not required -- barrel exports only)
```
### Exclusions (No `.md` Required)
- `.test.ts` / `.test.tsx` -- test files
- `.spec.ts` -- Playwright E2E files
- `.types.ts` -- types files within a component directory (shared type files in `types/` DO need docs)
- `.stories.tsx` -- Storybook stories
- `constants/index.ts` -- simple constant exports
### Directory Hierarchy
Documentation follows a three-level hierarchy with each level linking down:
```text
packages/<category>/<package>/
README.md # Level 1: Package overview
src/
<category>/
README.md # Level 2: Category index
<feature>/
<feature>.md # Level 3: Module documentation
<feature>.ts
```
**Level 1 (Package README)**: Lists all categories with descriptions, quick start, installation.
**Level 2 (Category README)**: Lists all feature modules with one-line descriptions and links.
**Level 3 (Module `.md`)**: Full documentation per the template below.
## Template
All module documentation follows `.claude/templates/module-docs.md`. The minimum requirements are:
### Required Sections
1. **Purpose** -- What the module does and why it exists (minimum 2 sentences)
2. **Features** -- Bullet list of capabilities
3. **Usage: Basic Example** -- Simple import and usage (the "happy path")
4. **Usage: Advanced Example** -- Composition, configuration, edge cases
5. **API Reference** -- Every public export documented with parameters and return types
6. **Implementation Notes** -- Design decisions, performance considerations, known limitations
### Content Requirements
- **Minimum 200 words** of content (excluding code blocks)
- **Both simple AND advanced examples** are mandatory in every doc
- **Code examples must compile** -- use real types and imports from the package
- **No marketing language** -- state capabilities honestly
- **No placeholder text** -- every section must contain real content
- **Update when code changes** -- documentation must stay in sync
## 3-Stage Workflow
### Stage 1: Context Gathering
Before writing documentation, gather full context:
1. **Read the implementation file** -- understand every public export
2. **Read the types file** -- understand all interfaces and type definitions
3. **Read the test file** -- understand expected behavior and edge cases
4. **Read related modules** -- understand how this module fits in the package
5. **Read the category README** -- understand the broader context
```bash
# Example: documenting a crypto module
# Read the implementation
cat packages/universal/crypto/src/symmetric/encryption/encryption.ts
# Read the types
cat packages/universal/crypto/src/symmetric/encryption/encryption.types.ts
# Read the tests (understand behavior)
cat packages/universal/crypto/src/symmetric/encryption/encryption.test.ts
# Read the category README
cat packages/universal/crypto/src/symmetric/README.md
```
### Stage 2: Iterative Refinement
Write the documentation in passes:
**Pass 1 -- Structure**: Create all section headings and fill in Purpose + Features.
**Pass 2 -- Examples**: Write the Basic Example (simplest correct usage) and Advanced Example (realistic production usage showing composition, error handling, and configuration).
**Pass 3 -- API Reference**: Document every public export:
- Functions: parameters with types, return type, throws, example
- Classes: constructor, methods, properties
- Types/Interfaces: every field with description
- Constants: value, purpose, usage context
**Pass 4 -- Notes**: Add Implementation Notes covering:
- Why this approach was chosen over alternatives
- Performance characteristics (time/space complexity)
- Known limitations and workarounds
- Platform compatibility notes
### Stage 3: Reader Testing
Validate the documentation by asking:
1. **Can a new developer use this module after reading only the Basic Example?**
2. **Can a senior developer configure advanced use cases from the Advanced Example?**
3. **Are all public APIs documented so a developer never needs to read source code?**
4. **Do the code examples actually compile with the correct imports?**
5. **Is the word count at least 200 words (excluding code blocks)?**
## Example: Complete Module Documentation
```markdown
# Encryption
## Purpose
Provides symmetric encryption and decryption using AES-256-GCM with authenticated
encryption. Wraps the Web Crypto API to provide a consistent interface across Node.js
and browser environments with automatic IV generation and authentication tag handling.
## Features
- AES-256-GCM authenticated encryption
- Automatic random IV generation per encryption call
- AEAD (Authenticated Encryption with Associated Data) support
- Streaming encryption for large payloads
- Cross-platform (Node.js, browsers, edge runtimes)
## Usage
### Basic Example
\`\`\`typescript
import { encrypt, decrypt, generateKey } from '@suaveplan/crypto/symmetric/encryption/encryption';
const key = await generateKey();
const ciphertext = await encrypt(key, 'Hello, world!');
const plaintext = await decrypt(key, ciphertext);
// plaintext === 'Hello, world!'
\`\`\`
### Advanced Example
\`\`\`typescript
import {
encrypt,
decrypt,
generateKey,
type EncryptionOptions,
} from '@suaveplan/crypto/symmetric/encryption/encryption';
const key = await generateKey();
// Encrypt with associated data for AEAD
const options: EncryptionOptions = {
associatedData: new TextEncoder().encode('metadata-v1'),
outputFormat: 'base64',
};
const ciphertext = await encrypt(key, largePayload, options);
// Decryption must provide the same associated data
const plaintext = await decrypt(key, ciphertext, {
associatedData: options.associatedData,
inputFormat: 'base64',
});
\`\`\`
## API Reference
### generateKey(): Promise<CryptoKey>
Generates a new AES-256-GCM key using the Web Crypto API.
**Returns:** Promise resolving to a CryptoKey suitable for encrypt/decrypt.
### encrypt(key: CryptoKey, data: string | Uint8Array, options?: EncryptionOptions): Promise<Uint8Array>
Encrypts data using AES-256-GCM.
**Parameters:**
- `key` -- AES-256-GCM CryptoKey from generateKey()
- `data` -- Plaintext string or binary data
- `options` -- Optional EncryptionOptions
**Returns:** Ciphertext with prepended IV (12 bytes) and appended auth tag (16 bytes).
**Throws:** `CryptoError` if key is invalid or encryption fails.
### decrypt(key: CryptoKey, ciphertext: Uint8Array, options?: DecryptionOptions): Promise<string | Uint8Array>
Decrypts AES-256-GCM ciphertext.
**Parameters:**
- `key` -- Same CryptoKey used for encryption
- `ciphertext` -- Output from encrypt()
- `options` -- Optional DecryptionOptions
**Returns:** Original plaintext.
**Throws:** `CryptoError` if authentication fails (tampered data) or key mismatch.
## Implementation Notes
### Design Decisions
- **AES-256-GCM chosen over AES-CBC** because GCM provides authentication
(integrity + confidentiality) in a single operation, preventing padding oracle attacks.
- **IV prepended to ciphertext** so callers do not need to manage IV storage separately.
The 12-byte IV is randomly generated per call, making nonce reuse statistically impossible.
### Performance Considerations
- **Time Complexity**: O(n) where n is plaintext length
- **Memory**: Entire plaintext must fit in memory (use streaming API for large files)
- **Web Crypto API**: Hardware-accelerated on platforms with AES-NI support
### Known Limitations
- Maximum plaintext size of 64 GiB per encryption call (GCM specification limit)
- Streaming API requires ReadableStream support (not available in all edge runtimes)
```
## Verification
Check documentation coverage before marking work complete:
```bash
# Check documentation coverage for a specific package
bun run docs:check --filter=@suaveplan/<package-name>
# Target: 90%+ documentation coverage
```
## Updating Existing Documentation
When implementation code changes:
1. Read the updated implementation file
2. Identify what changed (new exports, changed signatures, removed APIs)
3. Update the corresponding `.md` file to reflect changes
4. Verify examples still compile with the new API
5. Add a Changelog entry if the change is significant
## Constraints
- **200-word minimum** per documentation file (excluding code blocks)
- **Simple + Advanced examples** mandatory in every doc
- **90%+ documentation coverage** per package
- **Co-located files** -- `MyClass.ts` and `MyClass.md` must be in the same directory
- **No placeholder text** -- every section must have real content
- **Code examples must use real imports** -- `@suaveplan/<package>` paths
- **Follow `.claude/templates/module-docs.md`** template structure
- **Update docs when code changes** -- stale documentation is a defect
- **Link between levels** -- README -> Category README -> Module docs
- Template exclusions: `.test.ts`, `.spec.ts`, `.types.ts`, `.stories.tsx`

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-frontend-design",
"version": "1.0.0",
"description": "Suaveplan-specific frontend design skill for React + Tailwind + shadcn/ui"
}

View File

@@ -0,0 +1,175 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
---
This skill guides creation of distinctive, production-grade frontend interfaces for the Suaveplan monorepo. All output MUST use **React 19 + TypeScript + Tailwind CSS + shadcn/ui** and follow Suaveplan CLAUDE.md conventions.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Required Stack
All frontend code MUST use:
- **React 19** with TypeScript generics and strict types
- **Tailwind CSS** for styling (via utility classes, no inline styles)
- **shadcn/ui** components from `@suaveplan/react-ui` as the base component library
- **`cn()` utility** from `@suaveplan/react-ui` for conditional class merging (clsx + tailwind-merge)
- **CSS variables** for theming (defined in Tailwind config or `:root`)
- **Motion** (framer-motion) for React animations when available; CSS transitions/animations for simpler effects
### Available Suaveplan Libraries
Use these internal packages instead of external alternatives:
| Need | Use | Import |
|------|-----|--------|
| UI primitives (Button, Dialog, Card, etc.) | `@suaveplan/react-ui` | `import { Button } from '@suaveplan/react-ui'` |
| Class merging | `cn()` from `@suaveplan/react-ui` | `import { cn } from '@suaveplan/react-ui'` |
| Forms + validation | `@suaveplan/react-forms` | `import { Form } from '@suaveplan/react-forms'` |
| Data tables | `@suaveplan/react-data-table` | `import { DataTable } from '@suaveplan/react-data-table'` |
| Toast / notifications | `@suaveplan/react-notifications` | `import { toast } from '@suaveplan/react-notifications'` |
| Auth components | `@suaveplan/react-auth` | `import { LoginForm } from '@suaveplan/react-auth'` |
| Timeline / Gantt / Calendar | `@suaveplan/react-timeline` | `import { Timeline, Gantt, Calendar } from '@suaveplan/react-timeline'` |
| Media players | `@suaveplan/react-media` | `import { MediaPlayer } from '@suaveplan/react-media'` |
| i18n / translation | `@suaveplan/react-translate` | `import { useTranslation } from '@suaveplan/react-translate'` |
| Consent overlays | `@suaveplan/react-consent-overlay` | `import { ConsentOverlay } from '@suaveplan/react-consent-overlay'` |
| Drive sharing UI | `@suaveplan/drive-react-sharing` | `import { ShareDialog } from '@suaveplan/drive-react-sharing'` |
| Email templates | `@suaveplan/email-templates` | `import { WelcomeEmail } from '@suaveplan/email-templates'` |
**NEVER** install or import external UI libraries (Material UI, Chakra, Ant Design, Mantine, Radix directly, etc.) when a Suaveplan equivalent exists. Import shadcn/ui primitives from `@suaveplan/react-ui`.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Must use React + Tailwind + shadcn/ui. Must be tree-shakeable. Must follow Suaveplan file structure conventions.
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code that is:
- Production-grade, functional, and type-safe
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
- Tree-shakeable with proper exports
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Use Tailwind's font system with custom font families defined in `tailwind.config`. Avoid generic system fonts. Pair a distinctive display font with a refined body font using `font-display` and `font-body` custom utilities. Load via `@fontsource` packages or Google Fonts with `next/font`.
- **Color & Theme**: Use Tailwind CSS variables and shadcn/ui theming. Define colors in HSL format for shadcn compatibility. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Use `dark:` variants for dark mode support.
- **Motion**: Use Motion (framer-motion) for component animations — `AnimatePresence`, `motion.div`, staggered children via `variants`. Use Tailwind's `transition-*`, `animate-*` utilities for simpler effects. Focus on high-impact moments: one well-orchestrated page load with staggered reveals creates more delight than scattered micro-interactions.
- **Spatial Composition**: Use Tailwind's grid/flex utilities creatively. Unexpected layouts with `grid-cols-[custom]`, asymmetric `gap-*`, overlapping with negative margins or `absolute` positioning. Generous `space-*` or controlled density.
- **Backgrounds & Visual Details**: Create atmosphere with Tailwind gradients (`bg-gradient-to-*`), backdrop blur (`backdrop-blur-*`), shadows (`shadow-*`), and custom CSS for noise textures, geometric patterns, or grain overlays. Use `before:` and `after:` pseudo-elements via Tailwind for decorative effects.
### shadcn/ui Integration
- Build on top of shadcn/ui primitives — extend them, don't replace them
- Use `cn()` from `@suaveplan/react-ui` for all conditional class composition
- Override shadcn/ui defaults with Tailwind classes, not inline styles
- Respect the `data-*` attributes shadcn uses for state styling
- Example pattern:
```tsx
import { Button } from '@suaveplan/react-ui';
import { cn } from '@suaveplan/react-ui';
interface ActionButtonProps {
variant?: 'primary' | 'danger';
className?: string;
children: React.ReactNode;
}
export function ActionButton({ variant = 'primary', className, children }: ActionButtonProps) {
return (
<Button
className={cn(
'relative overflow-hidden transition-all duration-300',
variant === 'primary' && 'bg-emerald-600 hover:bg-emerald-700 hover:shadow-emerald-500/25 hover:shadow-lg',
variant === 'danger' && 'bg-rose-600 hover:bg-rose-700 hover:shadow-rose-500/25 hover:shadow-lg',
className
)}
>
{children}
</Button>
);
}
```
## Anti-Patterns
NEVER use:
- Generic AI-generated aesthetics (overused font families like Inter/Roboto/Arial, cliched purple gradients on white, predictable layouts)
- Inline styles — use Tailwind classes exclusively
- External UI libraries when a `@suaveplan/*` equivalent exists
- `vi.mock()` or `jest.mock()` in tests — use real implementations
- Files over 250 lines (code) or 500 lines (tests)
- `tsc` directly — use `bun run build` (Vite/tsup)
- TODO comments or placeholder implementations
NEVER converge on common choices (Space Grotesk, etc.) across generations. Vary between light and dark themes, different fonts, different aesthetics.
## Suaveplan Code Standards
All generated frontend code MUST follow:
1. **Max 250 lines per file** — split components into smaller focused modules
2. **TypeScript generics** — components should accept generic type parameters where applicable
3. **Each component gets its own folder** with: `.tsx`, `.test.tsx`, `.stories.tsx`, `.types.ts` (if needed), `.md`
4. **Storybook stories required** — default story, playground with controls, key variant stories
5. **Playwright `.spec.ts`** for E2E testing of interactive components
6. **Tree-shakeable** — use named exports, no side effects, declare `sideEffects: false`
7. **No re-exports** — import directly from source packages
8. **`workspace:*`** for internal dependencies
9. **CSS classes use `sp-` prefix** for custom component-specific styles (matches existing convention)
10. **Accessibility** — ARIA roles, keyboard navigation, focus management, screen reader support
### File Structure Pattern
```text
components/
my-component/
my-component.tsx # Implementation (max 250 lines)
my-component.test.tsx # Vitest unit tests (max 500 lines)
my-component.stories.tsx # Storybook stories
my-component.types.ts # Type definitions
my-component.md # Documentation with simple + advanced examples
```
### Documentation (.md) Requirements
Every `.md` file must include:
- Purpose description (200+ words)
- **Simple Example**: basic usage, getting started
- **Advanced Example**: generics, composition, edge cases
- API reference for all public exports
- Design decisions and implementation notes
## Implementation Checklist
When building a frontend component/page:
1. Design direction chosen and documented
2. Component file structure created (`.tsx`, `.test.tsx`, `.stories.tsx`, `.types.ts`, `.md`)
3. TypeScript types/interfaces defined with generics where applicable
4. Component implemented using React + Tailwind + shadcn/ui
5. `cn()` used for all conditional class composition
6. Accessibility attributes applied (ARIA, keyboard, focus)
7. Storybook stories written (default, playground, variants)
8. Unit tests written with Vitest (95%+ coverage target)
9. Playwright E2E spec written for interactive behaviors
10. Documentation written with simple + advanced examples
11. File sizes verified (code <= 250 lines, tests <= 500 lines)
12. Lint clean: `bun run lint --filter=@suaveplan/<package>`
13. Types clean: `bun run typecheck --filter=@suaveplan/<package>`
14. Tests passing: `bun run test --filter=@suaveplan/<package>`
Remember: Claude is capable of extraordinary creative work. Commit fully to a distinctive vision while respecting the Suaveplan stack and conventions.

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-mcp-builder",
"version": "1.0.0",
"description": "Create MCP servers exposing Suaveplan plugin functionality"
}

View File

@@ -0,0 +1,422 @@
---
name: mcp-builder
description: Build MCP (Model Context Protocol) servers that expose Suaveplan plugin functionality using TypeScript, Hono, SurrealDB, and the definePlugin() architecture
---
# MCP Builder for Suaveplan
Build MCP servers that expose Suaveplan's plugin architecture as tool endpoints for AI agents. This skill bridges the Suaveplan API plugin system with the Model Context Protocol, allowing plugins to serve both HTTP routes and MCP tool interfaces from the same codebase.
## Architecture Overview
Suaveplan's server-side plugin system (`@suaveplan/api`) provides the foundation. An MCP server wraps one or more plugins, exposing their operations as MCP tools, resources, and prompts.
```text
Suaveplan Plugin (definePlugin)
|
+-- Hono routes (HTTP API)
|
+-- MCP Server adapter
|
+-- Tools (operations)
+-- Resources (data endpoints)
+-- Prompts (templated interactions)
```
### Core Technologies
| Layer | Technology |
|-------|-----------|
| Plugin Framework | `definePlugin()` from `@suaveplan/api` |
| HTTP Router | Hono |
| Database | SurrealDB with `.surql` migration files |
| Schema Validation | Zod |
| Runtime | Bun |
| MCP Protocol | `@modelcontextprotocol/sdk` |
## Plugin Lifecycle
Every Suaveplan plugin follows this lifecycle:
```text
onRegister -> onInit -> onReady -> (running) -> onClose
```
- **onRegister**: Plugin is registered in the plugin registry. Validate configuration.
- **onInit**: Initialize resources, connect to databases, run migrations.
- **onReady**: Mount Hono routes, start serving. The MCP adapter binds here.
- **onClose**: Cleanup resources, close connections.
## File Structure
An MCP-enabled Suaveplan plugin follows this layout:
```text
packages/server/api-plugin-<name>/
src/
plugin.ts # Plugin definition with definePlugin()
plugin.test.ts # Plugin lifecycle tests
plugin.types.ts # Configuration and type definitions
plugin.md # Plugin documentation
mcp/
mcp-adapter.ts # MCP server adapter
mcp-adapter.test.ts # MCP adapter tests
mcp-adapter.types.ts # MCP-specific types
mcp-adapter.md # MCP adapter documentation
tools/
<tool-name>.ts # Individual tool implementations
<tool-name>.test.ts # Tool tests
<tool-name>.md # Tool documentation
resources/
<resource-name>.ts # Resource implementations
prompts/
<prompt-name>.ts # Prompt templates
routes/
<route>.ts # Hono route handlers
<route>.test.ts # Route tests
<route>.md # Route documentation
surql/
001-initial-schema.surql # SurrealDB migration files
002-add-indexes.surql # Sequential migrations
package.json
tsconfig.json
vitest.config.ts
```
## Creating a Plugin with definePlugin()
### Basic Plugin Definition
```typescript
// src/plugin.ts
import { definePlugin } from '@suaveplan/api';
import z from 'zod';
import type { MyPluginConfig } from './plugin.types';
const ConfigSchema = z.object({
apiKey: z.string().min(1),
maxRetries: z.number().int().positive().default(3),
enableMcp: z.boolean().default(true),
});
export const myPlugin = definePlugin<MyPluginConfig>({
name: 'my-plugin',
version: '1.0.0',
description: 'Description of what this plugin does',
dependencies: ['database'], // Required plugins
optionalDependencies: ['auth'], // Optional plugins
migrationsPath: './surql', // SurrealDB migration directory
tags: ['mcp', 'custom'],
configSchema: {
schema: ConfigSchema,
defaults: {
apiKey: '',
maxRetries: 3,
enableMcp: true,
},
},
async onInit(ctx) {
// Run after dependencies are initialized
// Access config: ctx.config
// Access database: ctx.db
// Run migrations automatically from migrationsPath
},
async onReady(ctx) {
// Mount Hono routes
ctx.app.route('/my-plugin', createRoutes(ctx));
// Initialize MCP adapter if enabled
if (ctx.config.enableMcp) {
const mcpServer = createMcpAdapter(ctx);
ctx.app.route('/mcp/my-plugin', mcpServer);
}
},
async onClose(ctx) {
// Cleanup resources
},
});
```
### Types Definition
```typescript
// src/plugin.types.ts
import type { z } from 'zod';
export interface MyPluginConfig {
apiKey: string;
maxRetries: number;
enableMcp: boolean;
}
export interface MyPluginContext {
config: MyPluginConfig;
db: SurrealDB;
app: Hono;
}
```
## MCP Adapter Pattern
### Wrapping a Plugin as an MCP Server
```typescript
// src/mcp/mcp-adapter.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import z from 'zod';
import type { MyPluginContext } from '../plugin.types';
export function createMcpAdapter(ctx: MyPluginContext): McpServer {
const server = new McpServer({
name: 'suaveplan-my-plugin',
version: '1.0.0',
});
// Register tools
server.tool(
'create_item',
'Create a new item in the system',
{
name: z.string().describe('Item name'),
category: z.enum(['a', 'b', 'c']).describe('Item category'),
metadata: z.record(z.string()).optional().describe('Optional metadata'),
},
async ({ name, category, metadata }) => {
const result = await ctx.db.create('item', { name, category, metadata });
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
);
server.tool(
'query_items',
'Search items with filters',
{
category: z.enum(['a', 'b', 'c']).optional(),
limit: z.number().int().positive().default(10),
},
async ({ category, limit }) => {
const query = category
? `SELECT * FROM item WHERE category = $category LIMIT $limit`
: `SELECT * FROM item LIMIT $limit`;
const results = await ctx.db.query(query, { category, limit });
return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
};
}
);
// Register resources
server.resource(
'item_schema',
'item://schema',
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify({
type: 'object',
properties: {
name: { type: 'string' },
category: { type: 'string', enum: ['a', 'b', 'c'] },
},
}),
}],
})
);
// Register prompts
server.prompt(
'summarize_items',
'Generate a summary of items by category',
{ category: z.string().optional() },
({ category }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: category
? `Summarize all items in category "${category}"`
: 'Summarize all items across all categories',
},
}],
})
);
return server;
}
```
## SurrealDB Migrations
Migration files live in `surql/` and are executed in alphabetical order during `onInit`:
```sql
-- surql/001-initial-schema.surql
DEFINE TABLE item SCHEMAFULL;
DEFINE FIELD name ON item TYPE string ASSERT $value != NONE;
DEFINE FIELD category ON item TYPE string ASSERT $value INSIDE ['a', 'b', 'c'];
DEFINE FIELD metadata ON item FLEXIBLE TYPE option<object>;
DEFINE FIELD created_at ON item TYPE datetime DEFAULT time::now();
DEFINE FIELD updated_at ON item TYPE datetime DEFAULT time::now();
DEFINE INDEX idx_item_category ON item FIELDS category;
```
```sql
-- surql/002-add-full-text.surql
DEFINE ANALYZER item_analyzer TOKENIZERS blank, class FILTERS lowercase, ascii;
DEFINE INDEX idx_item_name_search ON item FIELDS name SEARCH ANALYZER item_analyzer BM25;
```
**Critical**: The `package.json` `files` array MUST include `"surql"` for migrations to work when the plugin is published:
```json
{
"files": ["dist", "surql"]
}
```
## Hono Route Handlers
```typescript
// src/routes/items.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import z from 'zod';
import type { MyPluginContext } from '../plugin.types';
const CreateItemSchema = z.object({
name: z.string().min(1).max(200),
category: z.enum(['a', 'b', 'c']),
metadata: z.record(z.string()).optional(),
});
export function createItemRoutes(ctx: MyPluginContext) {
const app = new Hono();
app.post('/', zValidator('json', CreateItemSchema), async (c) => {
const body = c.req.valid('json');
const result = await ctx.db.create('item', body);
return c.json(result, 201);
});
app.get('/', async (c) => {
const items = await ctx.db.select('item');
return c.json(items);
});
app.get('/:id', async (c) => {
const id = c.req.param('id');
const item = await ctx.db.select(`item:${id}`);
return c.json(item);
});
return app;
}
```
## Plugin Registration
Register the plugin in the plugin registry:
```typescript
// packages/server/api/src/plugin/plugin-registry.ts
import { myPlugin } from '@suaveplan/api-plugin-my-name';
registry.register(myPlugin);
```
## Testing Patterns
### Plugin Lifecycle Tests
```typescript
// src/plugin.test.ts
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { myPlugin } from './plugin';
describe('MyPlugin', () => {
it('has correct metadata', () => {
expect(myPlugin.name).toBe('my-plugin');
expect(myPlugin.version).toBe('1.0.0');
expect(myPlugin.dependencies).toContain('database');
});
it('validates config schema', () => {
const result = myPlugin.configSchema!.schema.safeParse({
apiKey: 'test-key',
});
expect(result.success).toBe(true);
});
it('rejects invalid config', () => {
const result = myPlugin.configSchema!.schema.safeParse({
apiKey: '',
});
expect(result.success).toBe(false);
});
});
```
### MCP Adapter Tests
```typescript
// src/mcp/mcp-adapter.test.ts
import { describe, expect, it } from 'vitest';
import { createMcpAdapter } from './mcp-adapter';
describe('MCP Adapter', () => {
it('registers expected tools', () => {
const adapter = createMcpAdapter(mockContext);
const tools = adapter.listTools();
expect(tools).toContainEqual(
expect.objectContaining({ name: 'create_item' })
);
expect(tools).toContainEqual(
expect.objectContaining({ name: 'query_items' })
);
});
it('create_item tool validates input', async () => {
const adapter = createMcpAdapter(mockContext);
const result = await adapter.callTool('create_item', {
name: 'Test Item',
category: 'a',
});
expect(result.content[0].text).toContain('Test Item');
});
});
```
## Constraints
- **Max 250 lines** per implementation file
- **Max 500 lines** per test file
- **95% minimum test coverage** via `bun run test:coverage`
- **No mocks** -- use real SurrealDB instances for integration tests
- **Zod schemas** for all validation (already in monorepo, do not add Joi or Yup)
- **TypeScript only** -- no JavaScript files
- **Biome linting** -- not ESLint
- **Documentation** -- every `.ts` implementation file needs a matching `.md` file with 200+ words, simple + advanced examples
- **`files` in package.json** must include `"surql"` when migrations exist
- **Plugin naming**: `@suaveplan/api-plugin-<name>` for packages, `<name>` for the plugin `name` field
- Migration files must be numbered sequentially: `001-`, `002-`, etc.
## Checklist
1. Plugin defined with `definePlugin()` and proper lifecycle hooks
2. Zod config schema with sensible defaults
3. SurrealDB migrations in `surql/` directory
4. Hono routes for HTTP API
5. MCP adapter wrapping plugin operations as tools
6. Tests for plugin lifecycle, routes, and MCP tools
7. Documentation for every implementation file
8. Registered in plugin registry
9. `package.json` includes `"surql"` in files array
10. Lint clean: `bun run lint --filter=@suaveplan/api-plugin-<name>`

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-package-structure",
"version": "1.0.0",
"description": "Library package structure patterns and code organization rules for Suaveplan monorepo"
}

View File

@@ -0,0 +1,224 @@
---
name: package-structure
description: Library package structure patterns, code organization rules, documentation requirements, and Storybook conventions for the Suaveplan monorepo
---
# Package Structure & Code Organization
Use this skill when creating new packages, adding modules to existing packages, or organizing code within the Suaveplan monorepo. Covers architecture principles, file placement, documentation requirements, and component conventions.
## Package Architecture Principles
- **Universal packages** (`packages/universal/*`) must have ZERO browser/React dependencies
- **Browser packages** (`packages/browser/*`) can depend on React, DOM APIs, and browser-specific libraries
- **Server packages** (`packages/server/*`) can depend on Node.js APIs and server-specific libraries
Place utilities in the appropriate category:
- CSS/Tailwind utilities (cn, clsx, tailwind-merge) belong in `@suaveplan/react-ui`
- React hooks and components belong in browser or react packages
- Platform-agnostic utilities belong in universal packages
## Think Before Write
Before creating any file:
1. Consider WHERE to place it (correct package category)
2. Check existing patterns and structure in the target package
3. Use `tests/` for test files
4. Use `scripts/` for utility scripts
5. Use `claudedocs/` for Claude-generated documentation
## Documentation Requirement
**Every implementation file MUST have a corresponding .md file** co-located in the same directory with the same name (`MyClass.ts` -> `MyClass.md`).
### Exclusions (no .md required)
- `.test.ts` / `.test.tsx` -- test files
- `.spec.ts` -- Playwright E2E files
- `.types.ts` -- types files within a component directory (shared type files in `types/` DO need docs)
- `.stories.tsx` -- Storybook stories
- `constants/index.ts` -- simple constant exports
### Content Requirements
- **Minimum 200 words** of content (excluding code blocks)
- **Both simple AND advanced examples** are mandatory in every doc
- **Code examples must compile** -- use real types and imports from the package
- **No marketing language** -- state capabilities honestly
- **No placeholder text** -- every section must contain real content
- **Update when code changes** -- documentation must stay in sync
### Template
Use standard template from `.claude/templates/module-docs.md`. Required sections:
1. **Purpose** -- What the module does and why it exists (minimum 2 sentences)
2. **Features** -- Bullet list of capabilities
3. **Usage: Basic Example** -- Simple import and usage (the "happy path")
4. **Usage: Advanced Example** -- Composition, configuration, edge cases
5. **API Reference** -- Every public export documented with parameters and return types
6. **Implementation Notes** -- Design decisions, performance considerations, known limitations
### Example Structure
````markdown
# Module Name
## Purpose
Brief description of what this module does and why it exists.
## Features
- Feature 1
- Feature 2
## Usage
### Simple Example
```typescript
import { MyClass } from '@suaveplan/package';
const instance = new MyClass();
```
### Advanced Example
```typescript
import { MyClass } from '@suaveplan/package';
const instance = new MyClass<CustomType>({
onEvent: (data) => transform(data),
strategy: 'optimized',
});
```
## API Reference
### MyClass
#### constructor(options?: Options)
...
## Implementation Notes
- Design decisions
- Performance considerations
````
### Documentation Hierarchy
Three-level linking structure:
```text
packages/<category>/<package>/
README.md # Level 1: Package overview
src/
<category>/
README.md # Level 2: Category index
<feature>/
<feature>.md # Level 3: Module documentation
```
- Root `README.md` -> Category `README.md` -> Feature `.md`
- Each level links to the next, creating discoverable documentation
## Library Package Structure Pattern
For library packages (especially in `packages/universal/*`):
### Category Organization
- Group related features into category directories (e.g., `asymmetric/`, `symmetric/`, `core/`)
- Each category directory MUST have a `README.md` index linking to all feature modules
- Feature modules go in subdirectories within categories
### Feature Module Pattern (four files per feature)
```text
category/
README.md # Category index
feature-name/
feature-name.ts # Implementation
feature-name.test.ts # Tests
feature-name.types.ts # Type definitions
feature-name.md # Documentation
```
### Special Directories
- `types/`: Shared types, flat structure (`.ts` + `.md` only, no tests)
- `utils/`: Utilities, flat structure (`.ts` + `.test.ts` + `.md`)
- `constants/`: Just `index.ts` file (no tests or docs required)
### Subpath Exports (for tree-shaking)
Add granular exports in `package.json` for each feature module:
```json
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./category/feature/feature": {
"types": "./dist/category/feature/feature.d.ts",
"import": "./dist/category/feature/feature.js"
}
}
}
```
### Full Example (from @suaveplan/crypto)
```text
packages/universal/crypto/
README.md # Package overview
src/
asymmetric/
README.md # Category index
key-exchange/
key-exchange.ts
key-exchange.test.ts
key-exchange.types.ts
key-exchange.md
public-key-crypto/
signing/
symmetric/
README.md
encryption/
password/
streaming/
types/ # Shared types (flat)
common.ts
common.md
keys.ts
keys.md
utils/ # Utilities (flat)
utils.ts
utils.test.ts
utils.md
constants/ # Constants (no tests/docs)
index.ts
```
## Storybook Stories Required for Components
Every React component MUST have a corresponding `.stories.tsx` file with:
- A default story
- A playground story with controls
- Key variant stories
Storybook is the primary visual documentation for UI components. Components without stories are considered incomplete.
## Naming Conventions
- Follow language/framework standards (camelCase for JS/TS)
- Files, functions, variables must clearly describe purpose
- No mixed naming conventions within same project
## Clean Workspace
- Remove temporary files when done
- Delete build artifacts and logs
- No artifact pollution (debug.sh, test.log, temp/)

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-skill-creator",
"version": "1.0.0",
"description": "Guide for creating project-local skills in the suaveplan-plugins marketplace"
}

View File

@@ -0,0 +1,269 @@
---
name: skill-creator
description: Create new Claude Code skills for the Suaveplan monorepo plugin marketplace following project conventions, progressive disclosure, and degrees-of-freedom principles
---
# Skill Creator for Suaveplan
Create new Claude Code skills (SKILL.md files) within the Suaveplan plugin marketplace at `.claude/plugins/suaveplan-plugins/`. Each skill is a focused instruction set that teaches Claude how to perform a specific task within the Suaveplan monorepo, following established patterns for React 19, TypeScript, Tailwind CSS, Biome, Vitest, and the monorepo architecture.
## Marketplace Structure
The Suaveplan skill marketplace lives at:
```text
.claude/plugins/suaveplan-plugins/
.claude-plugin/ # Marketplace metadata directory
plugins/
suaveplan-<skill-group>/ # Plugin container
.claude-plugin/ # Plugin metadata directory
skills/
<skill-name>/
SKILL.md # The skill definition file
```
### Existing Plugins
| Plugin Directory | Skill(s) | Purpose |
|-----------------|----------|---------|
| `suaveplan-webapp-testing` | `webapp-testing` | Playwright + Storybook E2E testing |
| `suaveplan-web-artifacts` | `web-artifacts-builder` | React component and page building |
| `suaveplan-mcp-builder` | `mcp-builder` | MCP server plugins |
| `suaveplan-doc-coauthoring` | `doc-coauthoring` | Module documentation |
| `suaveplan-skill-creator` | `skill-creator` | Creating new skills (this skill) |
| `suaveplan-theme-factory` | `theme-factory` | Tailwind/shadcn theme generation |
| `suaveplan-frontend-design` | `frontend-design` | UI/UX design patterns |
| `suaveplan-threejs` | Multiple | Three.js/R3F sub-skills |
| `suaveplan-webgpu` | `webgpu-threejs-tsl` | WebGPU shader language |
## Creating a New Skill
### Step 1: Plan the Skill
Before writing, answer these questions:
1. **What task does this skill accomplish?** (one sentence)
2. **Which Suaveplan packages does it touch?** (list them)
3. **What existing patterns should it reference?** (file structure, testing, linting)
4. **What are the degrees of freedom?** (what can the user customize vs. what is fixed)
### Step 2: Create the Directory Structure
```bash
# Create the plugin container and skill directory
mkdir -p .claude/plugins/suaveplan-plugins/plugins/suaveplan-<group>/skills/<skill-name>
mkdir -p .claude/plugins/suaveplan-plugins/plugins/suaveplan-<group>/.claude-plugin
```
### Step 3: Write the SKILL.md
Every SKILL.md follows this structure:
```markdown
---
name: <skill-name>
description: <One line describing the skill in context of Suaveplan, React 19, TypeScript, Tailwind>
---
# <Skill Title> for Suaveplan
<Opening paragraph: what this skill does, when to use it, 2-3 sentences max>
## Architecture
<Technology table or diagram showing how this fits into Suaveplan>
## File Structure
<Show the directory layout this skill produces or works with>
## Patterns
<The core patterns and code templates, with TypeScript examples>
## Constraints
<Hard rules: line limits, coverage requirements, tooling choices>
## Checklist
<Numbered list of verification steps before marking work complete>
```
### Step 4: Enable the Plugin
Add the plugin to `.claude/settings.local.json`:
```json
{
"enabledPlugins": {
"suaveplan-<group>@suaveplan-plugins": true
}
}
```
## Core Principles for Writing Skills
### 1. Conciseness
Every sentence must teach something. Remove filler words, motivational language, and redundant explanations.
**Wrong:**
> This powerful and flexible skill enables you to build amazing components that leverage the full power of React 19's cutting-edge features.
**Right:**
> Build React 19 components within the Suaveplan monorepo using TypeScript, Tailwind CSS, and shadcn/ui from `@suaveplan/react-ui`.
### 2. Progressive Disclosure
Start with the simplest correct usage, then layer complexity. Structure information so a reader can stop at any point and have enough to begin work.
```text
1. One-sentence purpose
2. Technology table (what tools are used)
3. File structure (where things go)
4. Basic pattern (simplest correct example)
5. Advanced pattern (composition, configuration, edge cases)
6. Constraints (hard rules)
7. Checklist (verification steps)
```
### 3. Degrees of Freedom
Clearly distinguish between:
- **Fixed** -- things the developer cannot change (linter, test framework, file naming)
- **Configurable** -- things with sensible defaults that can be overridden
- **Open** -- things the developer decides entirely
Example:
```text
Fixed: Biome for linting (not ESLint), Vitest for tests, .spec.ts for E2E
Configurable: Component variant styles (via cva), Storybook port numbers
Open: Component API design, business logic, state management approach
```
### 4. Suaveplan Context
Every skill must reference the monorepo conventions:
- **React 19** with TypeScript strict mode
- **Tailwind CSS** with `cn()` from `@suaveplan/react-ui`
- **Biome** for linting (never ESLint)
- **Vitest** for unit tests, **Playwright** for E2E
- **Bun** as package manager, **Turborepo** for task orchestration
- **No mocks** -- real implementations in tests
- **95% test coverage** minimum
- **250-line implementation file limit**, **500-line test file limit**
- **Documentation** -- `.md` file co-located with every implementation file, 200+ words, simple + advanced examples
- **`data-testid`** with `sp-` prefix for all Suaveplan components
- **`workspace:*`** for internal dependencies
- **No tsc** -- Vite/tsup handle compilation, TypeScript for type-checking only
### 5. Actionable Code Examples
Every pattern in a SKILL.md must include a TypeScript code example that:
- Uses real import paths (`@suaveplan/react-ui`, not `./utils`)
- Compiles with the project's TypeScript configuration
- Follows Biome formatting rules
- Includes type annotations
## SKILL.md Frontmatter
Every SKILL.md must start with YAML frontmatter:
```yaml
---
name: <skill-name> # Lowercase, hyphenated, matches directory name
description: <description> # Must mention Suaveplan + relevant technologies
---
```
The `name` field must match the skill directory name exactly. The `description` should be under 150 characters and mention the primary technologies.
## Validation Checklist for New Skills
Before considering a skill complete:
1. Frontmatter has `name` and `description` fields
2. `name` matches the directory name
3. `description` mentions Suaveplan and primary technologies
4. Opening paragraph explains what and when (not why or motivation)
5. Technology table lists all tools and frameworks
6. File structure shows actual monorepo paths
7. Code examples use real `@suaveplan/*` imports
8. Constraints section lists all hard rules (line limits, coverage, linting)
9. Checklist section provides verification steps
10. No marketing language, no emojis, no filler
11. All fixed conventions are stated (Biome, Vitest, Playwright, Bun)
12. Degrees of freedom are clear (what is fixed vs. configurable vs. open)
## Example: Minimal Skill
```markdown
---
name: csv-export
description: Export Suaveplan data tables to CSV using TypeScript streaming and React 19 download patterns
---
# CSV Export for Suaveplan
Generate CSV files from `@suaveplan/react-data-table` data using TypeScript streaming. Works client-side with Blob URLs or server-side with Node.js streams.
## Architecture
| Layer | Technology |
|-------|-----------|
| Data Source | `@suaveplan/react-data-table` column definitions |
| Encoding | TextEncoder streaming API |
| Download | Blob URL + anchor click (browser) |
| Testing | Vitest |
## File Structure
\`\`\`
src/utils/csv-export/
csv-export.ts # Implementation
csv-export.test.ts # Tests
csv-export.types.ts # Types
csv-export.md # Documentation
\`\`\`
## Basic Pattern
\`\`\`typescript
import { exportToCsv } from './csv-export';
import type { ColumnDef } from '@suaveplan/react-data-table';
const columns: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
];
const data = [{ name: 'Alice', email: 'alice@example.com' }];
const csv = exportToCsv(columns, data);
// "Name,Email\nAlice,alice@example.com"
\`\`\`
## Constraints
- Max 250 lines per file
- 95% test coverage
- No external CSV libraries -- use TextEncoder
- Handle commas, quotes, and newlines in cell values per RFC 4180
## Checklist
1. Implementation handles all RFC 4180 edge cases
2. Tests cover: empty data, special characters, large datasets, Unicode
3. Documentation has simple + advanced examples
4. Biome lint clean
\`\`\`
```
## Constraints
- Skills must be concise -- remove every unnecessary word
- No Python scripts or external tooling -- manual directory creation only
- Every skill must reference Suaveplan monorepo conventions
- Code examples must compile with project TypeScript config
- No emojis in SKILL.md files
- Progressive disclosure: simplest information first, complexity layers later
- One skill per directory -- no multi-skill files

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-testing-workflow",
"version": "1.0.0",
"description": "Testing commands, CI/CD integration, turborepo filtering, and troubleshooting for Suaveplan"
}

View File

@@ -0,0 +1,243 @@
---
name: testing-workflow
description: Testing commands, CI/CD integration, advanced turborepo filtering, E2E/Storybook testing, development workflow, and troubleshooting for the Suaveplan monorepo
---
# Testing Workflow & CI/CD
Use this skill when running tests, setting up CI/CD, troubleshooting build issues, or following the development workflow in the Suaveplan monorepo.
## Package Testing
```bash
# Lint a package
bun run lint --filter=@suaveplan/<package-name>
# Type check a package
bun run typecheck --filter=@suaveplan/<package-name>
# Run unit/integration tests
bun run test --filter=@suaveplan/<package-name>
# Run tests with coverage
bun run test:coverage --filter=@suaveplan/<package-name>
# Run E2E tests (Playwright)
bun run e2e --filter=@suaveplan/<package-name>
```
## Workspace-Wide Testing
```bash
bunx turbo lint # Lint all packages
bunx turbo typecheck # Type check all packages
bunx turbo test # Run all tests
bunx turbo test:coverage # All tests with coverage
bunx turbo e2e # All E2E tests
```
## Advanced Turborepo Filtering
```bash
# Package and its dependencies
bunx turbo build --filter=@suaveplan/web...
# Package and its dependents
bunx turbo build --filter=...@suaveplan/core
# Affected since main branch
bunx turbo build --filter=...[main]
# Affected since specific commit
bunx turbo build --filter=...[HEAD^1]
# Combine filters (AND)
bunx turbo test --filter=@suaveplan/ui --filter=...@suaveplan/design-system
# Multiple packages (OR)
bunx turbo test --filter=@suaveplan/{ui,forms,auth-ui}
# Exclude packages
bunx turbo test --filter=!@suaveplan/storybook
```
## E2E Testing
9 packages have Playwright E2E tests:
@suaveplan/web, @suaveplan/admin, @suaveplan/auth-ui, @suaveplan/dashboard, @suaveplan/onboarding, @suaveplan/billing, @suaveplan/forms, @suaveplan/ui, @suaveplan/storybook
```bash
# Single package E2E
bun run e2e --filter=@suaveplan/web
# Multiple packages E2E
bunx turbo e2e --filter=@suaveplan/{web,admin,auth-ui}
# All E2E tests
bunx turbo e2e
```
## Storybook Testing
```bash
# Start Storybook dev server
bun run storybook --filter=@suaveplan/ui
# Run visual tests against Storybook
bun run test:storybook --filter=@suaveplan/ui
```
## Testing Best Practices
- Write clear, focused test descriptions
- Use arrange-act-assert pattern
- Test edge cases and error conditions
- Keep tests fast and deterministic
- Always add `{ timeout: 5000 }` to `it()`/`test()` calls (30000 for integration/async)
- Use `provider: 'istanbul'` for vitest coverage (not v8)
- Install `@vitest/coverage-istanbul` (from `catalog:testing`)
### Timeout Syntax
```typescript
// Unit test
it('description', { timeout: 5000 }, () => {
// test body
});
// Async test
it('async description', { timeout: 10000 }, async () => {
// async test body
});
```
## Tree-Shaking Verification
Before publishing any package:
1. Verify `sideEffects` declaration in package.json
2. Test bundle sizes: `bunx vite-bundle-visualizer`
3. Confirm no side effects in entry points:
- Review `src/index.ts` for immediate execution
- Check for global mutations or polyfills
- Ensure only export statements exist
## Development Workflow
### Starting Development
```bash
bun install # Install dependencies
bunx turbo build # Build all packages
bunx turbo dev # Start development servers
```
### Making Changes
```bash
git checkout -b feature/my-feature # 1. Create feature branch
# 2. Make changes to packages
bunx turbo test --filter=...[HEAD^1] # 3. Run affected tests
bunx turbo test:coverage --filter=...[HEAD^1] # 4. Check coverage
bunx turbo lint typecheck --filter=...[HEAD^1] # 5. Lint and type check
git add . && git commit -m "feat: msg" # 6. Commit changes
```
### Phase Completion Requirements
Before marking a phase as complete, verify:
1. All tests passing: `bunx turbo test`
2. 95%+ test coverage: `bunx turbo test:coverage`
3. 90%+ documentation coverage: `bun run docs:check`
4. No lint errors: `bunx turbo lint`
5. No type errors: `bunx turbo typecheck`
6. E2E tests passing: `bunx turbo e2e` (for applicable packages)
7. Tree-shaking configured: All packages have `sideEffects` declaration
## CI/CD Integration
### GitHub Actions Example
```yaml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build
run: bunx turbo build
- name: Lint
run: bunx turbo lint
- name: Type check
run: bunx turbo typecheck
- name: Test
run: bunx turbo test:coverage
- name: E2E tests
run: bunx turbo e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
```
### Remote Caching (Optional)
```bash
bunx turbo login # Login to Vercel
bunx turbo link # Link your repository
bunx turbo build --remote-cache # Run with remote cache
```
## Troubleshooting
### React Version Conflicts
**Symptoms:** `Error: Minified React error #31`, `TypeError: Cannot read properties of null (reading 'useContext')`, build fails during static page generation.
**Root Cause:** Multiple React versions in monorepo.
**Solution:** Use Bun `overrides` with exact versions and nuclear clean install. See `suaveplan-dependency-management` skill for full steps.
### Clear Cache
```bash
bunx turbo clean # Clear Turborepo cache
rm -rf node_modules && rm bun.lockb && bun install # Clear Bun cache
```
### Debug Mode
```bash
bunx turbo test --filter=@suaveplan/core --verbosity=3 # Verbose logging
bunx turbo test --summarize # Task execution summary
```
### Type Errors
```bash
bunx turbo build --filter=...@suaveplan/types # Build types for all packages
bun tsc --noEmit --pretty # Type check with verbose output
```
## Tool Selection
- MCP servers > Native tools > Basic alternatives
- Use specialized tools for their designed purpose
- Prefer powerful search tools (Grep, Glob) over bash

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-theme-factory",
"version": "1.0.0",
"description": "Tailwind CSS + shadcn/ui theming toolkit with curated themes"
}

View File

@@ -0,0 +1,723 @@
---
name: theme-factory
description: Generate and apply Tailwind CSS + shadcn/ui themes for Suaveplan React applications using HSL CSS variables, dark mode variants, and component-level theming
---
# Theme Factory for Suaveplan
Generate complete Tailwind CSS theme configurations compatible with `@suaveplan/react-ui` (shadcn/ui). Each theme defines HSL CSS variables for `:root` (light) and `.dark` (dark mode), extending the Tailwind config to provide consistent styling across all Suaveplan React components.
## Architecture
| Layer | Technology |
|-------|-----------|
| Color Format | HSL (required for shadcn/ui compatibility) |
| Variable Scope | CSS custom properties on `:root` and `.dark` |
| Dark Mode | Tailwind `dark:` variant via `class` strategy |
| Component Library | `@suaveplan/react-ui` (shadcn/ui-based) |
| Class Merging | `cn()` from `@suaveplan/react-ui` |
| Build | Tailwind CSS PostCSS pipeline via Vite |
## Theme Variables
Every Suaveplan theme must define these CSS variables in HSL format (hue saturation lightness without commas, no `hsl()` wrapper):
### Required Variables
```css
:root {
/* Page */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Card surfaces */
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
/* Popover surfaces */
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
/* Primary actions */
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* Secondary actions */
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
/* Subdued elements */
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
/* Highlights and hover states */
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
/* Errors and destructive actions */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
/* Borders and separators */
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
/* Focus rings */
--ring: 222.2 84% 4.9%;
/* Border radius scale */
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
```
### Optional Extended Variables
For advanced themes, add sidebar and chart variables:
```css
:root {
/* Sidebar (used by @suaveplan/react-ui sidebar component) */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Charts (used by @suaveplan/react-charts) */
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
```
## Tailwind Configuration Extension
Extend the Tailwind config to consume CSS variables:
```typescript
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: 'class',
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
};
export default config;
```
## 10 Built-In Themes
### 1. Default (Zinc)
The standard Suaveplan theme. Neutral zinc scale with sharp contrasts.
```css
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
```
### 2. Slate
Cool blue-gray tone. Professional, corporate.
```css
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
```
### 3. Ocean Blue
Vibrant blue primary. Energetic, tech-forward.
```css
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 217.2 91.2% 59.8%;
}
```
### 4. Emerald
Green primary. Growth, health, sustainability.
```css
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 160.1 84.1% 39.4%;
--primary-foreground: 0 0% 98%;
--secondary: 140 30% 95%;
--secondary-foreground: 160.1 84.1% 25%;
--muted: 140 20% 96%;
--muted-foreground: 150 10% 45%;
--accent: 140 30% 95%;
--accent-foreground: 160.1 84.1% 25%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 140 20% 90%;
--input: 140 20% 90%;
--ring: 160.1 84.1% 39.4%;
--radius: 0.5rem;
}
.dark {
--background: 150 30% 4%;
--foreground: 140 20% 98%;
--primary: 160.1 84.1% 39.4%;
--primary-foreground: 0 0% 98%;
--secondary: 150 20% 15%;
--secondary-foreground: 140 20% 98%;
--muted: 150 20% 15%;
--muted-foreground: 150 15% 60%;
--accent: 150 20% 15%;
--accent-foreground: 140 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 150 20% 15%;
--input: 150 20% 15%;
--ring: 160.1 84.1% 39.4%;
}
```
### 5. Rose
Pink-red primary. Bold, creative, expressive.
```css
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 340 30% 96%;
--secondary-foreground: 346.8 77.2% 35%;
--muted: 340 20% 96%;
--muted-foreground: 340 10% 45%;
--accent: 340 30% 96%;
--accent-foreground: 346.8 77.2% 35%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 340 20% 91%;
--input: 340 20% 91%;
--ring: 346.8 77.2% 49.8%;
--radius: 0.5rem;
}
.dark {
--background: 340 30% 4%;
--foreground: 340 10% 98%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 340 20% 15%;
--secondary-foreground: 340 10% 98%;
--muted: 340 20% 15%;
--muted-foreground: 340 15% 60%;
--accent: 340 20% 15%;
--accent-foreground: 340 10% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 340 20% 15%;
--input: 340 20% 15%;
--ring: 346.8 77.2% 49.8%;
}
```
### 6. Amber
Warm amber/orange primary. Friendly, approachable, energetic.
```css
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 30 30% 96%;
--secondary-foreground: 24.6 95% 30%;
--muted: 30 20% 96%;
--muted-foreground: 25 10% 45%;
--accent: 30 30% 96%;
--accent-foreground: 24.6 95% 30%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 30 20% 90%;
--input: 30 20% 90%;
--ring: 24.6 95% 53.1%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 20 20% 15%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 20 20% 15%;
--muted-foreground: 25 15% 60%;
--accent: 20 20% 15%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 20 20% 15%;
--input: 20 20% 15%;
--ring: 20.5 90.2% 48.2%;
}
```
### 7. Violet
Purple primary. Premium, creative, distinctive.
```css
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 260 30% 96%;
--secondary-foreground: 262.1 83.3% 35%;
--muted: 260 20% 96%;
--muted-foreground: 260 10% 45%;
--accent: 260 30% 96%;
--accent-foreground: 262.1 83.3% 35%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 260 20% 91%;
--input: 260 20% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
.dark {
--background: 260 30% 4%;
--foreground: 260 10% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 260 20% 15%;
--secondary-foreground: 260 10% 98%;
--muted: 260 20% 15%;
--muted-foreground: 260 15% 60%;
--accent: 260 20% 15%;
--accent-foreground: 260 10% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 260 20% 15%;
--input: 260 20% 15%;
--ring: 263.4 70% 50.4%;
}
```
### 8. Neutral
Pure grayscale. Minimal, content-focused, photography-friendly.
```css
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
```
### 9. Teal
Blue-green primary. Calming, trustworthy, health/wellness.
```css
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 175 84% 32%;
--primary-foreground: 0 0% 98%;
--secondary: 170 30% 95%;
--secondary-foreground: 175 84% 22%;
--muted: 170 20% 96%;
--muted-foreground: 170 10% 45%;
--accent: 170 30% 95%;
--accent-foreground: 175 84% 22%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 170 20% 90%;
--input: 170 20% 90%;
--ring: 175 84% 32%;
--radius: 0.5rem;
}
.dark {
--background: 175 30% 4%;
--foreground: 170 20% 98%;
--primary: 175 84% 32%;
--primary-foreground: 0 0% 98%;
--secondary: 175 20% 15%;
--secondary-foreground: 170 20% 98%;
--muted: 175 20% 15%;
--muted-foreground: 170 15% 60%;
--accent: 175 20% 15%;
--accent-foreground: 170 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 175 20% 15%;
--input: 175 20% 15%;
--ring: 175 84% 32%;
}
```
### 10. Crimson
Deep red primary. Bold, authoritative, editorial.
```css
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 0 72.2% 50.6%;
--primary-foreground: 0 85.7% 97.3%;
--secondary: 0 20% 96%;
--secondary-foreground: 0 72.2% 35%;
--muted: 0 15% 96%;
--muted-foreground: 0 8% 45%;
--accent: 0 20% 96%;
--accent-foreground: 0 72.2% 35%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 15% 91%;
--input: 0 15% 91%;
--ring: 0 72.2% 50.6%;
--radius: 0.5rem;
}
.dark {
--background: 0 20% 4%;
--foreground: 0 10% 98%;
--primary: 0 72.2% 50.6%;
--primary-foreground: 0 85.7% 97.3%;
--secondary: 0 15% 15%;
--secondary-foreground: 0 10% 98%;
--muted: 0 15% 15%;
--muted-foreground: 0 10% 60%;
--accent: 0 15% 15%;
--accent-foreground: 0 10% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 15% 15%;
--input: 0 15% 15%;
--ring: 0 72.2% 50.6%;
}
```
## Applying a Theme
### Method 1: Global CSS (Single Theme)
Place variables in the app's global CSS file:
```css
/* apps/web/src/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* ... rest of light theme variables */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... rest of dark theme variables */
}
}
```
### Method 2: Theme Switcher (Multiple Themes)
Use data attributes to switch between themes at runtime:
```css
/* themes.css */
[data-theme="default"] { /* ... zinc variables ... */ }
[data-theme="ocean"] { /* ... ocean blue variables ... */ }
[data-theme="emerald"] { /* ... emerald variables ... */ }
/* ... */
[data-theme="default"].dark { /* ... zinc dark variables ... */ }
[data-theme="ocean"].dark { /* ... ocean blue dark variables ... */ }
```
```typescript
// Theme switcher component
import * as React from 'react';
const themes = [
'default', 'slate', 'ocean', 'emerald', 'rose',
'amber', 'violet', 'neutral', 'teal', 'crimson',
] as const;
type Theme = (typeof themes)[number];
export function useTheme() {
const [theme, setTheme] = React.useState<Theme>('default');
const [dark, setDark] = React.useState(false);
React.useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.classList.toggle('dark', dark);
}, [theme, dark]);
return { theme, setTheme, dark, setDark, themes };
}
```
### Method 3: Component-Level Theming
Override variables on a specific container for scoped theming:
```typescript
import { cn } from '@suaveplan/react-ui';
export function BrandedSection({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<section
className={cn('rounded-lg p-6', className)}
style={{
'--primary': '221.2 83.2% 53.3%',
'--primary-foreground': '210 40% 98%',
} as React.CSSProperties}
>
{children}
</section>
);
}
```
## Creating a Custom Theme
### Step-by-Step
1. **Choose a primary hue** (0-360 on the HSL wheel)
2. **Set primary saturation** (60-90% for vibrant, 30-50% for muted)
3. **Derive secondary** from primary hue with lower saturation and higher lightness
4. **Derive muted** from primary hue with very low saturation (15-25%) and high lightness (95-97%)
5. **Set accent** equal to secondary (or slightly different hue for contrast)
6. **Keep destructive** at red (hue 0) -- users expect red for errors
7. **Derive border** from muted with slightly lower lightness
8. **Set ring** equal to primary (focus rings should match primary actions)
9. **Dark mode**: Invert lightness values -- backgrounds become dark (3-5%), foregrounds become light (95-98%)
### Contrast Requirements
All theme color pairs must meet WCAG 2.1 AA contrast ratios:
- **Normal text**: minimum 4.5:1 contrast ratio
- **Large text** (18px+ or 14px+ bold): minimum 3:1 contrast ratio
- **UI components**: minimum 3:1 against adjacent colors
Verify with: `foreground` against `background`, `primary-foreground` against `primary`, etc.
## Constraints
- **HSL format only** -- no hex, rgb, or oklch in CSS variables
- **No `hsl()` wrapper** in variable values -- Tailwind adds it via `hsl(var(--name))`
- **All 14 required variables** must be defined in both `:root` and `.dark`
- **WCAG AA contrast** on all foreground/background pairs
- **`dark:` variant** must be supported -- no light-only themes
- **`--radius`** must be included (default `0.5rem`)
- **No Tailwind color utilities** (`bg-blue-500`) for themed elements -- always use semantic tokens (`bg-primary`)
- Components must use `cn()` from `@suaveplan/react-ui` for class merging

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-threejs",
"version": "1.0.0",
"description": "React Three Fiber development skills for Suaveplan 3D packages"
}

View File

@@ -0,0 +1,538 @@
---
name: threejs-animation
description: Three.js animation systems adapted for React Three Fiber and Suaveplan R3F packages. Covers useFrame-driven animation loops, AnimationMixer integration, skeletal animation with @suaveplan/r3f-character, keyframe tracks, morph targets, and declarative animation patterns in the R3F component model.
---
This skill covers animation in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All animation code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to animate 3D objects, characters, cameras, or properties in an R3F scene. They may need frame-loop animations, clip-based skeletal animations, morph target blending, or physics-driven motion.
## Core Concepts
### Three.js Animation System
Three.js provides several animation primitives:
- **AnimationClip**: A reusable set of keyframe tracks (position, rotation, scale, morph targets)
- **AnimationMixer**: The playback controller that drives clips on a specific object
- **AnimationAction**: Controls a single clip's playback (play, pause, crossfade, weight, speed)
- **KeyframeTrack**: Raw keyframe data (times + values) for a single property
- **Clock**: High-resolution timer for delta time calculation
### R3F Animation Approach
In R3F, animation integrates with React's component lifecycle:
| Three.js (Imperative) | R3F (Declarative) | When to Use |
|---|---|---|
| `requestAnimationFrame` loop | `useFrame` hook | Per-frame updates |
| `new AnimationMixer(model)` | `useAnimations` from drei | GLTF clip playback |
| Manual clock management | `useFrame((state, delta) => {})` | Delta time access |
| `mixer.update(delta)` | Automatic in `useAnimations` | Mixer ticking |
| Event listeners | React refs + useEffect | Lifecycle binding |
### Suaveplan R3F Packages for Animation
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities, scene context | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-character` | Skeletal animation, character controllers | `import { useCharacterAnimation } from '@suaveplan/r3f-character'` |
| `@suaveplan/r3f-performance` | Animation performance monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
| `@suaveplan/r3f-shaders` | Animated shader materials | `import { useAnimatedShader } from '@suaveplan/r3f-shaders'` |
## useFrame: The R3F Animation Loop
`useFrame` is the primary animation hook. It runs every frame inside the R3F render loop and provides access to the Three.js state and delta time.
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import type { Mesh } from 'three';
interface SpinningBoxProps {
speed?: number;
axis?: 'x' | 'y' | 'z';
}
export function SpinningBox({ speed = 1, axis = 'y' }: SpinningBoxProps) {
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!meshRef.current) return;
meshRef.current.rotation[axis] += delta * speed;
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="tomato" />
</mesh>
);
}
```
### useFrame Priority System
R3F processes `useFrame` callbacks in priority order. Lower numbers run first. This is critical for animation ordering (e.g., physics before rendering).
```tsx
// Physics runs first (priority -1)
useFrame(() => { /* update physics */ }, -1);
// Animation runs at default priority (0)
useFrame(() => { /* update animations */ });
// Camera follows after animation (priority 1)
useFrame(() => { /* update camera */ }, 1);
```
### Conditional useFrame
Avoid creating/destroying hooks dynamically. Instead, gate logic inside the callback:
```tsx
export function ConditionalAnimation({ active }: { active: boolean }) {
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!active || !meshRef.current) return;
meshRef.current.rotation.y += delta;
});
return (
<mesh ref={meshRef}>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial color="dodgerblue" />
</mesh>
);
}
```
## AnimationMixer in R3F
For GLTF models with embedded animations, use `useAnimations` from `@react-three/drei` which wraps `AnimationMixer`.
```tsx
import { useRef, useEffect } from 'react';
import { useGLTF, useAnimations } from '@react-three/drei';
import type { Group } from 'three';
interface AnimatedModelProps {
url: string;
clipName: string;
playing?: boolean;
}
export function AnimatedModel({ url, clipName, playing = true }: AnimatedModelProps) {
const groupRef = useRef<Group>(null);
const { scene, animations } = useGLTF(url);
const { actions, mixer } = useAnimations(animations, groupRef);
useEffect(() => {
const action = actions[clipName];
if (!action) return;
if (playing) {
action.reset().fadeIn(0.3).play();
} else {
action.fadeOut(0.3);
}
return () => {
action.fadeOut(0.3);
};
}, [clipName, playing, actions]);
return <primitive ref={groupRef} object={scene} />;
}
```
### Crossfading Between Clips
```tsx
import { useRef, useEffect, useState } from 'react';
import { useGLTF, useAnimations } from '@react-three/drei';
import type { Group, AnimationAction } from 'three';
interface CrossfadeModelProps {
url: string;
currentClip: string;
fadeDuration?: number;
}
export function CrossfadeModel({ url, currentClip, fadeDuration = 0.4 }: CrossfadeModelProps) {
const groupRef = useRef<Group>(null);
const { scene, animations } = useGLTF(url);
const { actions } = useAnimations(animations, groupRef);
const [previousClip, setPreviousClip] = useState<string | null>(null);
useEffect(() => {
const nextAction = actions[currentClip];
if (!nextAction) return;
if (previousClip && actions[previousClip]) {
const prevAction = actions[previousClip] as AnimationAction;
nextAction.reset().fadeIn(fadeDuration).play();
prevAction.fadeOut(fadeDuration);
} else {
nextAction.reset().fadeIn(fadeDuration).play();
}
setPreviousClip(currentClip);
}, [currentClip, actions, fadeDuration]);
return <primitive ref={groupRef} object={scene} />;
}
```
## @suaveplan/r3f-character: Skeletal Animation
The `@suaveplan/r3f-character` package provides higher-level character animation utilities built on top of Three.js's `AnimationMixer`.
### Character Animation Controller
```tsx
import { useCharacterAnimation } from '@suaveplan/r3f-character';
import type { CharacterAnimationConfig } from '@suaveplan/r3f-character';
const animationConfig: CharacterAnimationConfig = {
clips: {
idle: { url: '/models/character/idle.glb', loop: true },
walk: { url: '/models/character/walk.glb', loop: true },
run: { url: '/models/character/run.glb', loop: true },
jump: { url: '/models/character/jump.glb', loop: false },
},
transitions: {
idle_to_walk: { duration: 0.3 },
walk_to_run: { duration: 0.2 },
any_to_jump: { duration: 0.1 },
},
defaultClip: 'idle',
};
export function Character() {
const { groupRef, playClip, currentClip } = useCharacterAnimation(animationConfig);
return (
<group ref={groupRef}>
{/* Character mesh loaded via the hook */}
</group>
);
}
```
### Blending Mixamo Animations
When working with Mixamo animations on a shared skeleton:
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { useGLTF, useAnimations } from '@react-three/drei';
import { AnimationUtils } from 'three';
import type { Group } from 'three';
interface MixamoCharacterProps {
modelUrl: string;
animationUrls: Record<string, string>;
currentAnimation: string;
}
export function MixamoCharacter({
modelUrl,
animationUrls,
currentAnimation,
}: MixamoCharacterProps) {
const groupRef = useRef<Group>(null);
const { scene, animations: baseAnimations } = useGLTF(modelUrl);
// Load additional animation files
const additionalAnims = Object.entries(animationUrls).map(([_name, url]) => {
return useGLTF(url).animations;
});
const allAnimations = useMemo(() => {
const merged = [...baseAnimations];
Object.keys(animationUrls).forEach((name, index) => {
const clips = additionalAnims[index];
if (clips?.[0]) {
const clip = AnimationUtils.subclip(clips[0], name, 0, clips[0].duration * 30, 30);
merged.push(clip);
}
});
return merged;
}, [baseAnimations, additionalAnims]);
const { actions } = useAnimations(allAnimations, groupRef);
useFrame(() => {
const action = actions[currentAnimation];
if (action && !action.isRunning()) {
Object.values(actions).forEach((a) => a?.fadeOut(0.3));
action.reset().fadeIn(0.3).play();
}
});
return <primitive ref={groupRef} object={scene} />;
}
```
## Morph Target Animation
Morph targets (blend shapes) animate vertex positions for facial expressions, deformations, etc.
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
import type { Mesh } from 'three';
interface MorphAnimationProps {
url: string;
/** Map of morph target name to weight (0-1) */
morphWeights: Record<string, number>;
/** Transition speed for weight interpolation */
lerpSpeed?: number;
}
export function MorphAnimation({ url, morphWeights, lerpSpeed = 5 }: MorphAnimationProps) {
const { scene } = useGLTF(url);
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!meshRef.current?.morphTargetDictionary || !meshRef.current.morphTargetInfluences) return;
const dict = meshRef.current.morphTargetDictionary;
const influences = meshRef.current.morphTargetInfluences;
for (const [name, targetWeight] of Object.entries(morphWeights)) {
const index = dict[name];
if (index !== undefined) {
const current = influences[index] ?? 0;
influences[index] = current + (targetWeight - current) * Math.min(delta * lerpSpeed, 1);
}
}
});
return <primitive ref={meshRef} object={scene} />;
}
```
## Procedural Animation Patterns
### Spring-Based Animation
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { MathUtils, Vector3 } from 'three';
import type { Mesh } from 'three';
interface SpringConfig {
stiffness: number;
damping: number;
}
export function SpringFollower({ target, config }: { target: Vector3; config: SpringConfig }) {
const meshRef = useRef<Mesh>(null);
const velocity = useRef(new Vector3());
useFrame((_state, delta) => {
if (!meshRef.current) return;
const pos = meshRef.current.position;
const force = target.clone().sub(pos).multiplyScalar(config.stiffness);
velocity.current.add(force.multiplyScalar(delta));
velocity.current.multiplyScalar(Math.max(0, 1 - config.damping * delta));
pos.add(velocity.current.clone().multiplyScalar(delta));
});
return (
<mesh ref={meshRef}>
<sphereGeometry args={[0.2, 16, 16]} />
<meshStandardMaterial color="limegreen" />
</mesh>
);
}
```
### Easing with MathUtils
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { MathUtils } from 'three';
import type { Mesh } from 'three';
export function SmoothRotation({ targetAngle }: { targetAngle: number }) {
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!meshRef.current) return;
meshRef.current.rotation.y = MathUtils.damp(
meshRef.current.rotation.y,
targetAngle,
4, // lambda (smoothing factor)
delta
);
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshNormalMaterial />
</mesh>
);
}
```
## Performance Considerations
### Animation Budget
Use `@suaveplan/r3f-performance` to monitor animation cost:
```tsx
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
export function MonitoredScene() {
usePerformanceMonitor({
onDecline: ({ fps }) => {
// Reduce animation quality when FPS drops
if (fps < 30) {
// Switch to simpler animations, reduce bone count, etc.
}
},
});
return <>{/* scene content */}</>;
}
```
### Optimization Guidelines
1. **Avoid allocations in useFrame**: Never create `new Vector3()` or `new Quaternion()` inside `useFrame`. Allocate in `useRef` or module scope.
2. **Use delta time**: Always multiply by `delta` for frame-rate independent animation.
3. **Limit active mixers**: Each `AnimationMixer` updates every frame. Pool or disable off-screen mixers.
4. **LOD-aware animation**: Reduce bone count or switch to procedural animation for distant characters.
5. **Pause when invisible**: Use `useIntersect` from drei or Intersection Observer to pause off-screen animations.
```tsx
// Pre-allocate reusable objects outside useFrame
const _tempVec = new Vector3();
const _tempQuat = new Quaternion();
useFrame(() => {
// Reuse allocated objects
_tempVec.set(0, 1, 0);
mesh.current?.position.lerp(_tempVec, 0.1);
});
```
### Three.js KeyframeTrack Types
When building animation clips programmatically:
| Track Type | Property | Value Type |
|---|---|---|
| `VectorKeyframeTrack` | `.position`, `.scale` | `[x,y,z]` per keyframe |
| `QuaternionKeyframeTrack` | `.quaternion` | `[x,y,z,w]` per keyframe |
| `NumberKeyframeTrack` | `.opacity`, morph targets | Single float per keyframe |
| `BooleanKeyframeTrack` | `.visible` | Boolean per keyframe |
| `ColorKeyframeTrack` | `.color` | `[r,g,b]` per keyframe |
| `StringKeyframeTrack` | Any string property | String per keyframe |
### Programmatic Clip Creation in R3F
```tsx
import { useMemo } from 'react';
import { AnimationClip, VectorKeyframeTrack, NumberKeyframeTrack } from 'three';
import { useAnimations } from '@react-three/drei';
function useBounceClip(name: string, height: number, duration: number) {
return useMemo(() => {
const times = [0, duration * 0.5, duration];
const posValues = [0, 0, 0, 0, height, 0, 0, 0, 0];
const scaleValues = [1, 1, 1, 1, 0.8, 1.2, 1, 1, 1];
return new AnimationClip(name, duration, [
new VectorKeyframeTrack('.position', times, posValues),
new VectorKeyframeTrack('.scale', times, scaleValues),
]);
}, [name, height, duration]);
}
```
## Testing Animations
### Vitest Unit Tests
```tsx
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
describe('useBounceClip', () => {
it('creates a clip with correct duration', () => {
const { result } = renderHook(() => useBounceClip('bounce', 2, 1));
expect(result.current.duration).toBe(1);
expect(result.current.tracks).toHaveLength(2);
});
it('includes position and scale tracks', () => {
const { result } = renderHook(() => useBounceClip('bounce', 2, 1));
const trackNames = result.current.tracks.map((t) => t.name);
expect(trackNames).toContain('.position');
expect(trackNames).toContain('.scale');
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { SpinningBox } from './spinning-box';
const meta: Meta<typeof SpinningBox> = {
title: 'R3F/Animation/SpinningBox',
component: SpinningBox,
decorators: [
(Story) => (
<Canvas style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Story />
</Canvas>
),
],
};
export default meta;
type Story = StoryObj<typeof SpinningBox>;
export const Default: Story = {};
export const FastSpin: Story = {
args: { speed: 5, axis: 'y' },
};
export const XAxis: Story = {
args: { speed: 2, axis: 'x' },
};
```
## Checklist
When implementing R3F animations:
1. Use `useFrame` with delta time for all per-frame updates
2. Pre-allocate math objects outside `useFrame` callbacks
3. Use `useAnimations` for GLTF clip playback rather than manual mixer management
4. Implement crossfade transitions between animation clips
5. Gate animation logic on `active` flags rather than conditionally calling hooks
6. Monitor performance with `@suaveplan/r3f-performance`
7. Write Vitest tests for animation clip creation and hook behavior
8. Write Storybook stories with Canvas decorator for visual verification
9. Keep animation component files under 250 lines
10. Document morph target names and clip names in `.md` files

View File

@@ -0,0 +1,522 @@
---
name: threejs-fundamentals
description: Three.js fundamentals adapted for React Three Fiber and Suaveplan R3F packages. Covers the Canvas component, declarative scene graph composition, Object3D hierarchy as JSX, coordinate systems, camera setup, and @suaveplan/r3f core utilities.
---
This skill covers Three.js fundamentals within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to set up 3D scenes, understand the scene graph, work with coordinate systems, or configure the rendering pipeline in an R3F application.
## Core Concepts
### Three.js Scene Architecture
Every Three.js application has three fundamental objects:
- **Scene**: The root container (a tree of `Object3D` nodes)
- **Camera**: Defines the viewpoint (perspective or orthographic)
- **Renderer**: Draws the scene from the camera's perspective using WebGL/WebGPU
### R3F Declarative Model
R3F replaces imperative Three.js setup with a React component tree. The `<Canvas>` component creates the renderer, scene, and default camera automatically.
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new THREE.Scene()` | `<Canvas>` (implicit) |
| `new THREE.PerspectiveCamera(75, aspect, 0.1, 1000)` | `<Canvas camera={{ fov: 75, near: 0.1, far: 1000 }}>` |
| `new THREE.WebGLRenderer({ antialias: true })` | `<Canvas gl={{ antialias: true }}>` |
| `scene.add(mesh)` | `<mesh>` as JSX child |
| `renderer.setSize(w, h)` | Automatic resize handling |
| `requestAnimationFrame(render)` | Automatic render loop |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities, scene context, helpers | `import { SceneProvider } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-camera` | Camera controllers | `import { OrbitControls } from '@suaveplan/r3f-camera'` |
| `@suaveplan/r3f-performance` | FPS monitoring, adaptive quality | `import { PerformanceMonitor } from '@suaveplan/r3f-performance'` |
## Canvas: The R3F Entry Point
The `<Canvas>` component is the root of every R3F scene. It creates a WebGL context, sets up the render loop, and provides React context for all Three.js state.
### Basic Scene Setup
```tsx
import { Canvas } from '@react-three/fiber';
export function BasicScene() {
return (
<Canvas
camera={{ position: [0, 2, 5], fov: 60 }}
gl={{ antialias: true, toneMapping: 3 /* ACESFilmic */ }}
style={{ width: '100%', height: '100vh' }}
>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} intensity={1} />
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#e74c3c" />
</mesh>
<gridHelper args={[10, 10]} />
</Canvas>
);
}
```
### Canvas Props Reference
```tsx
<Canvas
// Camera configuration
camera={{
position: [0, 5, 10], // World position
fov: 50, // Field of view (degrees)
near: 0.1, // Near clipping plane
far: 1000, // Far clipping plane
zoom: 1, // Zoom level (orthographic)
}}
// Renderer configuration
gl={{
antialias: true,
alpha: false, // Transparent background
toneMapping: 3, // ACESFilmicToneMapping
toneMappingExposure: 1.0,
outputColorSpace: 'srgb',
}}
// Performance
dpr={[1, 2]} // Device pixel ratio range
frameloop="demand" // 'always' | 'demand' | 'never'
performance={{ min: 0.5 }} // Adaptive DPR threshold
// Behavior
shadows // Enable shadow maps
flat // Disable tone mapping
orthographic // Use orthographic camera
linear // Disable sRGB color management
// Sizing
style={{ width: '100%', height: '100vh' }}
/>
```
## Scene Graph as JSX
In R3F, the Three.js scene graph maps directly to JSX elements. Every Three.js class that extends `Object3D` can be used as a JSX element in lowercase.
### Object3D Hierarchy
```tsx
{/* Three.js: const group = new Group(); scene.add(group); */}
<group position={[0, 1, 0]} rotation={[0, Math.PI / 4, 0]}>
{/* Three.js: const mesh = new Mesh(geometry, material); group.add(mesh); */}
<mesh position={[0, 0, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="blue" />
</mesh>
{/* Nested groups create parent-child transforms */}
<group position={[2, 0, 0]}>
<mesh>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial color="green" />
</mesh>
</group>
</group>
```
### JSX to Three.js Mapping
R3F automatically maps JSX elements to Three.js constructors:
| JSX Element | Three.js Class | Constructor Args |
|---|---|---|
| `<mesh>` | `new Mesh()` | None (geometry + material as children) |
| `<group>` | `new Group()` | None |
| `<points>` | `new Points()` | None |
| `<line>` | `new Line()` | None |
| `<lineSegments>` | `new LineSegments()` | None |
| `<sprite>` | `new Sprite()` | None |
| `<instancedMesh args={[null, null, count]}>` | `new InstancedMesh(g, m, count)` | Geometry, Material, Count |
| `<primitive object={obj}>` | Direct insertion | Existing Object3D |
### Transform Props
Every R3F element that maps to an `Object3D` accepts transform props:
```tsx
<mesh
position={[x, y, z]} // Vector3 or [number, number, number]
rotation={[x, y, z]} // Euler or [number, number, number]
scale={[x, y, z]} // Vector3 or number (uniform scale)
quaternion={[x, y, z, w]} // Quaternion rotation
visible={true} // Visibility toggle
frustumCulled={true} // Frustum culling
renderOrder={0} // Render ordering
castShadow // Cast shadows
receiveShadow // Receive shadows
name="myMesh" // Object name for debugging
userData={{ id: 'custom' }} // Custom data
/>
```
## Coordinate System
Three.js uses a **right-handed coordinate system**:
```text
Y (up)
|
|
+---- X (right)
/
Z (toward viewer)
```
- **X**: Positive to the right
- **Y**: Positive upward
- **Z**: Positive toward the viewer (out of the screen)
### Units and Scale
Three.js is unit-agnostic. By convention, 1 unit = 1 meter for physics and VR compatibility:
```tsx
// A 2m tall character standing on a 10m x 10m ground plane
<group>
<mesh position={[0, 1, 0]}> {/* Center at 1m (half height) */}
<boxGeometry args={[0.5, 2, 0.3]} /> {/* 0.5m wide, 2m tall, 0.3m deep */}
<meshStandardMaterial color="tan" />
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]}> {/* Rotate to be flat */}
<planeGeometry args={[10, 10]} />
<meshStandardMaterial color="forestgreen" />
</mesh>
</group>
```
## Accessing Three.js State
### useThree Hook
Access the full Three.js state from any R3F child component:
```tsx
import { useThree } from '@react-three/fiber';
export function SceneInfo() {
const { scene, camera, gl, size, viewport, clock } = useThree();
// scene: THREE.Scene instance
// camera: Active camera
// gl: WebGLRenderer instance
// size: { width, height } in pixels
// viewport: { width, height } in Three.js units at camera distance
// clock: THREE.Clock instance
return null;
}
```
### Imperative Escape Hatch with Refs
When you need direct Three.js API access:
```tsx
import { useRef, useEffect } from 'react';
import type { Mesh, MeshStandardMaterial } from 'three';
export function ImperativeMesh() {
const meshRef = useRef<Mesh>(null);
useEffect(() => {
if (!meshRef.current) return;
// Full imperative Three.js API access
const mesh = meshRef.current;
mesh.geometry.computeBoundingBox();
const box = mesh.geometry.boundingBox;
const material = mesh.material as MeshStandardMaterial;
material.needsUpdate = true;
// Traverse children
mesh.traverse((child) => {
if (child instanceof Mesh) {
child.castShadow = true;
}
});
}, []);
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="coral" />
</mesh>
);
}
```
## @suaveplan/r3f Core Utilities
The `@suaveplan/r3f` package provides scene-level context and shared utilities:
```tsx
import { SceneProvider, useSceneContext } from '@suaveplan/r3f';
import { Canvas } from '@react-three/fiber';
export function App() {
return (
<Canvas>
<SceneProvider config={{ units: 'meters', upAxis: 'y' }}>
<SceneContent />
</SceneProvider>
</Canvas>
);
}
function SceneContent() {
const { config } = useSceneContext();
// Access shared scene configuration
return <>{/* scene objects */}</>;
}
```
## Render Modes
### On-Demand Rendering
For scenes that only change on interaction, use `frameloop="demand"` to save GPU cycles:
```tsx
import { Canvas, invalidate } from '@react-three/fiber';
export function OnDemandScene() {
return (
<Canvas frameloop="demand">
<InteractiveObject />
</Canvas>
);
}
function InteractiveObject() {
return (
<mesh
onClick={(e) => {
e.object.rotation.y += 0.5;
invalidate(); // Request a re-render
}}
>
<boxGeometry />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}
```
### Adaptive Performance
```tsx
import { Canvas } from '@react-three/fiber';
import { PerformanceMonitor } from '@suaveplan/r3f-performance';
export function AdaptiveScene() {
return (
<Canvas dpr={[1, 2]} performance={{ min: 0.5 }}>
<PerformanceMonitor
onDecline={() => { /* reduce quality */ }}
onIncline={() => { /* increase quality */ }}
/>
{/* scene content */}
</Canvas>
);
}
```
## Scene Organization Patterns
### Layer-Based Architecture
```tsx
export function GameScene() {
return (
<Canvas shadows camera={{ position: [0, 10, 20], fov: 50 }}>
{/* Environment layer */}
<group name="environment">
<ambientLight intensity={0.3} />
<directionalLight position={[10, 10, 5]} castShadow />
<Sky />
<Ground />
</group>
{/* Static geometry layer */}
<group name="static">
<Buildings />
<Terrain />
</group>
{/* Dynamic objects layer */}
<group name="dynamic">
<Characters />
<Vehicles />
<Projectiles />
</group>
{/* UI overlay layer */}
<group name="ui">
<HUD />
<Markers />
</group>
</Canvas>
);
}
```
### Suspense for Loading
```tsx
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
function LoadingFallback() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="gray" wireframe />
</mesh>
);
}
export function SceneWithLoading() {
return (
<Canvas>
<ambientLight />
<Suspense fallback={<LoadingFallback />}>
<HeavyModel url="/models/scene.glb" />
</Suspense>
</Canvas>
);
}
```
## Color Management
R3F defaults to sRGB color management. Colors specified in JSX are treated as sRGB and converted to linear for rendering.
```tsx
// sRGB colors (default) - what you see in design tools
<meshStandardMaterial color="#ff6600" />
<meshStandardMaterial color="tomato" />
<meshStandardMaterial color={new Color(1, 0.4, 0)} /> // Linear!
// For linear workflow (textures already linear)
<Canvas linear>
{/* Colors not converted */}
</Canvas>
// Tone mapping options
<Canvas gl={{ toneMapping: ACESFilmicToneMapping, toneMappingExposure: 1.2 }}>
{/* Scene with filmic tone mapping */}
</Canvas>
```
## Disposal and Cleanup
R3F automatically disposes of geometries, materials, and textures when components unmount. For manual control:
```tsx
import { useEffect } from 'react';
import { useThree } from '@react-three/fiber';
export function CleanupExample() {
const { scene } = useThree();
useEffect(() => {
return () => {
// Manual cleanup for imperatively created resources
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry.dispose();
if (Array.isArray(object.material)) {
object.material.forEach((m) => m.dispose());
} else {
object.material.dispose();
}
}
});
};
}, [scene]);
return null;
}
```
## Testing Fundamentals
### Vitest Scene Tests
```tsx
import { describe, it, expect } from 'vitest';
import { createRoot } from '@react-three/fiber';
import { Scene } from 'three';
describe('BasicScene', () => {
it('renders without crashing', () => {
const scene = new Scene();
// Use @react-three/test-renderer for component testing
expect(scene.children).toHaveLength(0);
});
});
```
### Storybook Scene Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
function SceneTemplate() {
return (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<mesh>
<boxGeometry />
<meshStandardMaterial color="royalblue" />
</mesh>
<gridHelper />
<OrbitControls />
</Canvas>
);
}
const meta: Meta = {
title: 'R3F/Fundamentals/Scene',
component: SceneTemplate,
};
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When setting up an R3F scene:
1. Use `<Canvas>` as the root with appropriate camera and renderer config
2. Structure scene graph with named `<group>` layers
3. Set `dpr={[1, 2]}` for responsive pixel ratio
4. Wrap heavy models in `<Suspense>` with fallback geometry
5. Use `useThree` for state access, refs for imperative escape hatches
6. Enable shadows on Canvas and on individual lights/meshes
7. Configure tone mapping and color space for the project
8. Use `@suaveplan/r3f` SceneProvider for shared configuration
9. Use `@suaveplan/r3f-performance` for adaptive quality
10. Keep scene component files under 250 lines, split into sub-components

View File

@@ -0,0 +1,576 @@
---
name: threejs-geometry
description: Three.js geometry creation and manipulation adapted for React Three Fiber and Suaveplan R3F packages. Covers declarative mesh/geometry JSX, BufferGeometry with React refs, InstancedMesh patterns for high-performance rendering, custom geometry construction, and @react-three/drei geometry helpers.
---
This skill covers geometry creation and manipulation in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to create, modify, or optimize 3D geometry in an R3F scene. This includes built-in primitives, custom BufferGeometry, instanced rendering, and parametric shapes.
## Core Concepts
### Three.js Geometry System
Three.js geometry is based on `BufferGeometry`, which stores vertex data in typed arrays:
- **position**: `Float32Array` of `[x, y, z]` per vertex
- **normal**: `Float32Array` of `[nx, ny, nz]` per vertex
- **uv**: `Float32Array` of `[u, v]` per vertex
- **index**: `Uint16Array` or `Uint32Array` of triangle vertex indices
- **color**: `Float32Array` of `[r, g, b]` per vertex (optional)
### R3F Geometry Approach
In R3F, geometries are declared as JSX children of `<mesh>`:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new BoxGeometry(1, 1, 1)` | `<boxGeometry args={[1, 1, 1]} />` |
| `mesh.geometry = geometry` | `<mesh><boxGeometry /></mesh>` |
| `geometry.setAttribute(...)` | Via ref or attach prop |
| `new InstancedMesh(g, m, n)` | `<instancedMesh args={[null, null, n]}>` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities, geometry helpers | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Geometry LOD, draw call monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## Built-in Geometry Primitives
All Three.js geometry classes are available as lowercase JSX elements. Constructor arguments pass via `args`.
### Box
```tsx
<mesh>
<boxGeometry args={[
1, // width
1, // height
1, // depth
1, // widthSegments (optional)
1, // heightSegments (optional)
1, // depthSegments (optional)
]} />
<meshStandardMaterial color="tomato" />
</mesh>
```
### Sphere
```tsx
<mesh>
<sphereGeometry args={[
0.5, // radius
32, // widthSegments
32, // heightSegments
0, // phiStart (optional)
Math.PI * 2, // phiLength (optional)
0, // thetaStart (optional)
Math.PI, // thetaLength (optional)
]} />
<meshStandardMaterial color="dodgerblue" />
</mesh>
```
### Common Primitives Reference
| Geometry | JSX | Key Args |
|---|---|---|
| Box | `<boxGeometry args={[w, h, d]} />` | width, height, depth |
| Sphere | `<sphereGeometry args={[r, wSeg, hSeg]} />` | radius, segments |
| Cylinder | `<cylinderGeometry args={[rTop, rBot, h, rSeg]} />` | radii, height, segments |
| Cone | `<coneGeometry args={[r, h, rSeg]} />` | radius, height |
| Torus | `<torusGeometry args={[r, tube, rSeg, tSeg]} />` | radius, tube radius |
| Plane | `<planeGeometry args={[w, h, wSeg, hSeg]} />` | width, height |
| Ring | `<ringGeometry args={[inner, outer, tSeg]} />` | inner/outer radius |
| Circle | `<circleGeometry args={[r, seg]} />` | radius, segments |
| TorusKnot | `<torusKnotGeometry args={[r, tube, tSeg, rSeg, p, q]} />` | radius, tube, knot params |
| Dodecahedron | `<dodecahedronGeometry args={[r, detail]} />` | radius, detail level |
| Icosahedron | `<icosahedronGeometry args={[r, detail]} />` | radius, detail level |
| Octahedron | `<octahedronGeometry args={[r, detail]} />` | radius, detail level |
| Tetrahedron | `<tetrahedronGeometry args={[r, detail]} />` | radius, detail level |
## Custom BufferGeometry
### Declarative with Refs
```tsx
import { useRef, useEffect, useMemo } from 'react';
import { BufferAttribute } from 'three';
import type { BufferGeometry } from 'three';
interface CustomTriangleProps {
size?: number;
color?: string;
}
export function CustomTriangle({ size = 1, color = 'limegreen' }: CustomTriangleProps) {
const geometryRef = useRef<BufferGeometry>(null);
const { positions, normals, uvs } = useMemo(() => {
const s = size;
return {
positions: new Float32Array([
-s, -s, 0, // bottom-left
s, -s, 0, // bottom-right
0, s, 0, // top-center
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
]),
uvs: new Float32Array([
0, 0,
1, 0,
0.5, 1,
]),
};
}, [size]);
return (
<mesh>
<bufferGeometry ref={geometryRef}>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
/>
<bufferAttribute
attach="attributes-normal"
args={[normals, 3]}
/>
<bufferAttribute
attach="attributes-uv"
args={[uvs, 2]}
/>
</bufferGeometry>
<meshStandardMaterial color={color} side={2} />
</mesh>
);
}
```
### Indexed Geometry
For geometry with shared vertices (reduces memory and improves performance):
```tsx
import { useMemo } from 'react';
export function IndexedQuad({ size = 1 }: { size?: number }) {
const { positions, indices, uvs } = useMemo(() => {
const s = size / 2;
return {
positions: new Float32Array([
-s, -s, 0, // 0: bottom-left
s, -s, 0, // 1: bottom-right
s, s, 0, // 2: top-right
-s, s, 0, // 3: top-left
]),
indices: new Uint16Array([
0, 1, 2, // first triangle
0, 2, 3, // second triangle
]),
uvs: new Float32Array([
0, 0,
1, 0,
1, 1,
0, 1,
]),
};
}, [size]);
return (
<mesh>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-uv" args={[uvs, 2]} />
<bufferAttribute attach="index" args={[indices, 1]} />
</bufferGeometry>
<meshStandardMaterial color="coral" />
</mesh>
);
}
```
## Dynamic Geometry Updates
### Animating Vertices in useFrame
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import type { BufferGeometry } from 'three';
export function WavingPlane({ width = 5, segments = 32 }: { width?: number; segments?: number }) {
const geometryRef = useRef<BufferGeometry>(null);
useFrame(({ clock }) => {
if (!geometryRef.current) return;
const positions = geometryRef.current.attributes.position;
const time = clock.elapsedTime;
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const z = positions.getZ(i);
const y = Math.sin(x * 2 + time) * 0.3 + Math.cos(z * 2 + time * 0.7) * 0.2;
positions.setY(i, y);
}
positions.needsUpdate = true;
geometryRef.current.computeVertexNormals();
});
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry ref={geometryRef} args={[width, width, segments, segments]} />
<meshStandardMaterial color="steelblue" wireframe />
</mesh>
);
}
```
## InstancedMesh: High-Performance Rendering
`InstancedMesh` renders many copies of the same geometry with a single draw call. Each instance can have its own transform and color.
### Basic Instancing
```tsx
import { useRef, useEffect, useMemo } from 'react';
import { Object3D, Color, Matrix4 } from 'three';
import type { InstancedMesh } from 'three';
interface InstanceFieldProps {
count: number;
spread?: number;
}
export function InstanceField({ count, spread = 10 }: InstanceFieldProps) {
const meshRef = useRef<InstancedMesh>(null);
const dummy = useMemo(() => new Object3D(), []);
const color = useMemo(() => new Color(), []);
useEffect(() => {
if (!meshRef.current) return;
for (let i = 0; i < count; i++) {
dummy.position.set(
(Math.random() - 0.5) * spread,
Math.random() * 2,
(Math.random() - 0.5) * spread
);
dummy.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
0
);
dummy.scale.setScalar(0.2 + Math.random() * 0.8);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
color.setHSL(Math.random(), 0.7, 0.5);
meshRef.current.setColorAt(i, color);
}
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}, [count, spread, dummy, color]);
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial />
</instancedMesh>
);
}
```
### Animated Instances
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Object3D } from 'three';
import type { InstancedMesh } from 'three';
export function FloatingParticles({ count = 500 }: { count?: number }) {
const meshRef = useRef<InstancedMesh>(null);
const dummy = useMemo(() => new Object3D(), []);
const offsets = useMemo(() => {
return Array.from({ length: count }, () => ({
x: (Math.random() - 0.5) * 20,
y: Math.random() * 10,
z: (Math.random() - 0.5) * 20,
speed: 0.2 + Math.random() * 0.8,
phase: Math.random() * Math.PI * 2,
}));
}, [count]);
useFrame(({ clock }) => {
if (!meshRef.current) return;
const t = clock.elapsedTime;
for (let i = 0; i < count; i++) {
const { x, y, z, speed, phase } = offsets[i];
dummy.position.set(
x + Math.sin(t * speed + phase) * 0.5,
y + Math.sin(t * speed * 0.7 + phase) * 0.3,
z + Math.cos(t * speed + phase) * 0.5
);
dummy.scale.setScalar(0.05 + Math.sin(t * speed + phase) * 0.02);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<sphereGeometry args={[1, 8, 8]} />
<meshBasicMaterial color="white" transparent opacity={0.6} />
</instancedMesh>
);
}
```
## Drei Geometry Helpers
`@react-three/drei` provides higher-level geometry components:
### RoundedBox
```tsx
import { RoundedBox } from '@react-three/drei';
<RoundedBox args={[1, 1, 1]} radius={0.1} smoothness={4}>
<meshStandardMaterial color="royalblue" />
</RoundedBox>
```
### Text3D
```tsx
import { Text3D, Center } from '@react-three/drei';
<Center>
<Text3D
font="/fonts/helvetiker_regular.typeface.json"
size={0.5}
height={0.1}
bevelEnabled
bevelSize={0.02}
bevelThickness={0.01}
>
Hello R3F
<meshStandardMaterial color="gold" />
</Text3D>
</Center>
```
### Parametric Shapes
```tsx
import { Extrude, Lathe } from '@react-three/drei';
import { Shape, Vector2 } from 'three';
import { useMemo } from 'react';
export function CustomExtrusion() {
const shape = useMemo(() => {
const s = new Shape();
s.moveTo(0, 0);
s.lineTo(1, 0);
s.lineTo(1, 1);
s.lineTo(0.5, 1.5);
s.lineTo(0, 1);
s.closePath();
return s;
}, []);
return (
<Extrude args={[shape, { depth: 0.5, bevelEnabled: true, bevelSize: 0.05 }]}>
<meshStandardMaterial color="mediumpurple" />
</Extrude>
);
}
```
## Geometry Merging for Performance
Merge static geometries to reduce draw calls:
```tsx
import { useRef, useEffect } from 'react';
import { BoxGeometry, Matrix4 } from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import type { BufferGeometry } from 'three';
export function MergedBoxes({ positions }: { positions: [number, number, number][] }) {
const geometryRef = useRef<BufferGeometry>(null);
useEffect(() => {
const geometries = positions.map(([x, y, z]) => {
const box = new BoxGeometry(1, 1, 1);
box.applyMatrix4(new Matrix4().makeTranslation(x, y, z));
return box;
});
const merged = mergeGeometries(geometries, false);
if (merged && geometryRef.current) {
geometryRef.current.copy(merged);
geometries.forEach((g) => g.dispose());
merged.dispose();
}
}, [positions]);
return (
<mesh>
<bufferGeometry ref={geometryRef} />
<meshStandardMaterial color="sandybrown" />
</mesh>
);
}
```
## Geometry Bounding Volumes
Bounding volumes are essential for culling, collision detection, and raycasting:
```tsx
import { useRef, useEffect } from 'react';
import type { Mesh } from 'three';
export function BoundedMesh() {
const meshRef = useRef<Mesh>(null);
useEffect(() => {
if (!meshRef.current) return;
const geometry = meshRef.current.geometry;
// Compute bounding box
geometry.computeBoundingBox();
const box = geometry.boundingBox;
// box.min, box.max, box.getCenter(), box.getSize()
// Compute bounding sphere
geometry.computeBoundingSphere();
const sphere = geometry.boundingSphere;
// sphere.center, sphere.radius
}, []);
return (
<mesh ref={meshRef}>
<torusKnotGeometry args={[1, 0.3, 128, 32]} />
<meshStandardMaterial color="orchid" />
</mesh>
);
}
```
## Level of Detail (LOD)
Use `<Detailed>` from drei or Three.js LOD for distance-based geometry switching:
```tsx
import { Detailed } from '@react-three/drei';
export function LODObject() {
return (
<Detailed distances={[0, 10, 25, 50]}>
{/* Closest: high detail */}
<mesh><sphereGeometry args={[1, 64, 64]} /><meshStandardMaterial /></mesh>
{/* Medium distance */}
<mesh><sphereGeometry args={[1, 32, 32]} /><meshStandardMaterial /></mesh>
{/* Far distance */}
<mesh><sphereGeometry args={[1, 16, 16]} /><meshStandardMaterial /></mesh>
{/* Very far: billboard or point */}
<mesh><sphereGeometry args={[1, 8, 8]} /><meshStandardMaterial /></mesh>
</Detailed>
);
}
```
## Testing Geometry
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { BoxGeometry, SphereGeometry } from 'three';
describe('Geometry fundamentals', () => {
it('creates box with correct vertex count', () => {
const geom = new BoxGeometry(1, 1, 1);
expect(geom.attributes.position.count).toBe(24); // 6 faces x 4 vertices
});
it('creates indexed sphere', () => {
const geom = new SphereGeometry(1, 16, 16);
expect(geom.index).not.toBeNull();
expect(geom.attributes.position.count).toBeGreaterThan(0);
});
it('computes bounding box', () => {
const geom = new BoxGeometry(2, 3, 4);
geom.computeBoundingBox();
expect(geom.boundingBox!.max.x).toBeCloseTo(1);
expect(geom.boundingBox!.max.y).toBeCloseTo(1.5);
expect(geom.boundingBox!.max.z).toBeCloseTo(2);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { InstanceField } from './instance-field';
const meta: Meta<typeof InstanceField> = {
title: 'R3F/Geometry/InstanceField',
component: InstanceField,
decorators: [
(Story) => (
<Canvas camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Story />
<OrbitControls />
</Canvas>
),
],
};
export default meta;
export const Default: StoryObj<typeof InstanceField> = {
args: { count: 100, spread: 10 },
};
export const Dense: StoryObj<typeof InstanceField> = {
args: { count: 1000, spread: 5 },
};
```
## Checklist
When working with geometry in R3F:
1. Use built-in geometry JSX elements with `args` for standard shapes
2. Use `useMemo` for custom BufferGeometry data arrays
3. Set `needsUpdate = true` on attributes after modifying vertex data
4. Use InstancedMesh for rendering many identical objects (1 draw call)
5. Pre-allocate `Object3D` and `Color` objects outside `useFrame` for instancing
6. Merge static geometries with `mergeGeometries` to reduce draw calls
7. Compute bounding volumes after geometry modifications
8. Use LOD components for distance-based detail levels
9. Index geometry when vertices are shared between triangles
10. Keep geometry component files under 250 lines

View File

@@ -0,0 +1,487 @@
---
name: threejs-interaction
description: Three.js interaction and event handling adapted for React Three Fiber and Suaveplan R3F packages. Covers R3F pointer events (onClick, onPointerOver), raycasting via the R3F event system, @suaveplan/r3f-camera for orbit/first-person controls, drag interactions, hover effects, and input handling patterns.
---
This skill covers user interaction with 3D objects in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to make 3D objects interactive: clickable, hoverable, draggable, or responsive to pointer/touch input. They may need camera controls, raycasting, or input mapping.
## Core Concepts
### Three.js Raycasting
Three.js uses `Raycaster` to detect which objects a ray intersects. In vanilla Three.js this requires manual setup:
1. Convert mouse coordinates to normalized device coordinates (NDC)
2. Cast a ray from the camera through the NDC point
3. Test intersections against scene objects
4. Handle the intersection results
### R3F Event System
R3F abstracts all raycasting into React-style event handlers on mesh elements. No manual raycaster setup required.
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `raycaster.setFromCamera(mouse, camera)` | Automatic |
| `raycaster.intersectObjects(scene.children)` | Automatic per frame |
| Custom event dispatch | `onClick`, `onPointerOver`, etc. |
| `addEventListener('mousemove', ...)` | `onPointerMove` on mesh |
| Manual NDC conversion | Automatic coordinate mapping |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core interaction utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-camera` | Camera controls (orbit, FPS, TPS) | `import { OrbitCamera, FirstPersonCamera } from '@suaveplan/r3f-camera'` |
| `@suaveplan/r3f-booking` | Interactive 3D venue maps | `import { VenueMap } from '@suaveplan/r3f-booking'` |
## R3F Pointer Events
Every `<mesh>`, `<group>`, or `<primitive>` in R3F can receive pointer events. These work identically to React DOM events.
### Available Events
```tsx
<mesh
onClick={(e) => {}} // Click (pointerdown + pointerup)
onContextMenu={(e) => {}} // Right-click
onDoubleClick={(e) => {}} // Double-click
onPointerDown={(e) => {}} // Pointer pressed
onPointerUp={(e) => {}} // Pointer released
onPointerOver={(e) => {}} // Pointer enters object
onPointerOut={(e) => {}} // Pointer leaves object
onPointerEnter={(e) => {}} // Pointer enters (no bubbling)
onPointerLeave={(e) => {}} // Pointer leaves (no bubbling)
onPointerMove={(e) => {}} // Pointer moves over object
onPointerMissed={() => {}} // Click missed this object
onWheel={(e) => {}} // Scroll wheel
/>
```
### Event Object Properties
The R3F event object extends the Three.js intersection result:
```tsx
interface ThreeEvent {
// Three.js intersection data
object: Object3D; // The intersected object
point: Vector3; // World-space intersection point
distance: number; // Distance from camera
face: Face | null; // Intersected face
faceIndex: number; // Face index
uv: Vector2; // UV coordinates at intersection
uv1: Vector2; // Second UV set
// R3F additions
eventObject: Object3D; // The object with the event handler
camera: Camera; // Active camera
delta: number; // Distance moved (for click threshold)
ray: Ray; // The casting ray
intersections: Intersection[]; // All intersections along the ray
stopped: boolean; // Whether propagation was stopped
// DOM event
nativeEvent: PointerEvent; // Original DOM event
// Methods
stopPropagation(): void; // Stop event bubbling
}
```
### Basic Click Interaction
```tsx
import { useState, useCallback } from 'react';
import type { ThreeEvent } from '@react-three/fiber';
interface ClickableBoxProps {
position?: [number, number, number];
onSelect?: (point: [number, number, number]) => void;
}
export function ClickableBox({ position = [0, 0, 0], onSelect }: ClickableBoxProps) {
const [active, setActive] = useState(false);
const handleClick = useCallback((event: ThreeEvent<PointerEvent>) => {
event.stopPropagation(); // Prevent event from reaching objects behind
setActive((prev) => !prev);
const p = event.point;
onSelect?.([p.x, p.y, p.z]);
}, [onSelect]);
return (
<mesh position={position} onClick={handleClick} scale={active ? 1.2 : 1}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={active ? 'hotpink' : 'royalblue'} />
</mesh>
);
}
```
### Hover Effects
```tsx
import { useState, useCallback } from 'react';
export function HoverableSphere({ position }: { position: [number, number, number] }) {
const [hovered, setHovered] = useState(false);
const handlePointerOver = useCallback(() => {
setHovered(true);
document.body.style.cursor = 'pointer';
}, []);
const handlePointerOut = useCallback(() => {
setHovered(false);
document.body.style.cursor = 'auto';
}, []);
return (
<mesh
position={position}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
scale={hovered ? 1.1 : 1}
>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial
color={hovered ? '#ff6b6b' : '#4ecdc4'}
emissive={hovered ? '#ff6b6b' : '#000000'}
emissiveIntensity={hovered ? 0.3 : 0}
/>
</mesh>
);
}
```
## Event Propagation
R3F events bubble through the scene graph just like React DOM events. Objects closer to the camera receive events first.
### stopPropagation
```tsx
// The inner mesh receives the click first
// stopPropagation prevents the outer group from receiving it
<group onClick={() => console.log('group clicked')}>
<mesh onClick={(e) => {
e.stopPropagation();
console.log('mesh clicked'); // Only this fires
}}>
<boxGeometry />
<meshStandardMaterial />
</mesh>
</group>
```
### Event Filtering
Filter events based on intersection properties:
```tsx
<mesh onClick={(e) => {
// Only respond to front-face clicks
if (e.face && e.face.normal.z > 0) {
handleFrontClick(e.point);
}
}}>
<boxGeometry />
<meshStandardMaterial />
</mesh>
```
## Camera Controls
### @suaveplan/r3f-camera
The `@suaveplan/r3f-camera` package provides camera controllers optimized for different interaction modes:
```tsx
import { OrbitCamera } from '@suaveplan/r3f-camera';
import { Canvas } from '@react-three/fiber';
export function OrbitScene() {
return (
<Canvas camera={{ position: [5, 5, 5] }}>
<OrbitCamera
enableDamping
dampingFactor={0.05}
minDistance={2}
maxDistance={20}
minPolarAngle={0.1}
maxPolarAngle={Math.PI / 2 - 0.1}
/>
{/* scene content */}
</Canvas>
);
}
```
### First-Person Camera
```tsx
import { FirstPersonCamera } from '@suaveplan/r3f-camera';
export function FPSScene() {
return (
<Canvas>
<FirstPersonCamera
moveSpeed={5}
lookSpeed={0.002}
jumpForce={4}
gravity={9.8}
heightOffset={1.7}
/>
{/* scene content */}
</Canvas>
);
}
```
### Third-Person Camera
```tsx
import { ThirdPersonCamera } from '@suaveplan/r3f-camera';
import { useRef } from 'react';
import type { Group } from 'three';
export function TPSScene() {
const targetRef = useRef<Group>(null);
return (
<Canvas>
<ThirdPersonCamera
target={targetRef}
distance={5}
height={2}
smoothing={0.1}
/>
<group ref={targetRef} position={[0, 0, 0]}>
{/* Character mesh */}
</group>
</Canvas>
);
}
```
### Drei Camera Controls
For simpler use cases, `@react-three/drei` provides ready-made controls:
```tsx
import { OrbitControls, MapControls, FlyControls } from '@react-three/drei';
// Orbit: rotate around a target
<OrbitControls makeDefault />
// Map: pan/zoom (top-down views)
<MapControls makeDefault />
// Fly: free-flight movement
<FlyControls movementSpeed={5} rollSpeed={0.5} />
```
## Drag Interactions
### Basic Drag with useDrag
Use `@use-gesture/react`'s `useDrag` hook. In the drag callback, intersect the event ray with a `Plane` to constrain movement to a surface. Update mesh position via state.
### DragControls from Drei
Wrap draggable content in `<DragControls>` from drei. Use `onDragStart`/`onDragEnd` callbacks to update cursor style. Objects inside become draggable automatically.
## Raycasting: Imperative Escape Hatch
When you need custom raycasting beyond R3F events:
```tsx
import { useRef, useCallback } from 'react';
import { useThree } from '@react-three/fiber';
import { Raycaster, Vector2 } from 'three';
export function useCustomRaycast() {
const { camera, scene } = useThree();
const raycaster = useRef(new Raycaster());
const pointer = useRef(new Vector2());
const castRay = useCallback((clientX: number, clientY: number) => {
const canvas = document.querySelector('canvas');
if (!canvas) return [];
const rect = canvas.getBoundingClientRect();
pointer.current.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.current.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.current.setFromCamera(pointer.current, camera);
return raycaster.current.intersectObjects(scene.children, true);
}, [camera, scene]);
return { castRay };
}
```
### Raycasting Configuration
```tsx
// Limit ray distance
raycaster.far = 100;
raycaster.near = 0.1;
// Configure line threshold (for Line objects)
raycaster.params.Line.threshold = 0.1;
// Configure points threshold (for Points objects)
raycaster.params.Points.threshold = 0.1;
// Layer-based filtering
raycaster.layers.set(1); // Only intersect objects on layer 1
```
### Layer-Based Interaction Filtering
Use `meshRef.current.layers.enable(1)` in `useEffect` to place interactable objects on layer 1. Configure the raycaster to only intersect specific layers with `raycaster.layers.set(1)`.
## Selection Patterns
### Single Selection
Track `selectedId` in state. Each `SelectableItem` calls `onSelect(id)` on click. Toggle selection by comparing current vs previous ID. Apply visual feedback (color, emissive) based on `isSelected` prop.
### Multi-Selection with Shift-Click
Use `event.nativeEvent.shiftKey` to detect Shift held during click. Maintain a `Set<string>` of selected IDs. On shift-click, toggle the item in the existing set. Without shift, replace the entire selection.
## Touch and Mobile Interaction
R3F pointer events work with touch input automatically. Configure the `events` prop on Canvas to filter intersections (e.g., only closest) for touch optimization.
## Booking Venue Interaction
The `@suaveplan/r3f-booking` package provides interactive 3D venue maps with built-in seat selection, zone highlighting, and pricing display:
```tsx
import { VenueMap } from '@suaveplan/r3f-booking';
import { Canvas } from '@react-three/fiber';
import { OrbitCamera } from '@suaveplan/r3f-camera';
export function BookingScene() {
return (
<Canvas>
<OrbitCamera minDistance={5} maxDistance={50} />
<VenueMap
venueUrl="/models/venue.glb"
onSeatSelect={(seatId) => { /* handle booking */ }}
onZoneHover={(zoneId) => { /* show pricing */ }}
selectedSeats={['A1', 'A2']}
zoneColors={{
vip: '#ffd700',
standard: '#4a90d9',
economy: '#7cb342',
}}
/>
</Canvas>
);
}
```
## Performance Considerations
### Event Computation Cost
R3F raycasts on every pointer event by default. Optimize with:
```tsx
<Canvas
raycaster={{
// Increase threshold for easier touch targeting
params: { Line: { threshold: 5 }, Points: { threshold: 5 } },
}}
events={(store) => ({
...store,
// Only process closest intersection
filter: (intersections) => intersections.slice(0, 1),
})}
>
```
### Disabling Events on Non-Interactive Objects
```tsx
// Skip raycasting for decorative objects
<mesh raycast={() => null}>
<sphereGeometry />
<meshBasicMaterial />
</mesh>
// Or use a wrapper
function Decorative({ children }: { children: React.ReactNode }) {
return <group raycast={() => null}>{children}</group>;
}
```
## Testing Interactions
### Vitest Tests
```tsx
import { describe, it, expect, vi } from 'vitest';
describe('ClickableBox', () => {
it('calls onSelect with intersection point', () => {
const onSelect = vi.fn();
// Use @react-three/test-renderer for R3F component testing
// Verify callback receives correct coordinates
expect(onSelect).toBeDefined();
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { ClickableBox } from './clickable-box';
const meta: Meta<typeof ClickableBox> = {
title: 'R3F/Interaction/ClickableBox',
component: ClickableBox,
decorators: [
(Story) => (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Story />
<OrbitControls />
<gridHelper />
</Canvas>
),
],
};
export default meta;
export const Default: StoryObj<typeof ClickableBox> = {
args: { position: [0, 0.5, 0] },
};
```
## Checklist
When implementing interactions in R3F:
1. Use R3F pointer events (`onClick`, `onPointerOver`) instead of manual raycasting
2. Always call `event.stopPropagation()` to prevent unintended event bubbling
3. Set `document.body.style.cursor` on hover for visual feedback
4. Use `@suaveplan/r3f-camera` for camera controls (orbit, FPS, TPS)
5. Disable raycasting on decorative objects with `raycast={() => null}`
6. Handle both click and touch input (R3F normalizes automatically)
7. Use layers for raycasting categories (interactive vs. background)
8. Debounce expensive operations in `onPointerMove` handlers
9. Test interaction callbacks with Vitest mocks
10. Write Storybook stories with camera controls for interactive testing

View File

@@ -0,0 +1,513 @@
---
name: threejs-lighting
description: Three.js lighting systems adapted for React Three Fiber and Suaveplan R3F packages. Covers declarative light JSX components, shadow map configuration, environment maps with @react-three/drei, image-based lighting, light helpers, and @suaveplan/r3f-performance for light optimization and adaptive quality.
---
This skill covers lighting in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to light a 3D scene: configure light types, enable shadows, use environment maps for image-based lighting, or optimize light performance.
## Core Concepts
### Three.js Lighting Model
Three.js provides physically-based rendering (PBR) with several light types. Lights contribute to material shading based on the Lambertian/GGX BRDF model.
### R3F Lighting Approach
In R3F, lights are JSX elements added to the scene graph:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new AmbientLight(0xffffff, 0.5)` | `<ambientLight intensity={0.5} />` |
| `scene.add(light)` | Place inside `<Canvas>` tree |
| `light.position.set(5, 5, 5)` | `position={[5, 5, 5]}` prop |
| `light.castShadow = true` | `castShadow` prop |
| `light.shadow.mapSize.set(2048, 2048)` | `shadow-mapSize={[2048, 2048]}` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core scene utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Light budget monitoring, adaptive shadows | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
| `@suaveplan/r3f-shaders` | Custom light-reactive materials | `import { useAnimatedShader } from '@suaveplan/r3f-shaders'` |
## Light Types
### AmbientLight
Uniform light from all directions. No shadows. Use for base illumination.
```tsx
<ambientLight
color="#ffffff" // Light color
intensity={0.4} // Brightness (0-N, default 1)
/>
```
### DirectionalLight
Parallel rays from a direction (like the sun). Casts shadows.
```tsx
<directionalLight
position={[10, 10, 5]} // Direction = position - target
intensity={1.5}
color="#fffbe6"
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-10}
shadow-camera-right={10}
shadow-camera-top={10}
shadow-camera-bottom={-10}
shadow-camera-near={0.1}
shadow-camera-far={50}
shadow-bias={-0.0001}
/>
```
### PointLight
Emits light in all directions from a point (like a light bulb). Casts shadows.
```tsx
<pointLight
position={[0, 3, 0]}
intensity={100} // In candela (physical mode)
distance={10} // Maximum range (0 = infinite)
decay={2} // Physical falloff (2 = realistic)
color="#ffd700"
castShadow
shadow-mapSize={[1024, 1024]}
shadow-bias={-0.001}
/>
```
### SpotLight
Cone-shaped light (like a flashlight or stage light). Casts shadows.
```tsx
<spotLight
position={[0, 5, 0]}
target-position={[0, 0, 0]} // Aim point
angle={Math.PI / 6} // Cone angle (radians)
penumbra={0.5} // Soft edge (0-1)
intensity={200}
distance={20}
decay={2}
color="#ffffff"
castShadow
shadow-mapSize={[2048, 2048]}
shadow-bias={-0.0001}
/>
```
### HemisphereLight
Two-color gradient light (sky + ground). No shadows.
```tsx
<hemisphereLight
color="#87ceeb" // Sky color
groundColor="#8b4513" // Ground color
intensity={0.6}
/>
```
### RectAreaLight
Rectangular area light (like a window or fluorescent panel). No shadow map support.
```tsx
import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
// Must initialize uniforms before use
RectAreaLightUniformsLib.init();
<rectAreaLight
position={[0, 3, -2]}
rotation={[-Math.PI / 2, 0, 0]}
width={4}
height={2}
intensity={5}
color="#ffffff"
/>
```
## Shadow Configuration
### Enabling Shadows
Shadows require three things: Canvas shadow prop, light `castShadow`, and mesh `receiveShadow`/`castShadow`.
```tsx
import { Canvas } from '@react-three/fiber';
export function ShadowScene() {
return (
<Canvas
shadows // Enable shadow maps
// Or configure shadow type:
// shadows={{ type: PCFSoftShadowMap }}
>
{/* Light that casts shadows */}
<directionalLight
position={[5, 5, 5]}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-far={20}
shadow-bias={-0.0001}
/>
{/* Object that casts shadows */}
<mesh position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="tomato" />
</mesh>
{/* Ground that receives shadows */}
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial color="#444" />
</mesh>
</Canvas>
);
}
```
### Shadow Map Types
```tsx
import { PCFShadowMap, PCFSoftShadowMap, VSMShadowMap, BasicShadowMap } from 'three';
// BasicShadowMap: fastest, hard edges
// PCFShadowMap: default, some softness
// PCFSoftShadowMap: softer, more expensive
// VSMShadowMap: variance shadow map, supports blurring
<Canvas shadows={{ type: PCFSoftShadowMap }}>
```
### Shadow Camera Frustum
For directional lights, the shadow camera is orthographic. Size the frustum tightly around the scene for better shadow quality:
```tsx
<directionalLight
castShadow
shadow-camera-left={-8}
shadow-camera-right={8}
shadow-camera-top={8}
shadow-camera-bottom={-8}
shadow-camera-near={0.5}
shadow-camera-far={30}
/>
```
### Cascaded Shadow Maps (CSM)
For large scenes, use drei's CSM:
```tsx
import { AccumulativeShadows, RandomizedLight } from '@react-three/drei';
export function SoftShadows() {
return (
<AccumulativeShadows
temporal
frames={60}
alphaTest={0.85}
scale={10}
position={[0, 0.01, 0]}
>
<RandomizedLight
amount={8}
radius={2}
ambient={0.5}
position={[5, 5, -3]}
bias={0.001}
/>
</AccumulativeShadows>
);
}
```
## Environment Maps and IBL
### Environment Component (drei)
The simplest way to add image-based lighting:
```tsx
import { Environment } from '@react-three/drei';
// Preset environments
<Environment preset="sunset" />
<Environment preset="city" />
<Environment preset="forest" />
<Environment preset="studio" />
// Custom HDR
<Environment files="/textures/env.hdr" />
// As scene background
<Environment preset="sunset" background />
// Blur the background
<Environment preset="sunset" background blur={0.5} />
```
### Available Presets
| Preset | Description | Use Case |
|---|---|---|
| `apartment` | Indoor apartment | Interior scenes |
| `city` | Urban skyline | Outdoor urban |
| `dawn` | Early morning sky | Warm exterior |
| `forest` | Dense forest | Nature scenes |
| `lobby` | Hotel lobby | Indoor formal |
| `night` | Night sky | Dark scenes |
| `park` | Park setting | Outdoor casual |
| `studio` | Photography studio | Product shots |
| `sunset` | Golden hour | Dramatic exterior |
| `warehouse` | Industrial space | Gritty interior |
### Custom Environment Map
```tsx
import { useRef } from 'react';
import { useThree } from '@react-three/fiber';
import { CubeCamera } from '@react-three/drei';
export function ReflectiveSphere() {
return (
<CubeCamera resolution={256} frames={Infinity}>
{(texture) => (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial
envMap={texture}
metalness={1}
roughness={0}
/>
</mesh>
)}
</CubeCamera>
);
}
```
## Light Helpers
Visualize light positions and frustums during development:
```tsx
import { useHelper } from '@react-three/drei';
import { useRef } from 'react';
import {
DirectionalLightHelper,
SpotLightHelper,
PointLightHelper,
CameraHelper,
} from 'three';
import type { DirectionalLight, SpotLight, PointLight } from 'three';
export function DebugLights({ debug = false }: { debug?: boolean }) {
const dirRef = useRef<DirectionalLight>(null);
const spotRef = useRef<SpotLight>(null);
const pointRef = useRef<PointLight>(null);
// Helpers only rendered when debug is true
useHelper(debug && dirRef, DirectionalLightHelper, 1, 'red');
useHelper(debug && spotRef, SpotLightHelper, 'green');
useHelper(debug && pointRef, PointLightHelper, 0.5, 'blue');
return (
<>
<directionalLight ref={dirRef} position={[5, 5, 5]} castShadow />
<spotLight ref={spotRef} position={[0, 5, 0]} angle={Math.PI / 6} />
<pointLight ref={pointRef} position={[-3, 2, 0]} intensity={50} />
</>
);
}
```
## Lighting Recipes
### Outdoor Scene (Sun + Sky)
```tsx
import { Sky, Environment } from '@react-three/drei';
export function OutdoorLighting() {
return (
<>
{/* Sun light */}
<directionalLight
position={[50, 50, 25]}
intensity={2}
color="#fff5e1"
castShadow
shadow-mapSize={[4096, 4096]}
shadow-camera-left={-30}
shadow-camera-right={30}
shadow-camera-top={30}
shadow-camera-bottom={-30}
/>
{/* Fill light (sky) */}
<hemisphereLight
color="#87ceeb"
groundColor="#362312"
intensity={0.4}
/>
{/* Sky dome */}
<Sky
sunPosition={[50, 50, 25]}
turbidity={3}
rayleigh={0.5}
/>
</>
);
}
```
### Indoor Scene (Area Lights + Ambient)
```tsx
export function IndoorLighting() {
return (
<>
{/* Ambient base */}
<ambientLight intensity={0.2} color="#f0e6d3" />
{/* Ceiling lights */}
<pointLight position={[0, 2.8, 0]} intensity={80} decay={2} distance={8} castShadow />
<pointLight position={[3, 2.8, 0]} intensity={60} decay={2} distance={6} />
{/* Window light */}
<directionalLight
position={[-5, 3, 2]}
intensity={1.5}
color="#b8d4e3"
castShadow
shadow-mapSize={[2048, 2048]}
/>
</>
);
}
```
### Product Studio Lighting
Use `<Environment>` with nested `<Lightformer>` components for key, fill, and rim lighting. Add `<ContactShadows>` from drei for soft ground shadows without shadow maps. Configure `form="rect"` or `form="ring"`, position, intensity, and scale for each lightformer.
## Adaptive Lighting with @suaveplan/r3f-performance
Use `usePerformanceMonitor` to track FPS and switch between quality presets (high/medium/low). Each preset configures shadow map size (4096/2048/1024), env map resolution, and max light count. Scale down on `onDecline`, scale up on `onIncline`.
## Light Animation
### Flickering Light
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import type { PointLight } from 'three';
export function FlickeringLight({ position }: { position: [number, number, number] }) {
const lightRef = useRef<PointLight>(null);
useFrame(({ clock }) => {
if (!lightRef.current) return;
const t = clock.elapsedTime;
const flicker = Math.sin(t * 20) * 0.1 + Math.sin(t * 37) * 0.05 + Math.sin(t * 53) * 0.03;
lightRef.current.intensity = 80 + flicker * 40;
});
return (
<pointLight
ref={lightRef}
position={position}
color="#ff9500"
distance={8}
decay={2}
castShadow
/>
);
}
```
## Testing Lighting
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { DirectionalLight, PointLight } from 'three';
describe('Lighting setup', () => {
it('creates directional light with shadow', () => {
const light = new DirectionalLight(0xffffff, 1.5);
light.castShadow = true;
light.shadow.mapSize.set(2048, 2048);
expect(light.castShadow).toBe(true);
expect(light.shadow.mapSize.x).toBe(2048);
});
it('configures point light physical decay', () => {
const light = new PointLight(0xffd700, 100, 10, 2);
expect(light.decay).toBe(2);
expect(light.distance).toBe(10);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
function LightingDemo() {
return (
<Canvas shadows camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
<ambientLight intensity={0.3} />
<directionalLight position={[5, 5, 5]} castShadow />
<mesh castShadow position={[0, 0.5, 0]}>
<boxGeometry />
<meshStandardMaterial color="coral" />
</mesh>
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10]} />
<meshStandardMaterial color="#555" />
</mesh>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Lighting/ShadowDemo', component: LightingDemo };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When setting up lighting in R3F:
1. Enable `shadows` on `<Canvas>` and choose a shadow map type
2. Use `castShadow` on lights and objects, `receiveShadow` on surfaces
3. Size shadow camera frustum tightly around the visible scene
4. Set `shadow-bias` to prevent shadow acne (start with `-0.0001`)
5. Use `<Environment>` from drei for quick IBL and reflections
6. Use `useHelper` to visualize light positions during development
7. Limit active shadow-casting lights (expensive: 1 shadow map per light per frame)
8. Use `@suaveplan/r3f-performance` for adaptive shadow quality
9. Combine ambient/hemisphere with directional for natural-looking scenes
10. Keep lighting configuration components under 250 lines

View File

@@ -0,0 +1,519 @@
---
name: threejs-loaders
description: Three.js asset loading adapted for React Three Fiber and Suaveplan R3F packages. Covers useLoader, useGLTF, and useTexture hooks, React Suspense boundaries for async loading, preloading strategies, @suaveplan/r3f loading utilities, Draco/KTX2 mesh and texture compression, and progressive loading patterns.
---
This skill covers asset loading in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to load 3D models, textures, fonts, or other assets into an R3F scene. They may need GLTF models, compressed assets, preloading, or progress tracking.
## Core Concepts
### Three.js Loader System
Three.js provides loader classes for different asset types:
- **GLTFLoader**: Loads `.gltf`/`.glb` files (the primary 3D model format)
- **TextureLoader**: Loads images as GPU textures
- **CubeTextureLoader**: Loads cubemap textures (6 faces)
- **FBXLoader**: Loads FBX files
- **OBJLoader**: Loads OBJ files
- **DRACOLoader**: Decompresses Draco-compressed geometry
- **KTX2Loader**: Loads KTX2 GPU-compressed textures
- **EXRLoader/RGBELoader**: Loads HDR environment maps
### R3F Loading Approach
R3F replaces manual loader management with hooks and React Suspense:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new GLTFLoader().load(url, cb)` | `useGLTF(url)` + `<Suspense>` |
| `new TextureLoader().load(url, cb)` | `useTexture(url)` + `<Suspense>` |
| Manual progress tracking | `useProgress()` hook |
| Manual error handling | Error boundaries |
| Manual disposal | Automatic on unmount |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Loading utilities, asset management | `import { useAssetLoader } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-character` | Character model loading | `import { useCharacterModel } from '@suaveplan/r3f-character'` |
| `@suaveplan/r3f-performance` | Loading performance monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## useGLTF: Loading 3D Models
The primary way to load 3D models in R3F. Returns the full GLTF structure.
### Basic Model Loading
```tsx
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
interface ModelProps {
url: string;
position?: [number, number, number];
scale?: number;
}
function Model({ url, position = [0, 0, 0], scale = 1 }: ModelProps) {
const { scene } = useGLTF(url);
return (
<primitive
object={scene}
position={position}
scale={scale}
/>
);
}
export function ModelScene() {
return (
<Canvas>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Suspense fallback={<LoadingPlaceholder />}>
<Model url="/models/building.glb" />
</Suspense>
</Canvas>
);
}
function LoadingPlaceholder() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="gray" wireframe />
</mesh>
);
}
```
### GLTF Return Structure
```tsx
const gltf = useGLTF('/model.glb');
// gltf.scene - THREE.Group (the root scene)
// gltf.nodes - Named nodes (meshes, bones, empties)
// gltf.materials - Named materials
// gltf.animations - AnimationClip[] (for useAnimations)
// gltf.asset - GLTF metadata
```
### Accessing Named Nodes
GLTF files export named objects. Access them via `gltf.nodes`:
```tsx
import { useGLTF } from '@react-three/drei';
function Character({ url }: { url: string }) {
const { nodes, materials } = useGLTF(url);
return (
<group>
{/* Access specific meshes by name */}
<mesh
geometry={(nodes.Body as THREE.Mesh).geometry}
material={materials.Skin}
castShadow
/>
<mesh
geometry={(nodes.Eyes as THREE.Mesh).geometry}
material={materials.Eye}
/>
</group>
);
}
```
### GLTF with TypeScript (gltfjsx)
Use `gltfjsx` to generate typed React components from GLTF files:
```bash
# Generate typed component (do NOT run tsc, this outputs .tsx directly)
bunx gltfjsx model.glb --types --transform
```
Generated output pattern:
```tsx
import { useRef } from 'react';
import { useGLTF } from '@react-three/drei';
import type { Group, Mesh, MeshStandardMaterial } from 'three';
import type { GLTF } from 'three-stdlib';
interface GLTFResult extends GLTF {
nodes: {
Body: Mesh;
Head: Mesh;
};
materials: {
Skin: MeshStandardMaterial;
Hair: MeshStandardMaterial;
};
}
export function CharacterModel(props: JSX.IntrinsicElements['group']) {
const groupRef = useRef<Group>(null);
const { nodes, materials } = useGLTF('/character.glb') as GLTFResult;
return (
<group ref={groupRef} {...props} dispose={null}>
<mesh geometry={nodes.Body.geometry} material={materials.Skin} castShadow />
<mesh geometry={nodes.Head.geometry} material={materials.Hair} castShadow />
</group>
);
}
useGLTF.preload('/character.glb');
```
## Draco Compression
Draco compresses mesh geometry by 80-90%. R3F handles decompression automatically.
### Using Draco with useGLTF
```tsx
import { useGLTF } from '@react-three/drei';
function CompressedModel({ url }: { url: string }) {
// useGLTF auto-detects Draco and uses the bundled decoder
const { scene } = useGLTF(url, '/draco/');
// Second argument is the Draco decoder path
return <primitive object={scene} />;
}
// Default Draco decoder from drei CDN (no path needed usually)
function SimpleModel() {
const { scene } = useGLTF('/model-draco.glb');
return <primitive object={scene} />;
}
```
### Custom Draco Decoder Path
Pass a second argument to `useGLTF` for the Draco decoder path: `useGLTF('/model.glb', '/libs/draco/gltf/')`.
## KTX2 Texture Compression
KTX2 provides GPU-compressed textures (ASTC, ETC2, BC7) for smaller VRAM footprint. Load with `useLoader(KTX2Loader, url, (loader) => { loader.setTranscoderPath('/libs/basis/'); loader.detectSupport(gl); })`.
## useTexture: Loading Textures
```tsx
import { useTexture } from '@react-three/drei';
import { RepeatWrapping, SRGBColorSpace } from 'three';
function TexturedMesh() {
const texture = useTexture('/textures/wood.jpg');
// Configure texture properties
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
texture.colorSpace = SRGBColorSpace;
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}
```
### Loading Multiple Textures (PBR Set)
```tsx
import { useTexture } from '@react-three/drei';
function PBRMaterial() {
const textures = useTexture({
map: '/textures/brick/diffuse.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/displacement.jpg',
});
return (
<mesh>
<planeGeometry args={[4, 4, 64, 64]} />
<meshStandardMaterial
{...textures}
displacementScale={0.1}
/>
</mesh>
);
}
// Preload all textures
useTexture.preload({
map: '/textures/brick/diffuse.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/displacement.jpg',
});
```
## useLoader: Generic Loader Hook
For loaders not covered by drei helpers:
```tsx
import { useLoader } from '@react-three/fiber';
import { FBXLoader } from 'three-stdlib';
import { OBJLoader } from 'three-stdlib';
import { FontLoader } from 'three-stdlib';
// FBX model
function FBXModel({ url }: { url: string }) {
const fbx = useLoader(FBXLoader, url);
return <primitive object={fbx} scale={0.01} />;
}
// OBJ model
function OBJModel({ url }: { url: string }) {
const obj = useLoader(OBJLoader, url);
return <primitive object={obj} />;
}
// Font
function LoadedFont({ url }: { url: string }) {
const font = useLoader(FontLoader, url);
// Use with TextGeometry
return null;
}
```
### Loader with Extensions
Pass a configuration callback as the third argument to `useLoader`. Inside, attach `DRACOLoader` via `loader.setDRACOLoader(dracoLoader)` and `MeshoptDecoder` via `loader.setMeshoptDecoder(MeshoptDecoder)`.
## Suspense and Loading UI
### Loading Progress
```tsx
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useProgress, Html } from '@react-three/drei';
function Loader() {
const { progress, active, errors, item, loaded, total } = useProgress();
return (
<Html center>
<div style={{ color: 'white', fontSize: '1.5rem' }}>
{progress.toFixed(0)}% loaded
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>
{loaded}/{total} assets
</div>
</div>
</Html>
);
}
export function SceneWithProgress() {
return (
<Canvas>
<Suspense fallback={<Loader />}>
<HeavyScene />
</Suspense>
</Canvas>
);
}
```
### Nested Suspense Boundaries
Load critical assets first, then progressively load detail:
```tsx
import { Suspense } from 'react';
export function ProgressiveScene() {
return (
<Canvas>
{/* Critical: load immediately */}
<ambientLight />
<directionalLight position={[5, 5, 5]} />
{/* Tier 1: primary content */}
<Suspense fallback={<PlaceholderBuilding />}>
<Building url="/models/building.glb" />
{/* Tier 2: secondary content (loads after building) */}
<Suspense fallback={null}>
<Furniture url="/models/furniture.glb" />
{/* Tier 3: decorative (loads last) */}
<Suspense fallback={null}>
<Plants url="/models/plants.glb" />
<Decorations url="/models/decor.glb" />
</Suspense>
</Suspense>
</Suspense>
</Canvas>
);
}
```
## Preloading
Preload assets before they are needed (e.g., during a loading screen or on hover):
```tsx
import { useGLTF, useTexture } from '@react-three/drei';
// Preload at module level (loads immediately)
useGLTF.preload('/models/character.glb');
useGLTF.preload('/models/environment.glb');
useTexture.preload('/textures/ground.jpg');
// Conditional preloading (e.g., on hover or route change)
function PreloadOnHover({ modelUrl }: { modelUrl: string }) {
return (
<button
onMouseEnter={() => useGLTF.preload(modelUrl)}
onClick={() => { /* navigate to 3D view */ }}
>
View in 3D
</button>
);
}
```
### Preloading Multiple Assets
```tsx
import { useGLTF } from '@react-three/drei';
const MODEL_URLS = [
'/models/scene.glb',
'/models/character.glb',
'/models/props.glb',
];
// Preload all at once
MODEL_URLS.forEach(useGLTF.preload);
```
## Error Handling
### Error Boundaries for Loading Failures
Wrap `<Suspense>` in a React error boundary class. Override `getDerivedStateFromError` to catch loading failures. Render a fallback mesh (e.g., red wireframe box) when errors occur. Nest as `<ErrorBoundary><Suspense fallback={<Loader />}><Model /></Suspense></ErrorBoundary>`.
## Asset Optimization Guidelines
### Model Optimization
| Technique | Savings | Tool |
|---|---|---|
| Draco compression | 80-90% geometry | `gltf-transform draco` |
| Meshopt compression | 60-80% geometry | `gltf-transform meshopt` |
| Texture compression (KTX2) | 75% texture VRAM | `gltf-transform ktx2` |
| Mesh simplification | 50-90% triangles | `gltf-transform simplify` |
| Texture resize | Variable | `gltf-transform resize` |
| Remove unused data | Variable | `gltf-transform prune` |
### Pipeline Command
```bash
# Optimize a GLTF file (run via bun, not tsc)
bunx gltf-transform optimize input.glb output.glb \
--compress draco \
--texture-compress ktx2 \
--simplify-ratio 0.5
```
## Disposal
R3F automatically disposes of loaded assets when components unmount. For manual control:
```tsx
import { useGLTF } from '@react-three/drei';
function DisposableModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
// Pass dispose={null} to prevent auto-disposal (useful for shared assets)
return <primitive object={scene} dispose={null} />;
}
// Manually clear the cache
function clearModelCache(url: string) {
useGLTF.clear(url);
}
```
## Testing Loading
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
describe('Asset loading', () => {
it('preload function exists on useGLTF', async () => {
const { useGLTF } = await import('@react-three/drei');
expect(typeof useGLTF.preload).toBe('function');
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, useProgress, Html } from '@react-three/drei';
function LoaderUI() {
const { progress } = useProgress();
return <Html center><span>{progress.toFixed(0)}%</span></Html>;
}
function ModelStory() {
return (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<ambientLight />
<directionalLight position={[5, 5, 5]} />
<Suspense fallback={<LoaderUI />}>
{/* Model component here */}
</Suspense>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Loaders/ModelLoader', component: ModelStory };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When loading assets in R3F:
1. Use `useGLTF` for GLTF/GLB models (the standard 3D format)
2. Wrap loaded components in `<Suspense>` with a fallback
3. Use `useProgress` for loading progress UI
4. Preload assets with `useGLTF.preload()` at module level or on interaction
5. Use Draco compression for mesh geometry (80-90% savings)
6. Use KTX2 for GPU-compressed textures
7. Generate typed components with `gltfjsx --types`
8. Use nested Suspense for progressive loading (critical first)
9. Add error boundaries for loading failures
10. Keep loader component files under 250 lines

View File

@@ -0,0 +1,579 @@
---
name: threejs-materials
description: Three.js materials adapted for React Three Fiber and Suaveplan R3F packages. Covers declarative material props in JSX, physically-based rendering (PBR) with MeshStandardMaterial and MeshPhysicalMaterial, @suaveplan/r3f-shaders for custom materials, material refs and animation via useFrame, transparency, and material instancing.
---
This skill covers materials in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to configure material appearance for 3D objects: PBR properties, textures, transparency, custom shaders, or animated material properties.
## Core Concepts
### Three.js Material System
Materials define how surfaces respond to light. Three.js provides a hierarchy from basic to physically-based:
- **MeshBasicMaterial**: Unlit, flat color (no light interaction)
- **MeshLambertMaterial**: Diffuse shading only (fast, non-glossy)
- **MeshPhongMaterial**: Diffuse + specular highlights (legacy)
- **MeshStandardMaterial**: PBR with metalness/roughness workflow
- **MeshPhysicalMaterial**: Extended PBR (clearcoat, transmission, sheen, iridescence)
- **MeshToonMaterial**: Cel-shaded / cartoon look
- **ShaderMaterial**: Fully custom GLSL shaders
- **RawShaderMaterial**: Custom GLSL without Three.js built-in uniforms
### R3F Material Approach
In R3F, materials are JSX children of mesh elements:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new MeshStandardMaterial({ color: 'red' })` | `<meshStandardMaterial color="red" />` |
| `mesh.material = material` | `<mesh><meshStandardMaterial /></mesh>` |
| `material.metalness = 0.8` | `metalness={0.8}` prop |
| `material.needsUpdate = true` | Automatic on prop change |
| `material.dispose()` | Automatic on unmount |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-shaders` | Custom shader materials | `import { GlassMaterial, HologramMaterial } from '@suaveplan/r3f-shaders'` |
| `@suaveplan/r3f-performance` | Material LOD, adaptive quality | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## MeshStandardMaterial: The PBR Default
The standard PBR material using the metalness/roughness workflow. This should be the default choice for most objects.
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial
color="#e74c3c" // Base color (albedo)
metalness={0.0} // 0 = dielectric, 1 = metal
roughness={0.4} // 0 = mirror, 1 = matte
emissive="#000000" // Self-illumination color
emissiveIntensity={0} // Emissive strength
envMapIntensity={1} // Environment reflection strength
flatShading={false} // Faceted vs smooth normals
wireframe={false} // Wireframe rendering
side={0} // 0=Front, 1=Back, 2=Double
/>
</mesh>
```
### PBR with Texture Maps
```tsx
import { useTexture } from '@react-three/drei';
function PBRSurface() {
const textures = useTexture({
map: '/textures/metal/diffuse.jpg',
normalMap: '/textures/metal/normal.jpg',
roughnessMap: '/textures/metal/roughness.jpg',
metalnessMap: '/textures/metal/metalness.jpg',
aoMap: '/textures/metal/ao.jpg',
});
return (
<mesh>
<planeGeometry args={[4, 4]} />
<meshStandardMaterial
{...textures}
normalScale={[1, 1]}
aoMapIntensity={1}
/>
</mesh>
);
}
```
## MeshPhysicalMaterial: Advanced PBR
Extends `MeshStandardMaterial` with physically-accurate properties for glass, car paint, fabric, etc.
### Glass / Transmission
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#ffffff"
transmission={1} // 0 = opaque, 1 = fully transparent
thickness={0.5} // Refraction depth
roughness={0.05} // Surface roughness
ior={1.5} // Index of refraction (glass = 1.5)
chromaticAberration={0.02} // Color fringing
envMapIntensity={1}
/>
</mesh>
```
### Car Paint / Clearcoat
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#1a1a2e"
metalness={0.9}
roughness={0.1}
clearcoat={1} // Clearcoat layer strength
clearcoatRoughness={0.1} // Clearcoat roughness
reflectivity={1}
/>
</mesh>
```
### Fabric / Sheen
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#2d1b69"
roughness={0.8}
sheen={1} // Sheen intensity (velvet/fabric)
sheenColor="#8b5cf6" // Sheen tint color
sheenRoughness={0.3} // Sheen roughness
/>
</mesh>
```
### Iridescence
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#333333"
metalness={1}
roughness={0.2}
iridescence={1} // Iridescence intensity
iridescenceIOR={1.3} // Thin film IOR
iridescenceThicknessRange={[100, 400]} // nm range
/>
</mesh>
```
## Material Properties Reference
### Common Properties (All Materials)
| Property | Type | Default | Description |
|---|---|---|---|
| `color` | string/Color | `'#ffffff'` | Base color |
| `opacity` | number | `1` | Transparency (requires `transparent={true}`) |
| `transparent` | boolean | `false` | Enable transparency |
| `alphaTest` | number | `0` | Discard pixels below threshold |
| `side` | number | `FrontSide` | Which face to render (0/1/2) |
| `visible` | boolean | `true` | Whether to render |
| `depthTest` | boolean | `true` | Test against depth buffer |
| `depthWrite` | boolean | `true` | Write to depth buffer |
| `wireframe` | boolean | `false` | Render as wireframe |
| `toneMapped` | boolean | `true` | Apply tone mapping |
### MeshStandardMaterial Specific
| Property | Type | Default | Description |
|---|---|---|---|
| `metalness` | number | `0` | Metallic appearance (0-1) |
| `roughness` | number | `1` | Surface roughness (0-1) |
| `emissive` | string/Color | `'#000000'` | Self-illumination color |
| `emissiveIntensity` | number | `1` | Emissive strength multiplier |
| `envMapIntensity` | number | `1` | Environment map reflection strength |
| `flatShading` | boolean | `false` | Use face normals vs vertex normals |
| `normalScale` | [x, y] | `[1, 1]` | Normal map strength |
| `map` | Texture | `null` | Albedo/diffuse texture |
| `normalMap` | Texture | `null` | Normal map texture |
| `roughnessMap` | Texture | `null` | Roughness texture (green channel) |
| `metalnessMap` | Texture | `null` | Metalness texture (blue channel) |
| `aoMap` | Texture | `null` | Ambient occlusion map |
| `displacementMap` | Texture | `null` | Displacement/height map |
| `emissiveMap` | Texture | `null` | Emissive map |
## Animated Materials
### Animating Properties with useFrame
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { MathUtils } from 'three';
import type { MeshStandardMaterial } from 'three';
export function PulsingMaterial() {
const materialRef = useRef<MeshStandardMaterial>(null);
useFrame(({ clock }) => {
if (!materialRef.current) return;
const t = clock.elapsedTime;
materialRef.current.emissiveIntensity = (Math.sin(t * 2) + 1) * 0.5;
materialRef.current.roughness = MathUtils.lerp(0.1, 0.9, (Math.sin(t) + 1) * 0.5);
});
return (
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial
ref={materialRef}
color="#4ecdc4"
emissive="#4ecdc4"
emissiveIntensity={0}
metalness={0.5}
/>
</mesh>
);
}
```
### Color Transitions
```tsx
import { useRef, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { Color } from 'three';
import type { MeshStandardMaterial } from 'three';
interface ColorTransitionProps {
targetColor: string;
speed?: number;
}
export function ColorTransition({ targetColor, speed = 3 }: ColorTransitionProps) {
const materialRef = useRef<MeshStandardMaterial>(null);
const target = useRef(new Color(targetColor));
useEffect(() => {
target.current.set(targetColor);
}, [targetColor]);
useFrame((_state, delta) => {
if (!materialRef.current) return;
materialRef.current.color.lerp(target.current, Math.min(delta * speed, 1));
});
return (
<mesh>
<boxGeometry />
<meshStandardMaterial ref={materialRef} color={targetColor} />
</mesh>
);
}
```
## @suaveplan/r3f-shaders Custom Materials
The `@suaveplan/r3f-shaders` package provides pre-built custom materials:
```tsx
import { GlassMaterial, HologramMaterial, NoiseMaterial } from '@suaveplan/r3f-shaders';
// Glass with custom refraction
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<GlassMaterial
color="#88ccff"
ior={1.45}
thickness={0.3}
distortion={0.1}
/>
</mesh>
// Hologram effect
<mesh>
<boxGeometry args={[1, 2, 1]} />
<HologramMaterial
color="#00ff88"
scanlineSpeed={2}
fresnelIntensity={1.5}
glitchIntensity={0.3}
/>
</mesh>
// Animated noise
<mesh>
<planeGeometry args={[4, 4, 64, 64]} />
<NoiseMaterial
color="#4a90d9"
scale={3}
speed={0.5}
displacement={0.2}
/>
</mesh>
```
## Material Sharing and Instancing
### Shared Material Across Meshes
```tsx
import { useMemo } from 'react';
import { MeshStandardMaterial } from 'three';
export function SharedMaterialScene() {
const sharedMaterial = useMemo(
() => new MeshStandardMaterial({ color: 'tomato', roughness: 0.3 }),
[]
);
return (
<>
<mesh position={[-2, 0, 0]} material={sharedMaterial}>
<boxGeometry />
</mesh>
<mesh position={[0, 0, 0]} material={sharedMaterial}>
<sphereGeometry args={[0.5, 32, 32]} />
</mesh>
<mesh position={[2, 0, 0]} material={sharedMaterial}>
<cylinderGeometry args={[0.5, 0.5, 1]} />
</mesh>
</>
);
}
```
### Material Variants
```tsx
import { useMemo } from 'react';
import { MeshStandardMaterial, Color } from 'three';
type MaterialVariant = 'default' | 'hover' | 'selected' | 'disabled';
function useMaterialVariants(): Record<MaterialVariant, MeshStandardMaterial> {
return useMemo(() => ({
default: new MeshStandardMaterial({ color: new Color('#6b7280'), roughness: 0.5 }),
hover: new MeshStandardMaterial({ color: new Color('#3b82f6'), roughness: 0.3 }),
selected: new MeshStandardMaterial({
color: new Color('#10b981'),
emissive: new Color('#10b981'),
emissiveIntensity: 0.2,
roughness: 0.3,
}),
disabled: new MeshStandardMaterial({
color: new Color('#9ca3af'),
roughness: 0.8,
transparent: true,
opacity: 0.5,
}),
}), []);
}
```
## Transparency Patterns
### Basic Transparency
```tsx
<meshStandardMaterial
color="white"
transparent
opacity={0.5}
/>
```
### Alpha Map Transparency
```tsx
import { useTexture } from '@react-three/drei';
function AlphaMasked() {
const alphaMap = useTexture('/textures/leaf-alpha.png');
return (
<mesh>
<planeGeometry args={[1, 1]} />
<meshStandardMaterial
color="green"
alphaMap={alphaMap}
transparent
alphaTest={0.5} // Discard pixels below 50% alpha
side={2} // DoubleSide for leaves
/>
</mesh>
);
}
```
### Sorting and Depth Issues
Transparent objects must render back-to-front. Control with `renderOrder` and `depthWrite`:
```tsx
{/* Render behind other transparent objects */}
<mesh renderOrder={-1}>
<meshStandardMaterial transparent opacity={0.3} depthWrite={false} />
</mesh>
{/* Render in front */}
<mesh renderOrder={1}>
<meshStandardMaterial transparent opacity={0.6} depthWrite={false} />
</mesh>
```
## Special Material Types
### MeshBasicMaterial (Unlit)
Use for UI elements, wireframes, or objects that should not be affected by lighting:
```tsx
<meshBasicMaterial color="white" /> // Flat color, no lighting
<meshBasicMaterial map={texture} /> // Textured, no lighting
<meshBasicMaterial wireframe color="cyan" /> // Wireframe
```
### MeshToonMaterial (Cel Shading)
```tsx
import { useTexture } from '@react-three/drei';
function ToonObject() {
// 3-step gradient ramp texture (3x1 pixels: dark, medium, light)
const gradientMap = useTexture('/textures/threeTone.jpg');
gradientMap.minFilter = gradientMap.magFilter = 1003; // NearestFilter
return (
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshToonMaterial color="#ff6b6b" gradientMap={gradientMap} />
</mesh>
);
}
```
### MeshMatcapMaterial
Fast, no lights needed. Appearance defined entirely by a matcap texture:
```tsx
import { useTexture } from '@react-three/drei';
function MatcapObject() {
const matcap = useTexture('/textures/matcap-gold.png');
return (
<mesh>
<torusKnotGeometry args={[1, 0.3, 128, 32]} />
<meshMatcapMaterial matcap={matcap} />
</mesh>
);
}
```
## Adaptive Material Quality
```tsx
import { useState } from 'react';
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
export function AdaptiveMaterial() {
const [quality, setQuality] = useState<'high' | 'low'>('high');
usePerformanceMonitor({
onDecline: () => setQuality('low'),
onIncline: () => setQuality('high'),
});
return (
<mesh>
<sphereGeometry args={[1, quality === 'high' ? 64 : 16, quality === 'high' ? 64 : 16]} />
{quality === 'high' ? (
<meshPhysicalMaterial
color="#4ecdc4"
metalness={0.8}
roughness={0.2}
clearcoat={1}
clearcoatRoughness={0.1}
/>
) : (
<meshStandardMaterial
color="#4ecdc4"
metalness={0.8}
roughness={0.2}
/>
)}
</mesh>
);
}
```
## Testing Materials
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { MeshStandardMaterial, MeshPhysicalMaterial, Color } from 'three';
describe('Material configuration', () => {
it('creates PBR material with correct defaults', () => {
const mat = new MeshStandardMaterial({ color: '#ff0000', metalness: 0.8 });
expect(mat.metalness).toBe(0.8);
expect(mat.color.getHexString()).toBe('ff0000');
});
it('configures glass material', () => {
const mat = new MeshPhysicalMaterial({ transmission: 1, ior: 1.5 });
expect(mat.transmission).toBe(1);
expect(mat.ior).toBe(1.5);
});
it('disposes material correctly', () => {
const mat = new MeshStandardMaterial();
mat.dispose();
expect(mat.disposed).toBeDefined();
});
});
```
### Storybook Material Gallery
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
function MaterialGallery() {
const materials = [
{ name: 'Metal', props: { metalness: 1, roughness: 0.1, color: '#888' } },
{ name: 'Plastic', props: { metalness: 0, roughness: 0.3, color: '#e74c3c' } },
{ name: 'Rubber', props: { metalness: 0, roughness: 0.9, color: '#2c3e50' } },
];
return (
<Canvas camera={{ position: [0, 0, 6] }} style={{ height: 400 }}>
<Environment preset="studio" />
{materials.map((m, i) => (
<mesh key={m.name} position={[(i - 1) * 2.5, 0, 0]}>
<sphereGeometry args={[0.8, 64, 64]} />
<meshStandardMaterial {...m.props} />
</mesh>
))}
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Materials/Gallery', component: MaterialGallery };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When configuring materials in R3F:
1. Use `meshStandardMaterial` as the default PBR material
2. Use `meshPhysicalMaterial` only when clearcoat, transmission, sheen, or iridescence is needed
3. Share materials with `useMemo` to avoid creating duplicates per mesh
4. Set `transparent={true}` before using `opacity` or `alphaTest`
5. Use `depthWrite={false}` on transparent materials to prevent sorting artifacts
6. Animate material properties via refs in `useFrame` (not re-rendering)
7. Use `@suaveplan/r3f-shaders` for pre-built custom materials
8. Use `@suaveplan/r3f-performance` for adaptive material quality
9. Load texture maps with `useTexture` inside `<Suspense>`
10. Keep material configuration components under 250 lines

View File

@@ -0,0 +1,576 @@
---
name: threejs-postprocessing
description: Three.js post-processing effects adapted for React Three Fiber and Suaveplan R3F packages. Covers @react-three/postprocessing EffectComposer, bloom, SSAO, depth-of-field, vignette, chromatic aberration, custom effects, render passes, and @suaveplan/r3f-performance for monitoring post-processing cost.
---
This skill covers post-processing in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to add screen-space visual effects to a 3D scene: bloom, ambient occlusion, depth of field, color grading, or custom post-processing passes.
## Core Concepts
### Three.js Post-Processing Pipeline
Post-processing applies full-screen image effects after the scene is rendered:
1. Scene renders to a framebuffer (render target) instead of the screen
2. The framebuffer texture passes through a chain of shader passes (effects)
3. The final result renders to the screen
### R3F Post-Processing Libraries
Two libraries exist for R3F post-processing:
| Library | Approach | Performance | Recommendation |
|---|---|---|---|
| `@react-three/postprocessing` | Effect merging (pmndrs/postprocessing) | Faster (fewer draw calls) | **Use this** |
| `three/examples/jsm/postprocessing` | Pass-based (EffectComposer) | Slower (1 pass per effect) | Legacy only |
`@react-three/postprocessing` merges compatible effects into a single shader pass, reducing GPU overhead significantly.
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core scene utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Effect budget monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
| `@suaveplan/r3f-shaders` | Custom effect shaders | `import { useAnimatedShader } from '@suaveplan/r3f-shaders'` |
## EffectComposer Setup
The `<EffectComposer>` component wraps all post-processing effects:
```tsx
import { Canvas } from '@react-three/fiber';
import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing';
export function PostProcessedScene() {
return (
<Canvas>
<ambientLight />
<directionalLight position={[5, 5, 5]} />
<mesh>
<boxGeometry />
<meshStandardMaterial color="tomato" />
</mesh>
<EffectComposer>
<Bloom intensity={0.5} luminanceThreshold={0.8} />
<Vignette darkness={0.5} offset={0.3} />
</EffectComposer>
</Canvas>
);
}
```
### EffectComposer Props
```tsx
<EffectComposer
enabled={true} // Toggle all effects
depthBuffer={true} // Enable depth buffer (needed for DOF, SSAO)
stencilBuffer={false} // Enable stencil buffer
autoClear={true} // Clear before render
multisampling={8} // MSAA samples (0 to disable)
frameBufferType={1016} // HalfFloatType for HDR
>
{/* Effects here */}
</EffectComposer>
```
## Bloom
Adds glow to bright areas. Pairs with emissive materials for neon/light effects.
```tsx
import { Bloom } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<EffectComposer>
<Bloom
intensity={1.0} // Bloom strength
luminanceThreshold={0.9} // Brightness cutoff (0-1)
luminanceSmoothing={0.025} // Softness of the threshold
mipmapBlur={true} // Use mipmap blur (better quality)
radius={0.8} // Blur radius
blendFunction={BlendFunction.ADD} // How to composite
/>
</EffectComposer>
```
### Selective Bloom with Emissive Materials
Only emissive objects produce bloom when `luminanceThreshold` is high:
```tsx
export function NeonScene() {
return (
<>
{/* This glows (emissive > threshold) */}
<mesh position={[0, 1, 0]}>
<sphereGeometry args={[0.3, 32, 32]} />
<meshStandardMaterial
color="#ff0080"
emissive="#ff0080"
emissiveIntensity={5}
toneMapped={false} // Allow values > 1 for HDR bloom
/>
</mesh>
{/* This does not glow (no emissive) */}
<mesh>
<boxGeometry />
<meshStandardMaterial color="gray" />
</mesh>
<EffectComposer>
<Bloom
luminanceThreshold={1}
intensity={1.5}
mipmapBlur
/>
</EffectComposer>
</>
);
}
```
## Screen-Space Ambient Occlusion (SSAO)
Adds soft shadows in crevices and corners. Provides depth and realism.
```tsx
import { SSAO } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<EffectComposer>
<SSAO
blendFunction={BlendFunction.MULTIPLY}
samples={30} // Quality (more = slower)
radius={5} // AO radius in world units
intensity={30} // AO intensity
luminanceInfluence={0.6} // Bright areas occlude less
bias={0.035} // Depth bias
worldDistanceThreshold={20}
worldDistanceFalloff={5}
worldProximityThreshold={0.5}
worldProximityFalloff={0.3}
/>
</EffectComposer>
```
### N8AO (Alternative SSAO)
Higher quality SSAO from drei:
```tsx
import { N8AO } from '@react-three/postprocessing';
<EffectComposer>
<N8AO
aoRadius={1}
intensity={2}
distanceFalloff={1}
color="black"
quality="medium" // 'low' | 'medium' | 'high' | 'ultra'
/>
</EffectComposer>
```
## Depth of Field (DOF)
Simulates camera lens focus with bokeh blur on out-of-focus areas.
```tsx
import { DepthOfField } from '@react-three/postprocessing';
<EffectComposer>
<DepthOfField
focusDistance={0} // Normalized focus distance (0-1)
focalLength={0.02} // Focal length (affects DOF range)
bokehScale={6} // Bokeh size
height={480} // Resolution of the effect
/>
</EffectComposer>
```
### Auto-Focus DOF
Focus on what the user is looking at:
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { DepthOfField } from '@react-three/postprocessing';
import { Vector3 } from 'three';
export function AutoFocusDOF() {
const depthRef = useRef<any>(null);
const focusPoint = useRef(new Vector3());
useFrame(({ camera, raycaster, pointer, scene }) => {
if (!depthRef.current) return;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(scene.children, true);
if (hits.length > 0) {
const distance = hits[0].distance;
const normalizedDistance = distance / camera.far;
depthRef.current.focusDistance = normalizedDistance;
}
});
return (
<EffectComposer>
<DepthOfField
ref={depthRef}
focusDistance={0.01}
focalLength={0.02}
bokehScale={4}
/>
</EffectComposer>
);
}
```
## Color Grading and Tone
### Tone Mapping
```tsx
import { ToneMapping } from '@react-three/postprocessing';
import { ToneMappingMode } from 'postprocessing';
<EffectComposer>
<ToneMapping
mode={ToneMappingMode.ACES_FILMIC}
// Options: LINEAR, REINHARD, REINHARD2, REINHARD2_ADAPTIVE,
// UNCHARTED2, OPTIMIZED_CINEON, ACES_FILMIC, AGX, NEUTRAL
/>
</EffectComposer>
```
### Brightness/Contrast
```tsx
import { BrightnessContrast } from '@react-three/postprocessing';
<EffectComposer>
<BrightnessContrast brightness={0.05} contrast={0.1} />
</EffectComposer>
```
### Hue/Saturation
```tsx
import { HueSaturation } from '@react-three/postprocessing';
<EffectComposer>
<HueSaturation hue={0} saturation={0.15} />
</EffectComposer>
```
### LUT (Lookup Table) Color Grading
```tsx
import { LUT3D } from '@react-three/postprocessing';
import { useLoader } from '@react-three/fiber';
import { LUTCubeLoader } from 'postprocessing';
function CinematicGrade() {
const texture = useLoader(LUTCubeLoader, '/luts/cinematic.cube');
return (
<EffectComposer>
<LUT3D lut={texture} />
</EffectComposer>
);
}
```
## Screen Effects
### Vignette
```tsx
import { Vignette } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<Vignette
offset={0.3} // Inner radius
darkness={0.7} // Edge darkness
blendFunction={BlendFunction.NORMAL}
/>
```
### Chromatic Aberration
```tsx
import { ChromaticAberration } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
import { Vector2 } from 'three';
<ChromaticAberration
offset={new Vector2(0.002, 0.002)} // R/G/B channel offset
blendFunction={BlendFunction.NORMAL}
radialModulation={true} // Stronger at edges
modulationOffset={0.5}
/>
```
### Noise / Film Grain
```tsx
import { Noise } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<Noise
premultiply // Multiply with scene color
blendFunction={BlendFunction.ADD}
opacity={0.5}
/>
```
### Scanlines
```tsx
import { Scanline } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<Scanline
density={1.5}
blendFunction={BlendFunction.OVERLAY}
opacity={0.1}
/>
```
## Effect Combinations: Recipes
### Cinematic Look
```tsx
<EffectComposer>
<Bloom luminanceThreshold={0.8} intensity={0.5} mipmapBlur />
<DepthOfField focusDistance={0.01} focalLength={0.02} bokehScale={3} />
<Vignette offset={0.3} darkness={0.6} />
<HueSaturation saturation={-0.1} />
<BrightnessContrast contrast={0.05} />
<Noise opacity={0.03} premultiply />
</EffectComposer>
```
### Neon / Cyberpunk
```tsx
<EffectComposer>
<Bloom luminanceThreshold={0.5} intensity={2} mipmapBlur radius={0.9} />
<ChromaticAberration offset={new Vector2(0.003, 0.003)} radialModulation />
<Scanline density={1.2} opacity={0.05} />
<Noise opacity={0.04} premultiply />
<Vignette darkness={0.8} />
</EffectComposer>
```
### Clean Product Visualization
```tsx
<EffectComposer multisampling={8}>
<N8AO aoRadius={0.5} intensity={1} quality="high" />
<Bloom luminanceThreshold={1.2} intensity={0.3} mipmapBlur />
<ToneMapping mode={ToneMappingMode.ACES_FILMIC} />
</EffectComposer>
```
## Custom Effects
### Writing a Custom Effect
```tsx
import { forwardRef, useMemo } from 'react';
import { Effect, BlendFunction } from 'postprocessing';
import { Uniform } from 'three';
const fragmentShader = /* glsl */ `
uniform float intensity;
uniform float time;
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 center = uv - 0.5;
float dist = length(center);
float wave = sin(dist * 20.0 - time * 3.0) * intensity;
vec2 distortedUv = uv + center * wave * 0.02;
outputColor = texture2D(inputBuffer, distortedUv);
}
`;
class RippleEffectImpl extends Effect {
constructor({ intensity = 0.5, blendFunction = BlendFunction.NORMAL } = {}) {
super('RippleEffect', fragmentShader, {
blendFunction,
uniforms: new Map([
['intensity', new Uniform(intensity)],
['time', new Uniform(0)],
]),
});
}
update(_renderer: any, _inputBuffer: any, deltaTime: number) {
const time = this.uniforms.get('time')!;
time.value += deltaTime;
}
}
export const RippleEffect = forwardRef<RippleEffectImpl, { intensity?: number }>(
({ intensity = 0.5 }, ref) => {
const effect = useMemo(() => new RippleEffectImpl({ intensity }), [intensity]);
return <primitive ref={ref} object={effect} dispose={null} />;
}
);
// Usage:
// <EffectComposer>
// <RippleEffect intensity={0.3} />
// </EffectComposer>
```
## Adaptive Post-Processing
Use `@suaveplan/r3f-performance` to scale effects based on frame rate:
```tsx
import { useState, useCallback } from 'react';
import { EffectComposer, Bloom, SSAO, Vignette } from '@react-three/postprocessing';
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
type EffectQuality = 'high' | 'medium' | 'low' | 'off';
export function AdaptiveEffects() {
const [quality, setQuality] = useState<EffectQuality>('high');
usePerformanceMonitor({
onDecline: ({ fps }) => {
if (fps < 20) setQuality('off');
else if (fps < 30) setQuality('low');
else if (fps < 45) setQuality('medium');
},
onIncline: ({ fps }) => {
if (fps > 55) setQuality('high');
else if (fps > 45) setQuality('medium');
else if (fps > 30) setQuality('low');
},
});
if (quality === 'off') return null;
return (
<EffectComposer multisampling={quality === 'high' ? 8 : quality === 'medium' ? 4 : 0}>
<Bloom
intensity={quality === 'high' ? 1 : 0.5}
luminanceThreshold={0.8}
mipmapBlur
/>
{quality !== 'low' && (
<SSAO
samples={quality === 'high' ? 30 : 15}
radius={5}
intensity={20}
/>
)}
<Vignette darkness={0.5} />
</EffectComposer>
);
}
```
## Performance Guidelines
### Effect Cost Hierarchy (Approximate)
| Effect | Cost | Notes |
|---|---|---|
| Vignette | Very Low | Single texture lookup |
| Noise | Very Low | Math-only |
| BrightnessContrast | Very Low | Per-pixel math |
| HueSaturation | Low | Color space conversion |
| ChromaticAberration | Low | 3 texture lookups |
| Bloom (mipmap) | Medium | Multi-pass blur |
| DOF | Medium-High | Multi-sample blur |
| SSAO | High | Many depth samples |
| N8AO (ultra) | Very High | Many samples + blur |
### Best Practices
1. Merge effects into a single `<EffectComposer>` (auto-merged into fewer passes)
2. Use `mipmapBlur` for Bloom (faster than kernel blur)
3. Reduce `samples` count on SSAO for mobile
4. Use `multisampling={0}` on mobile devices
5. Disable effects entirely when FPS drops below threshold
6. Use `HalfFloatType` framebuffer for HDR bloom
## Testing Post-Processing
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
describe('Post-processing setup', () => {
it('imports effects without errors', async () => {
const { Bloom, Vignette, SSAO } = await import('@react-three/postprocessing');
expect(Bloom).toBeDefined();
expect(Vignette).toBeDefined();
expect(SSAO).toBeDefined();
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing';
function BloomDemo() {
return (
<Canvas style={{ height: 400 }}>
<Environment preset="studio" />
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial
color="#ff0080"
emissive="#ff0080"
emissiveIntensity={3}
toneMapped={false}
/>
</mesh>
<EffectComposer>
<Bloom luminanceThreshold={0.8} intensity={1.5} mipmapBlur />
<Vignette darkness={0.5} />
</EffectComposer>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/PostProcessing/Bloom', component: BloomDemo };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When adding post-processing to R3F:
1. Use `@react-three/postprocessing` (effect merging) over Three.js pass-based
2. Place `<EffectComposer>` as a sibling to scene content, not wrapping it
3. Enable `depthBuffer` for effects that need depth (DOF, SSAO)
4. Use `toneMapped={false}` on emissive materials for HDR bloom
5. Set `luminanceThreshold` high to prevent everything from blooming
6. Start with low SSAO samples and increase to taste
7. Implement adaptive quality with `@suaveplan/r3f-performance`
8. Profile with browser DevTools GPU panel to measure effect cost
9. Disable all effects as a fallback for low-end devices
10. Keep post-processing configuration under 250 lines per file

View File

@@ -0,0 +1,572 @@
---
name: threejs-shaders
description: Three.js shader programming adapted for React Three Fiber and Suaveplan R3F packages. Covers shaderMaterial in JSX, the @suaveplan/r3f-shaders library, GLSL vertex and fragment shaders with React refs, uniform animation via useFrame, the extend() pattern for custom JSX elements, and onBeforeCompile for material patching.
---
This skill covers custom shader development in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to write custom GLSL shaders for visual effects, procedural materials, particle systems, or compute-like operations in the GPU.
## Core Concepts
### Three.js Shader Pipeline
Three.js shaders run on the GPU as GLSL programs:
- **Vertex Shader**: Transforms vertex positions (runs per vertex)
- **Fragment Shader**: Determines pixel color (runs per fragment/pixel)
- **Uniforms**: Values passed from JavaScript to GLSL (same for all vertices/fragments)
- **Attributes**: Per-vertex data (position, normal, UV)
- **Varyings**: Values interpolated from vertex to fragment shader
### R3F Shader Approach
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new ShaderMaterial({ uniforms, vertexShader, fragmentShader })` | `<shaderMaterial>` JSX or `extend()` |
| `material.uniforms.time.value = t` | Ref + `useFrame` |
| `new RawShaderMaterial(...)` | `<rawShaderMaterial>` |
| Custom class extending Material | `extend({ CustomMaterial })` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f-shaders` | Pre-built shader materials and utilities | `import { GlassMaterial, HologramMaterial, useShaderUniforms } from '@suaveplan/r3f-shaders'` |
| `@suaveplan/r3f` | Core R3F utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Shader performance monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## ShaderMaterial in JSX
### Basic Custom Shader
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { ShaderMaterial, Color } from 'three';
const vertexShader = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormal;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = /* glsl */ `
uniform float uTime;
uniform vec3 uColor;
varying vec2 vUv;
varying vec3 vNormal;
void main() {
float stripe = sin(vUv.y * 20.0 + uTime * 2.0) * 0.5 + 0.5;
vec3 color = mix(uColor, uColor * 0.3, step(0.5, stripe));
float fresnel = pow(1.0 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
color += fresnel * 0.3;
gl_FragColor = vec4(color, 1.0);
}
`;
export function StripedMaterial({ color = '#4ecdc4' }: { color?: string }) {
const materialRef = useRef<ShaderMaterial>(null);
const uniforms = useMemo(
() => ({
uTime: { value: 0 },
uColor: { value: new Color(color) },
}),
[]
);
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uniforms.uTime.value = clock.elapsedTime;
}
});
return (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<shaderMaterial
ref={materialRef}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={uniforms}
/>
</mesh>
);
}
```
### Key Rule: useMemo for Uniforms
Always wrap uniforms in `useMemo`. Without it, R3F recreates the material every render:
```tsx
// CORRECT: uniforms created once
const uniforms = useMemo(() => ({
uTime: { value: 0 },
uResolution: { value: new Vector2(window.innerWidth, window.innerHeight) },
}), []);
// WRONG: new uniforms object every render, causes material recreation
const uniforms = {
uTime: { value: 0 },
};
```
## extend(): Custom JSX Elements
For reusable shader materials, use `extend()` to register a custom Three.js class as a JSX element:
```tsx
import { extend, useFrame } from '@react-three/fiber';
import { shaderMaterial } from '@react-three/drei';
import { Color, Vector2 } from 'three';
import { useRef } from 'react';
// Create a typed shader material class
const WaveMaterial = shaderMaterial(
// Uniforms with defaults
{
uTime: 0,
uColor: new Color('#ff6b6b'),
uAmplitude: 0.3,
uFrequency: 4.0,
},
// Vertex shader
/* glsl */ `
uniform float uTime;
uniform float uAmplitude;
uniform float uFrequency;
varying vec2 vUv;
varying float vElevation;
void main() {
vUv = uv;
vec3 pos = position;
float elevation = sin(pos.x * uFrequency + uTime) * uAmplitude
+ sin(pos.z * uFrequency * 0.7 + uTime * 0.8) * uAmplitude * 0.5;
pos.y += elevation;
vElevation = elevation;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
// Fragment shader
/* glsl */ `
uniform vec3 uColor;
uniform float uAmplitude;
varying vec2 vUv;
varying float vElevation;
void main() {
float brightness = (vElevation / uAmplitude + 1.0) * 0.5;
vec3 color = mix(uColor * 0.3, uColor, brightness);
gl_FragColor = vec4(color, 1.0);
}
`
);
// Register as JSX element
extend({ WaveMaterial });
// TypeScript declaration for JSX
declare module '@react-three/fiber' {
interface ThreeElements {
waveMaterial: JSX.IntrinsicElements['shaderMaterial'] & {
uTime?: number;
uColor?: Color;
uAmplitude?: number;
uFrequency?: number;
};
}
}
// Usage component
export function WavePlane() {
const materialRef = useRef<typeof WaveMaterial.prototype>(null);
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uTime = clock.elapsedTime;
}
});
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10, 128, 128]} />
<waveMaterial
ref={materialRef}
uColor={new Color('#4ecdc4')}
uAmplitude={0.3}
uFrequency={4}
/>
</mesh>
);
}
```
## GLSL Built-in Variables
### Vertex Shader
| Variable | Type | Description |
|---|---|---|
| `position` | `vec3` | Vertex position (attribute) |
| `normal` | `vec3` | Vertex normal (attribute) |
| `uv` | `vec2` | Texture coordinate (attribute) |
| `modelMatrix` | `mat4` | Object to world transform |
| `viewMatrix` | `mat4` | World to camera transform |
| `projectionMatrix` | `mat4` | Camera to clip space |
| `modelViewMatrix` | `mat4` | `viewMatrix * modelMatrix` |
| `normalMatrix` | `mat3` | Inverse transpose of modelView (for normals) |
| `cameraPosition` | `vec3` | Camera world position |
| `gl_Position` | `vec4` | **Output**: clip-space position |
### Fragment Shader
| Variable | Type | Description |
|---|---|---|
| `gl_FragCoord` | `vec4` | Pixel position in screen space |
| `gl_FrontFacing` | `bool` | Whether fragment is on front face |
| `gl_FragColor` | `vec4` | **Output**: pixel color (deprecated, use `out vec4`) |
## Common GLSL Patterns
### Fresnel Effect
```glsl
// In vertex shader
varying vec3 vViewDirection;
varying vec3 vWorldNormal;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vViewDirection = normalize(cameraPosition - worldPos.xyz);
vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
// In fragment shader
void main() {
float fresnel = pow(1.0 - dot(vViewDirection, vWorldNormal), 3.0);
vec3 color = mix(vec3(0.1), vec3(0.0, 0.8, 1.0), fresnel);
gl_FragColor = vec4(color, 1.0);
}
```
### Noise Functions
```glsl
// Simple 2D noise (include in fragment shader)
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
// Simplex-like value noise
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
// FBM (Fractal Brownian Motion) for complex noise
float fbm(vec2 st) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * noise(st);
st *= 2.0;
amplitude *= 0.5;
}
return value;
}
```
### SDF (Signed Distance Functions)
Common 2D SDFs: `sdCircle(p, r)` = `length(p) - r`, `sdBox(p, b)` = `length(max(abs(p) - b, 0.0))`. Use for procedural shapes, soft shadows, and UI in shaders. Combine with `smoothstep` for anti-aliased edges.
## @suaveplan/r3f-shaders Library
Pre-built shader materials from the Suaveplan shader library:
### Available Shaders
```tsx
import {
GlassMaterial,
HologramMaterial,
NoiseMaterial,
GradientMaterial,
FresnelMaterial,
GridMaterial,
DissolveEffect,
} from '@suaveplan/r3f-shaders';
// Glass with custom refraction
<GlassMaterial
color="#88ccff"
ior={1.45}
thickness={0.3}
distortion={0.1}
chromaticAberration={0.02}
/>
// Sci-fi hologram
<HologramMaterial
color="#00ff88"
scanlineSpeed={2}
fresnelIntensity={1.5}
glitchIntensity={0.3}
wireframe
/>
// Procedural noise surface
<NoiseMaterial
color="#4a90d9"
scale={3}
speed={0.5}
displacement={0.2}
octaves={4}
/>
// Two-color gradient
<GradientMaterial
colorA="#ff6b6b"
colorB="#4ecdc4"
direction="vertical"
/>
```
### Creating Custom Shaders for the Library
Follow the `@suaveplan/r3f-shaders` pattern:
```tsx
// src/materials/my-effect/my-effect.ts
import { shaderMaterial } from '@react-three/drei';
import { Color } from 'three';
export const MyEffectMaterial = shaderMaterial(
{ uTime: 0, uColor: new Color('#ffffff'), uIntensity: 1.0 },
/* glsl */ `/* vertex shader */`,
/* glsl */ `/* fragment shader */`
);
// src/materials/my-effect/my-effect.types.ts
import type { Color } from 'three';
export interface MyEffectMaterialProps {
uTime?: number;
uColor?: Color | string;
uIntensity?: number;
}
```
## Uniform Animation Patterns
### Time-Based Animation
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import type { ShaderMaterial } from 'three';
export function AnimatedShader() {
const matRef = useRef<ShaderMaterial>(null);
useFrame(({ clock, pointer }) => {
if (!matRef.current) return;
matRef.current.uniforms.uTime.value = clock.elapsedTime;
matRef.current.uniforms.uMouse.value.set(pointer.x, pointer.y);
});
// ... shader material with uTime and uMouse uniforms
}
```
### Reactive Uniforms (Props to Shader)
```tsx
import { useRef, useEffect } from 'react';
import { Color } from 'three';
import type { ShaderMaterial } from 'three';
interface ReactiveShaderProps {
color: string;
intensity: number;
}
export function ReactiveShader({ color, intensity }: ReactiveShaderProps) {
const matRef = useRef<ShaderMaterial>(null);
useEffect(() => {
if (!matRef.current) return;
(matRef.current.uniforms.uColor.value as Color).set(color);
matRef.current.uniforms.uIntensity.value = intensity;
}, [color, intensity]);
// ... shader material
}
```
## onBeforeCompile: Patching Built-in Materials
Modify Three.js built-in materials by injecting GLSL:
```tsx
import { useRef, useCallback } from 'react';
import { useFrame } from '@react-three/fiber';
import type { MeshStandardMaterial, Shader } from 'three';
export function PatchedMaterial() {
const matRef = useRef<MeshStandardMaterial>(null);
const shaderRef = useRef<Shader | null>(null);
const onBeforeCompile = useCallback((shader: Shader) => {
shaderRef.current = shader;
shader.uniforms.uTime = { value: 0 };
// Inject uniform declaration
shader.vertexShader = shader.vertexShader.replace(
'#include <common>',
/* glsl */ `
#include <common>
uniform float uTime;
`
);
// Inject vertex displacement
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
/* glsl */ `
#include <begin_vertex>
transformed.y += sin(transformed.x * 4.0 + uTime * 2.0) * 0.2;
`
);
}, []);
useFrame(({ clock }) => {
if (shaderRef.current) {
shaderRef.current.uniforms.uTime.value = clock.elapsedTime;
}
});
return (
<mesh>
<planeGeometry args={[5, 5, 64, 64]} />
<meshStandardMaterial
ref={matRef}
color="steelblue"
onBeforeCompile={onBeforeCompile}
/>
</mesh>
);
}
```
## RawShaderMaterial
For full control without Three.js built-in uniforms/attributes, use `<rawShaderMaterial>`. You must manually declare all attributes (`position`, `uv`), uniforms (`projectionMatrix`, `modelViewMatrix`), and set `precision`. Supports `glslVersion="300 es"` for GLSL 3.0.
## Performance Tips
1. **Minimize texture lookups**: Each `texture2D()` call has a cost. Cache results in variables.
2. **Avoid branches**: GPU prefers math over `if/else`. Use `step()`, `smoothstep()`, `mix()` instead.
3. **Limit loop iterations**: Unroll small loops. Keep `for` loops short and bounded.
4. **Use mediump where possible**: `precision mediump float` saves bandwidth on mobile.
5. **Pre-calculate on CPU**: Move invariant math to `useFrame` and pass as uniforms.
```glsl
// SLOW: branching
if (dist < radius) { color = vec3(1.0); }
else { color = vec3(0.0); }
// FAST: step function
color = vec3(step(dist, radius));
// SLOW: multiple conditions
if (x > 0.0 && x < 1.0 && y > 0.0 && y < 1.0) { ... }
// FAST: step math
float inside = step(0.0, x) * step(x, 1.0) * step(0.0, y) * step(y, 1.0);
```
## Testing Shaders
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { ShaderMaterial, Color } from 'three';
describe('Custom shader material', () => {
it('creates material with correct uniforms', () => {
const material = new ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new Color('#ff0000') },
},
vertexShader: 'void main() { gl_Position = vec4(0.0); }',
fragmentShader: 'void main() { gl_FragColor = vec4(1.0); }',
});
expect(material.uniforms.uTime.value).toBe(0);
expect(material.uniforms.uColor.value).toBeInstanceOf(Color);
});
it('updates uniform values', () => {
const uniforms = { uTime: { value: 0 } };
uniforms.uTime.value = 1.5;
expect(uniforms.uTime.value).toBe(1.5);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { WavePlane } from './wave-plane';
const meta: Meta<typeof WavePlane> = {
title: 'R3F/Shaders/WavePlane',
component: WavePlane,
decorators: [
(Story) => (
<Canvas camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<Story />
<OrbitControls />
</Canvas>
),
],
};
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When writing shaders in R3F:
1. Wrap GLSL strings with `/* glsl */` template literal tag for IDE syntax highlighting
2. Always `useMemo` for uniform objects to prevent material recreation
3. Use `extend()` + `shaderMaterial` from drei for reusable shader elements
4. Animate uniforms via `useFrame` and refs (not React state)
5. Declare TypeScript JSX types when using `extend()`
6. Use `@suaveplan/r3f-shaders` pre-built materials before writing custom GLSL
7. Prefer `step`/`smoothstep`/`mix` over `if/else` in GLSL
8. Pre-allocate all uniform objects (Color, Vector2, Vector3) in `useMemo`
9. Use `onBeforeCompile` to patch built-in materials rather than rewriting from scratch
10. Keep shader component files under 250 lines (split GLSL into separate strings or files)

View File

@@ -0,0 +1,487 @@
---
name: threejs-textures
description: Three.js texture management adapted for React Three Fiber and Suaveplan R3F packages. Covers useTexture hook, texture configuration (wrapping, filtering, encoding), environment maps, HDR loading, render targets for dynamic textures, cubemaps, texture atlases, and @suaveplan/r3f texture utilities.
---
This skill covers texture management in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to load, configure, or create textures for 3D materials: image textures, environment maps, render targets, procedural textures, or compressed texture formats.
## Core Concepts
### Three.js Texture System
Textures in Three.js are GPU-side image data. Key classes:
- **Texture**: Base class for 2D textures from images
- **CanvasTexture**: Texture from an HTML canvas element
- **VideoTexture**: Texture from a video element
- **CubeTexture**: 6-face cubemap for environment mapping
- **DataTexture**: Texture from raw typed arrays
- **WebGLRenderTarget**: Framebuffer for render-to-texture
### R3F Texture Approach
R3F provides hooks that integrate texture loading with React Suspense:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new TextureLoader().load(url, cb)` | `useTexture(url)` |
| `new CubeTextureLoader().load(urls, cb)` | `<Environment>` or `useCubeTexture` |
| `new RGBELoader().load(url, cb)` | `useLoader(RGBELoader, url)` |
| Manual texture.wrapS/wrapT | Configure after `useTexture` |
| `renderer.setRenderTarget(rt)` | `useFBO()` or `createPortal()` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core texture utilities, loading helpers | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-shaders` | Texture-based shader materials | `import { NoiseMaterial } from '@suaveplan/r3f-shaders'` |
| `@suaveplan/r3f-performance` | Texture memory monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## useTexture: Loading Textures
The primary hook for loading image textures. Suspends the component until loaded.
### Single Texture
```tsx
import { Suspense } from 'react';
import { useTexture } from '@react-three/drei';
function TexturedBox() {
const texture = useTexture('/textures/wood.jpg');
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}
export function Scene() {
return (
<Suspense fallback={null}>
<TexturedBox />
</Suspense>
);
}
```
### Multiple Textures (PBR Map Set)
```tsx
import { useTexture } from '@react-three/drei';
function PBRSurface() {
const textures = useTexture({
map: '/textures/brick/color.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/height.jpg',
});
return (
<mesh>
<planeGeometry args={[4, 4, 64, 64]} />
<meshStandardMaterial
{...textures}
displacementScale={0.1}
normalScale={[1, 1]}
aoMapIntensity={1}
/>
</mesh>
);
}
```
### Preloading Textures
```tsx
import { useTexture } from '@react-three/drei';
// Preload at module level (starts loading immediately)
useTexture.preload('/textures/wood.jpg');
useTexture.preload({
map: '/textures/brick/color.jpg',
normalMap: '/textures/brick/normal.jpg',
});
```
## Texture Configuration
After loading, configure texture properties before use:
### Wrapping Modes
```tsx
import { useTexture } from '@react-three/drei';
import { RepeatWrapping, MirroredRepeatWrapping, ClampToEdgeWrapping } from 'three';
function TiledFloor() {
const texture = useTexture('/textures/tile.jpg');
// Tile 4x4 across the surface
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(4, 4);
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}
```
| Wrapping Mode | Constant | Behavior |
|---|---|---|
| `ClampToEdgeWrapping` | Default | Edge pixels stretch |
| `RepeatWrapping` | Tiling | Texture repeats |
| `MirroredRepeatWrapping` | Mirrored tiling | Alternating mirror |
### Filtering Modes
```tsx
import { NearestFilter, LinearFilter, LinearMipmapLinearFilter } from 'three';
// Pixel-art style (no blurring)
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
texture.generateMipmaps = false;
// Smooth (default for photos/PBR)
texture.magFilter = LinearFilter;
texture.minFilter = LinearMipmapLinearFilter;
// Anisotropic filtering (reduces blur at oblique angles)
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
```
| Filter | Minification | Magnification | Use Case |
|---|---|---|---|
| `NearestFilter` | Pixelated | Pixelated | Pixel art, data textures |
| `LinearFilter` | Smooth | Smooth | Photos, PBR maps |
| `LinearMipmapLinearFilter` | Trilinear | N/A | Default min filter |
### Color Space
```tsx
import { SRGBColorSpace, LinearSRGBColorSpace, NoColorSpace } from 'three';
// Color/albedo textures: sRGB (default in R3F)
texture.colorSpace = SRGBColorSpace;
// Data textures (normal, roughness, metalness, AO): Linear
normalMap.colorSpace = LinearSRGBColorSpace;
roughnessMap.colorSpace = LinearSRGBColorSpace;
// Raw data (displacement, custom): No conversion
dataTexture.colorSpace = NoColorSpace;
```
### Texture Configuration Helper
```tsx
import { useTexture } from '@react-three/drei';
import { RepeatWrapping, SRGBColorSpace, LinearSRGBColorSpace } from 'three';
import type { Texture } from 'three';
function configureTexture(texture: Texture, options: {
repeat?: [number, number];
colorSpace?: string;
anisotropy?: number;
}) {
if (options.repeat) {
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(...options.repeat);
}
if (options.colorSpace) {
texture.colorSpace = options.colorSpace;
}
if (options.anisotropy) {
texture.anisotropy = options.anisotropy;
}
return texture;
}
function ConfiguredSurface() {
const textures = useTexture({
map: '/textures/ground/color.jpg',
normalMap: '/textures/ground/normal.jpg',
});
configureTexture(textures.map, { repeat: [4, 4], colorSpace: SRGBColorSpace });
configureTexture(textures.normalMap, { repeat: [4, 4], colorSpace: LinearSRGBColorSpace });
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial {...textures} />
</mesh>
);
}
```
## Environment Maps
### HDR Environment Maps
```tsx
import { useLoader } from '@react-three/fiber';
import { RGBELoader } from 'three-stdlib';
import { EquirectangularReflectionMapping } from 'three';
function HDREnvironment({ url }: { url: string }) {
const hdr = useLoader(RGBELoader, url);
hdr.mapping = EquirectangularReflectionMapping;
return (
<>
<primitive object={hdr} attach="environment" />
<primitive object={hdr} attach="background" />
</>
);
}
```
### Drei Environment Component
The simplest way to add environment maps:
```tsx
import { Environment } from '@react-three/drei';
// Preset HDR
<Environment preset="sunset" background />
// Custom HDR file
<Environment files="/textures/env.hdr" background />
// EXR file
<Environment files="/textures/env.exr" background />
// Multiple face cubemap
<Environment
files={[
'/cubemap/px.jpg', '/cubemap/nx.jpg',
'/cubemap/py.jpg', '/cubemap/ny.jpg',
'/cubemap/pz.jpg', '/cubemap/nz.jpg',
]}
/>
// Blurred background
<Environment files="/textures/env.hdr" background blur={0.5} />
```
### Lightformer Environments
Create procedural environment maps with `<Lightformer>` inside `<Environment>`. Use `form="rect"` or `form="ring"` with position, intensity, scale, and color props to simulate key, fill, and rim lights.
## Render Targets (Dynamic Textures)
### useFBO: Render to Texture
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { useFBO } from '@react-three/drei';
import type { Mesh } from 'three';
export function MirrorSurface() {
const meshRef = useRef<Mesh>(null);
const renderTarget = useFBO(512, 512);
useFrame(({ gl, scene, camera }) => {
// Hide mirror during render-to-texture
if (meshRef.current) meshRef.current.visible = false;
gl.setRenderTarget(renderTarget);
gl.render(scene, camera);
gl.setRenderTarget(null);
if (meshRef.current) meshRef.current.visible = true;
});
return (
<mesh ref={meshRef}>
<planeGeometry args={[4, 3]} />
<meshBasicMaterial map={renderTarget.texture} />
</mesh>
);
}
```
### Portal Rendering
Use `createPortal()` from R3F to render a separate `Scene` into a `useFBO()` render target. In `useFrame`, set the render target, render the portal scene with a dedicated camera, then reset to null.
## Canvas, Video, and Data Textures
- **CanvasTexture**: Create via `new CanvasTexture(canvas)` with a drawn 2D canvas. Wrap in `useMemo`.
- **VideoTexture**: Use `useVideoTexture` from drei for video-mapped surfaces.
- **DataTexture**: Create from `Float32Array` for noise, heightmaps, lookup tables. Set `needsUpdate = true`.
## Texture Atlas
Pack multiple images into a single texture and use UV offsets:
```tsx
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
interface AtlasFrame {
x: number;
y: number;
width: number;
height: number;
}
function useTextureAtlas(url: string, frame: AtlasFrame, atlasSize: [number, number]) {
const texture = useTexture(url);
return useMemo(() => {
const clone = texture.clone();
clone.repeat.set(frame.width / atlasSize[0], frame.height / atlasSize[1]);
clone.offset.set(frame.x / atlasSize[0], 1 - (frame.y + frame.height) / atlasSize[1]);
clone.needsUpdate = true;
return clone;
}, [texture, frame, atlasSize]);
}
// Usage: select a 64x64 region from a 512x512 atlas
function AtlasSprite() {
const tex = useTextureAtlas(
'/textures/sprites.png',
{ x: 128, y: 0, width: 64, height: 64 },
[512, 512]
);
return (
<sprite scale={[1, 1, 1]}>
<spriteMaterial map={tex} />
</sprite>
);
}
```
## Texture Memory Management
### GPU Memory Estimation
| Dimensions | Format | Mipmaps | VRAM |
|---|---|---|---|
| 1024x1024 | RGBA8 | Yes | ~5.3 MB |
| 2048x2048 | RGBA8 | Yes | ~21.3 MB |
| 4096x4096 | RGBA8 | Yes | ~85.3 MB |
| 1024x1024 | KTX2 (BC7) | Yes | ~1.3 MB |
| 2048x2048 | KTX2 (BC7) | Yes | ~5.3 MB |
### Optimization Guidelines
1. **Use power-of-two dimensions**: 256, 512, 1024, 2048, 4096 (required for mipmaps)
2. **Use KTX2 compression**: 4x-8x VRAM savings over raw RGBA
3. **Reduce resolution**: Most roughness/AO maps work fine at 512x512
4. **Share textures**: Use `texture.clone()` for UV variations on the same image
5. **Generate mipmaps**: Set `texture.generateMipmaps = true` (default) for filtered rendering
6. **Dispose unused textures**: `texture.dispose()` frees GPU memory
```tsx
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
function useAdaptiveTextureResolution() {
const [resolution, setResolution] = useState<'high' | 'low'>('high');
usePerformanceMonitor({
onDecline: () => setResolution('low'),
onIncline: () => setResolution('high'),
});
return resolution === 'high' ? '2k' : '1k';
}
```
## Testing Textures
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import {
DataTexture,
FloatType,
RedFormat,
RepeatWrapping,
CanvasTexture,
} from 'three';
describe('Texture utilities', () => {
it('creates data texture with correct dimensions', () => {
const size = 64;
const data = new Float32Array(size * size);
const tex = new DataTexture(data, size, size, RedFormat, FloatType);
expect(tex.image.width).toBe(size);
expect(tex.image.height).toBe(size);
});
it('configures repeat wrapping', () => {
const data = new Float32Array(16);
const tex = new DataTexture(data, 4, 4, RedFormat, FloatType);
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.repeat.set(4, 4);
expect(tex.wrapS).toBe(RepeatWrapping);
expect(tex.repeat.x).toBe(4);
});
it('creates canvas texture', () => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const tex = new CanvasTexture(canvas);
expect(tex.image).toBe(canvas);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
function TextureDemo() {
return (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<Environment preset="studio" />
<Suspense fallback={null}>
{/* Textured mesh component */}
</Suspense>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Textures/PBRSurface', component: TextureDemo };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When working with textures in R3F:
1. Use `useTexture` from drei for all image texture loading
2. Wrap textured components in `<Suspense>` for async loading
3. Set `colorSpace = SRGBColorSpace` for color/albedo maps only
4. Set `colorSpace = LinearSRGBColorSpace` for normal, roughness, metalness, AO maps
5. Use `RepeatWrapping` and `texture.repeat.set()` for tiled surfaces
6. Preload textures with `useTexture.preload()` at module level
7. Use KTX2 compression for production (4-8x VRAM savings)
8. Use power-of-two dimensions for mipmap generation
9. Dispose textures when no longer needed (`texture.dispose()`)
10. Keep texture-related component files under 250 lines

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-web-artifacts",
"version": "1.0.0",
"description": "Build production-grade React artifacts using Suaveplan component library"
}

View File

@@ -0,0 +1,326 @@
---
name: web-artifacts-builder
description: Build production-ready React 19 + TypeScript + Tailwind CSS components and pages using Suaveplan's UI library, Vite bundling, and shadcn/ui primitives
---
# Web Artifacts Builder for Suaveplan
Build React 19 components, pages, and interactive artifacts within the Suaveplan monorepo. All artifacts use TypeScript, Tailwind CSS, shadcn/ui from `@suaveplan/react-ui`, and Vite for bundling. No external UI libraries when Suaveplan equivalents exist.
## Stack
| Layer | Technology |
|-------|-----------|
| Framework | React 19 (Server Components, Actions, useActionState) |
| Language | TypeScript 5.x (strict mode) |
| Styling | Tailwind CSS with `cn()` from `@suaveplan/react-ui` |
| UI Primitives | shadcn/ui via `@suaveplan/react-ui` |
| Bundler | Vite (NOT Parcel, NOT webpack) |
| Testing | Vitest (unit) + Playwright (E2E) |
| Linting | Biome (NOT ESLint) |
| Stories | Storybook 8 |
| Package Manager | Bun with workspace protocol |
## Available Suaveplan Packages
Always use these instead of external alternatives:
| Need | Suaveplan Package | Do NOT Use |
|------|------------------|------------|
| Buttons, Cards, Dialogs, Inputs | `@suaveplan/react-ui` | Material UI, Chakra UI, Ant Design |
| Forms with validation | `@suaveplan/react-forms` | React Hook Form (standalone) |
| Data grids/tables | `@suaveplan/react-data-table` | AG Grid, TanStack Table (standalone) |
| Notifications/toasts | `@suaveplan/react-notifications` | react-hot-toast, sonner |
| Timeline/Gantt views | `@suaveplan/react-timeline` | vis-timeline |
| Authentication UI | `@suaveplan/react-auth` | custom auth forms |
| Media players | `@suaveplan/react-media` | react-player |
| Internationalization | `@suaveplan/react-translate` | react-i18next (standalone) |
| CSS class merging | `cn()` from `@suaveplan/react-ui` | clsx, classnames (standalone) |
| Charts/visualization | `@suaveplan/react-charts` | recharts (standalone) |
| Drive/file sharing UI | `@suaveplan/drive-react-sharing` | custom file dialogs |
| Virtualized lists | `@suaveplan/react-virtual` | react-window, react-virtuoso |
## Component File Structure
Every component follows this five-file pattern inside its directory:
```text
src/components/<component-name>/
<component-name>.tsx # Implementation (max 250 lines)
<component-name>.test.tsx # Unit tests (max 500 lines, 95%+ coverage)
<component-name>.stories.tsx # Storybook stories
<component-name>.types.ts # TypeScript interfaces and types
<component-name>.md # Documentation (200+ words, simple + advanced examples)
```
### Implementation File Pattern
```typescript
// src/components/status-badge/status-badge.tsx
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@suaveplan/react-ui';
import type { StatusBadgeProps } from './status-badge.types';
const statusBadgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
status: {
active: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
inactive: 'bg-muted text-muted-foreground',
},
},
defaultVariants: { status: 'active' },
}
);
export const StatusBadge = React.forwardRef<HTMLSpanElement, StatusBadgeProps>(
({ className, status, label, ...props }, ref) => {
return (
<span
ref={ref}
data-testid="sp-status-badge"
role="status"
aria-label={`Status: ${label ?? status}`}
className={cn(statusBadgeVariants({ status }), className)}
{...props}
>
{label ?? status}
</span>
);
}
);
StatusBadge.displayName = 'StatusBadge';
```
### Types File Pattern
```typescript
// src/components/status-badge/status-badge.types.ts
import type { VariantProps } from 'class-variance-authority';
import type { statusBadgeVariants } from './status-badge';
export interface StatusBadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof statusBadgeVariants> {
/** Display label (defaults to status name) */
label?: string;
}
```
### Stories File Pattern
```typescript
// src/components/status-badge/status-badge.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { StatusBadge } from './status-badge';
const meta: Meta<typeof StatusBadge> = {
title: 'Components/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
status: {
control: 'select',
options: ['active', 'pending', 'inactive'],
},
},
};
export default meta;
type Story = StoryObj<typeof StatusBadge>;
export const Active: Story = { args: { status: 'active' } };
export const Pending: Story = { args: { status: 'pending', label: 'Awaiting Review' } };
export const Inactive: Story = { args: { status: 'inactive' } };
export const AllStatuses: Story = {
render: () => (
<div className="flex gap-2">
<StatusBadge status="active" />
<StatusBadge status="pending" />
<StatusBadge status="inactive" />
</div>
),
};
```
### Test File Pattern
```typescript
// src/components/status-badge/status-badge.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { StatusBadge } from './status-badge';
describe('StatusBadge', () => {
it('renders with active status', () => {
render(<StatusBadge status="active" />);
expect(screen.getByTestId('sp-status-badge')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('active');
});
it('uses custom label when provided', () => {
render(<StatusBadge status="pending" label="In Review" />);
expect(screen.getByRole('status')).toHaveTextContent('In Review');
});
it('applies custom className via cn()', () => {
render(<StatusBadge status="active" className="ml-4" />);
expect(screen.getByTestId('sp-status-badge')).toHaveClass('ml-4');
});
it('forwards ref', () => {
const ref = { current: null as HTMLSpanElement | null };
render(<StatusBadge ref={ref} status="active" />);
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
});
it('has accessible aria-label', () => {
render(<StatusBadge status="active" />);
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Status: active');
});
});
```
### Documentation File Pattern
Follow `.claude/templates/module-docs.md` template with these requirements:
- Minimum 200 words
- Include a "Basic Example" section (simple import + render)
- Include an "Advanced Example" section (composition, custom styling, controlled state)
- Document all props in an API Reference section
- Explain design decisions
```markdown
# StatusBadge
## Purpose
A visual indicator component that displays the current status of an entity...
## Usage
### Basic Example
\`\`\`typescript
import { StatusBadge } from '@suaveplan/react-<package>';
<StatusBadge status="active" />
\`\`\`
### Advanced Example
\`\`\`typescript
import { StatusBadge } from '@suaveplan/react-<package>';
import { cn } from '@suaveplan/react-ui';
// Composed with conditional logic and custom styling...
\`\`\`
## API Reference
...
```
## Styling Rules
### Tailwind CSS + cn() Utility
Always use `cn()` from `@suaveplan/react-ui` for conditional class merging:
```typescript
import { cn } from '@suaveplan/react-ui';
// Correct: cn() for merging
<div className={cn('flex items-center', isActive && 'bg-primary', className)} />
// Wrong: template literals for conditional classes
<div className={`flex items-center ${isActive ? 'bg-primary' : ''}`} />
```
### Dark Mode
Use Tailwind's `dark:` variant. Shadcn/ui CSS variables automatically adapt:
```typescript
<div className="bg-background text-foreground dark:bg-background dark:text-foreground">
<p className="text-muted-foreground">Uses CSS variables, works in both modes</p>
</div>
```
### Shadcn/UI CSS Variables
Reference these semantic tokens instead of raw Tailwind colors:
- `background` / `foreground` -- page background and default text
- `primary` / `primary-foreground` -- primary actions
- `secondary` / `secondary-foreground` -- secondary actions
- `muted` / `muted-foreground` -- subdued UI elements
- `accent` / `accent-foreground` -- highlights and hover states
- `destructive` / `destructive-foreground` -- errors and deletions
- `border` -- default border color
- `ring` -- focus ring color
- `card` / `card-foreground` -- card surfaces
## Build Configuration
Suaveplan browser packages use Vite for building:
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [react(), dts({ rollupTypes: true })],
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
},
},
});
```
## Package.json Requirements
```json
{
"name": "@suaveplan/react-<name>",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@suaveplan/react-ui": "workspace:*"
},
"peerDependencies": {
"react": "19.0.0",
"react-dom": "19.0.0"
}
}
```
## Constraints
- **Max 250 lines** per implementation file -- split into sub-components if larger
- **Max 500 lines** per test file -- split by feature area
- **95% minimum test coverage** -- verified via `bun run test:coverage`
- **No external UI libraries** when a Suaveplan package covers the need
- **No Parcel** -- Vite is the bundler for all browser packages
- **No tsc compilation** -- Vite handles transforms, TypeScript is for type-checking only
- **No mocks** -- test with real implementations
- **Biome linting** -- not ESLint
- **React 19** -- use modern patterns (useActionState, useOptimistic, Server Components where applicable)
- **Accessibility** -- ARIA labels, keyboard navigation, focus management on all interactive elements
- **`data-testid`** attributes must use `sp-` prefix on all Suaveplan components
- **`forwardRef`** -- all leaf components must forward refs
- **Documentation** -- every `.tsx` implementation file must have a matching `.md` file

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-webapp-testing",
"version": "1.0.0",
"description": "Playwright E2E testing toolkit for Suaveplan browser packages"
}

View File

@@ -0,0 +1,302 @@
---
name: webapp-testing
description: End-to-end and visual regression testing for Suaveplan React 19 + TypeScript + Tailwind CSS web applications using Playwright and Storybook
---
# Webapp Testing for Suaveplan
Write and maintain Playwright E2E tests and Storybook visual regression tests for Suaveplan browser packages. All tests are TypeScript `.spec.ts` files executed natively through Playwright -- no Python scripts, no wrapper layers.
## Architecture
- **Runtime**: Bun + Turborepo monorepo
- **E2E Framework**: Playwright (TypeScript) with `.spec.ts` files in `e2e/` directories
- **Unit/Integration**: Vitest with `.test.ts` / `.test.tsx` files co-located with source
- **Visual Testing**: Storybook stories serve as E2E test fixtures via `playwright.config.ts` webServer
- **UI Library**: `@suaveplan/react-ui` (shadcn/ui-based) with `cn()` utility for Tailwind class merging
- **CSS Prefix**: All Suaveplan components use `sp-` prefix in `data-testid` attributes
## Running Tests
```bash
# Run E2E tests for a specific package
bun run e2e --filter=@suaveplan/<package-name>
# Run unit tests
bun run test --filter=@suaveplan/<package-name>
# Run all E2E tests across monorepo
bunx turbo e2e
# Run with coverage (95% minimum required)
bun run test:coverage --filter=@suaveplan/<package-name>
```
## File Structure
Every testable browser package follows this layout:
```text
packages/browser/<package-name>/
playwright.config.ts # Playwright configuration
e2e/
<feature>.spec.ts # E2E test files
accessibility.spec.ts # Dedicated a11y E2E tests
.storybook/
main.ts # Storybook configuration
preview.ts # Global decorators, Tailwind CSS import
src/
components/
<component>/
<component>.tsx # Implementation
<component>.test.tsx # Unit tests (Vitest)
<component>.stories.tsx # Storybook stories (E2E fixtures)
<component>.types.ts # TypeScript types
<component>.md # Documentation
```
## Playwright Configuration Pattern
Each package has its own `playwright.config.ts` that launches Storybook as the web server:
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['json', { outputFile: 'test-results.json' }]],
use: {
baseURL: 'http://localhost:6030',
viewport: { width: 1280, height: 720 },
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'bun run storybook',
port: 6030,
reuseExistingServer: !process.env.CI,
timeout: 300 * 1000,
stdout: 'pipe',
stderr: 'pipe',
},
});
```
## E2E Test Patterns
### Navigating to Storybook Stories
All E2E tests use Storybook iframe URLs as their entry point:
```typescript
import { expect, test } from '@playwright/test';
const storyUrl = (story: string) =>
`/iframe.html?viewMode=story&id=components-<component>--${story}`;
test.describe('<Component>: Default story', () => {
test.beforeEach(async ({ page }) => {
await page.goto(storyUrl('default'));
await page.waitForSelector('[data-testid="sp-<component>-container"]');
});
test('renders the component', async ({ page }) => {
const container = page.getByTestId('sp-<component>-container');
await expect(container).toBeVisible();
});
});
```
### Selector Strategy (Priority Order)
1. **`data-testid` with `sp-` prefix** -- Primary selector for Suaveplan components
2. **ARIA roles and labels** -- For accessible elements
3. **Text content** -- For user-visible labels
4. **CSS class selectors** -- Last resort only, prefix with `sp-`
```typescript
// Preferred: data-testid with sp- prefix
page.getByTestId('sp-timeline-container');
page.getByTestId('sp-event-bar');
page.getByTestId('sp-data-table-row');
// Good: ARIA roles
page.getByRole('button', { name: 'Submit' });
page.getByRole('dialog', { name: 'Confirm deletion' });
page.getByRole('application', { name: 'Timeline' });
// Acceptable: text content
page.getByText('No results found');
// Avoid unless necessary: CSS classes
page.locator('.sp-card-header');
```
### Testing shadcn/ui Components
shadcn/ui components from `@suaveplan/react-ui` use Radix UI primitives. Test their behavior through ARIA roles:
```typescript
// Dialog (from @suaveplan/react-ui)
test('opens dialog on trigger click', async ({ page }) => {
await page.getByRole('button', { name: 'Open dialog' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toHaveAttribute('aria-modal', 'true');
});
// Select (Radix-based)
test('selects an option from dropdown', async ({ page }) => {
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Option A' }).click();
await expect(page.getByRole('combobox')).toHaveText('Option A');
});
// Toast notifications (@suaveplan/react-notifications)
test('shows success toast after save', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click();
const toast = page.getByTestId('sp-toast');
await expect(toast).toBeVisible();
await expect(toast).toContainText('Saved successfully');
});
// Data table (@suaveplan/react-data-table)
test('sorts table by column header', async ({ page }) => {
await page.getByRole('columnheader', { name: 'Name' }).click();
const firstRow = page.getByTestId('sp-data-table-row').first();
await expect(firstRow).toContainText('Alpha');
});
```
### Accessibility Testing Pattern
Every package should have a dedicated `accessibility.spec.ts`:
```typescript
import { expect, test } from '@playwright/test';
const storyUrl = (story: string) =>
`/iframe.html?viewMode=story&id=components-<component>--${story}`;
test.describe('Accessibility', () => {
test('all interactive elements are keyboard-focusable', async ({ page }) => {
await page.goto(storyUrl('default'));
await page.waitForSelector('[data-testid="sp-<component>-container"]');
// Tab through interactive elements
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toHaveAttribute('tabindex', '0');
});
test('ARIA labels are present on all semantic elements', async ({ page }) => {
await page.goto(storyUrl('default'));
const container = page.getByTestId('sp-<component>-container');
await expect(container).toHaveAttribute('aria-label');
});
test('focus is trapped inside modal dialogs', async ({ page }) => {
await page.goto(storyUrl('with-dialog'));
await page.getByRole('button', { name: 'Open' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeFocused();
});
});
```
### Visual Regression Pattern
Use Playwright screenshot comparison against Storybook stories:
```typescript
test('matches visual snapshot', async ({ page }) => {
await page.goto(storyUrl('default'));
await page.waitForSelector('[data-testid="sp-<component>-container"]');
// Wait for animations to settle
await page.waitForTimeout(300);
await expect(page).toHaveScreenshot('<component>-default.png', {
maxDiffPixelRatio: 0.01,
});
});
test('matches dark mode snapshot', async ({ page }) => {
await page.goto(storyUrl('dark-mode'));
await page.waitForSelector('[data-testid="sp-<component>-container"]');
await page.waitForTimeout(300);
await expect(page).toHaveScreenshot('<component>-dark.png', {
maxDiffPixelRatio: 0.01,
});
});
```
### Form Testing Pattern
For `@suaveplan/react-forms` components:
```typescript
test('validates required fields on submit', async ({ page }) => {
await page.goto(storyUrl('with-validation'));
await page.getByRole('button', { name: 'Submit' }).click();
// Check validation messages appear
await expect(page.getByText('Name is required')).toBeVisible();
await expect(page.getByText('Email is required')).toBeVisible();
});
test('submits form with valid data', async ({ page }) => {
await page.goto(storyUrl('with-validation'));
await page.getByLabel('Name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByRole('button', { name: 'Submit' }).click();
// Verify success state
await expect(page.getByTestId('sp-form-success')).toBeVisible();
});
```
## Constraints
- **Max 500 lines per test file** -- split large test suites into multiple `.spec.ts` files by feature
- **Max 250 lines per implementation file** -- components exceeding this need refactoring
- **95% minimum test coverage** -- enforced across unit + E2E combined
- **No mocks** -- all tests use real implementations and real Storybook stories
- **TypeScript only** -- no `.js` or `.mjs` test files
- **Biome linting** -- all test files must pass `bun run lint`
- Every component must have `data-testid` attributes with `sp-` prefix
- Every `.spec.ts` file must have a corresponding `.stories.tsx` fixture
- Accessibility tests are mandatory for interactive components
## Packages with E2E Tests
The following packages have Playwright configurations and should always maintain E2E coverage:
| Package | Storybook Port | Test Directory |
|---------|---------------|----------------|
| `@suaveplan/react-ui` | 6006 | `e2e/` |
| `@suaveplan/react-forms` | 6007 | `e2e/` |
| `@suaveplan/react-data-table` | 6030 | `e2e/` |
| `@suaveplan/react-timeline` | 6030 | `e2e/` |
| `@suaveplan/react-notifications` | 6030 | `e2e/` |
| `@suaveplan/react-charts` | 6030 | `e2e/` |
| `@suaveplan/drive-react-sharing` | 6030 | `e2e/` |
| `@suaveplan/react-virtual` | 6030 | `e2e/` |
| `@suaveplan/react-auth` | 6030 | `e2e/` |
| `@suaveplan/react-media` | 6030 | `e2e/` |
## Checklist Before Completing Tests
1. All `.spec.ts` files pass: `bun run e2e --filter=@suaveplan/<package>`
2. All `.test.tsx` files pass: `bun run test --filter=@suaveplan/<package>`
3. Coverage at 95%+: `bun run test:coverage --filter=@suaveplan/<package>`
4. Biome lint clean: `bun run lint --filter=@suaveplan/<package>`
5. TypeScript types pass: `bun run typecheck --filter=@suaveplan/<package>`
6. Accessibility spec exists for interactive components
7. No `vi.mock()` or `jest.mock()` calls
8. All `data-testid` attributes use `sp-` prefix

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-webgpu",
"version": "1.0.0",
"description": "WebGPU rendering with Three.js TSL in React Three Fiber"
}

View File

@@ -0,0 +1,404 @@
# TSL API Reference -- Cheatsheet for R3F
Complete reference for Three Shading Language nodes, operators, and patterns.
All examples assume a React Three Fiber context with WebGPU or WebGL backend.
## Imports
```ts
// Node materials
import {
MeshStandardNodeMaterial,
MeshPhysicalNodeMaterial,
MeshBasicNodeMaterial,
SpriteNodeMaterial,
LineBasicNodeMaterial,
PointsNodeMaterial,
} from 'three/webgpu';
// TSL core
import {
// Types & constructors
float, int, uint, bool, vec2, vec3, vec4, mat2, mat3, mat4, color,
// Uniforms & attributes
uniform, attribute, varying, varyingProperty,
// Math
sin, cos, tan, asin, acos, atan, atan2,
abs, sign, floor, ceil, fract, round, trunc,
pow, exp, exp2, log, log2, sqrt, inverseSqrt,
min, max, clamp, mix, step, smoothstep, saturate,
length, distance, dot, cross, normalize, faceForward, reflect, refract,
// Geometry
positionLocal, positionWorld, positionView, positionClip,
normalLocal, normalWorld, normalView, normalGeometry,
tangentLocal, tangentWorld, tangentView,
uv, // uv() for channel 0
// Camera
cameraPosition, cameraNear, cameraFar, cameraProjectionMatrix,
cameraViewMatrix, cameraWorldMatrix,
// Time
timerLocal, timerGlobal, timerDelta, frameId,
// Textures
texture, cubeTexture, triplanarTexture,
// Display / output
screenSize, screenCoordinate, viewportSize, viewportCoordinate,
// Control flow
If, Loop, select, Fn,
// Storage / compute
instancedArray, storage, storageObject, compute,
instanceIndex, vertexIndex, drawIndex,
// Noise
mx_noise_float, mx_noise_vec3, mx_fractal_noise_float,
mx_cell_noise_float, mx_worley_noise_float,
// Color / tone
luminance, saturation, vibrance, hue,
toneMappingExposure, colorSpaceToWorking,
// SDF
sdfCircle, sdfBox, sdfRoundedBox,
// Miscellaneous
hash, range, vertexColor, frontFacing,
} from 'three/tsl';
```
## TSL Types and Constructors
| Constructor | WGSL Type | Example |
|---|---|---|
| `float(x)` | `f32` | `float(1.0)` |
| `int(x)` | `i32` | `int(5)` |
| `uint(x)` | `u32` | `uint(10)` |
| `bool(x)` | `bool` | `bool(true)` |
| `vec2(x, y)` | `vec2f` | `vec2(1, 2)` |
| `vec3(x, y, z)` | `vec3f` | `vec3(1, 0, 0)` |
| `vec4(x, y, z, w)` | `vec4f` | `vec4(1, 0, 0, 1)` |
| `mat2(...)` | `mat2x2f` | `mat2(1,0,0,1)` |
| `mat3(...)` | `mat3x3f` | `mat3(...)` |
| `mat4(...)` | `mat4x4f` | `mat4(...)` |
| `color(hex)` | `vec3f` | `color(0x3b82f6)` |
| `uniform(val)` | varies | `uniform(0.5)` |
## Operators (Method Chaining)
TSL overloads methods, not JS operators. Use method calls:
```ts
const a = float(1.0);
const b = float(2.0);
a.add(b) // a + b
a.sub(b) // a - b
a.mul(b) // a * b
a.div(b) // a / b
a.mod(b) // a % b
a.equal(b) // a == b
a.notEqual(b) // a != b
a.lessThan(b) // a < b
a.greaterThan(b) // a > b
a.lessThanEqual(b)
a.greaterThanEqual(b)
a.and(b) // a && b
a.or(b) // a || b
a.not() // !a
a.negate() // -a
a.abs() // abs(a)
// Ternary
a.greaterThan(b).select(a, b) // a > b ? a : b
```
## Swizzling
```ts
const v = vec4(1, 2, 3, 4);
v.x // float(1)
v.xy // vec2(1, 2)
v.xyz // vec3(1, 2, 3)
v.zyx // vec3(3, 2, 1) -- reorder
v.xxx // vec3(1, 1, 1) -- repeat
```
## Uniforms (R3F Pattern)
```tsx
function MyMesh() {
const uTime = useMemo(() => uniform(0), []);
const uColor = useMemo(() => uniform(new THREE.Color(0xff0000)), []);
const uIntensity = useMemo(() => uniform(1.0), []);
useFrame((state) => {
uTime.value = state.clock.elapsedTime;
});
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
mat.colorNode = uColor.mul(uIntensity);
mat.emissiveNode = uColor.mul(sin(uTime).mul(0.5).add(0.5));
return mat;
}, [uTime, uColor, uIntensity]);
return (
<mesh>
<boxGeometry />
<primitive object={material} attach="material" />
</mesh>
);
}
```
## Geometry Nodes
```ts
positionLocal // Object-space vertex position
positionWorld // World-space vertex position
positionView // View-space (camera) position
positionClip // Clip-space position
normalLocal // Object-space normal
normalWorld // World-space normal
normalView // View-space normal
normalGeometry // Raw geometry normal (before skinning)
tangentLocal // Object-space tangent
tangentWorld // World-space tangent
uv() // UV channel 0
uv(1) // UV channel 1
```
## Camera Nodes
```ts
cameraPosition // Camera world position (vec3)
cameraNear // Near plane (float)
cameraFar // Far plane (float)
cameraProjectionMatrix // Projection matrix (mat4)
cameraViewMatrix // View matrix (mat4)
cameraWorldMatrix // Camera world matrix (mat4)
```
## Time Nodes
```ts
timerLocal() // Time since material creation (seconds, float)
timerGlobal() // Time since renderer init (seconds, float)
timerDelta() // Delta time per frame (seconds, float)
frameId // Integer frame counter
```
## Noise Functions (MaterialX)
```ts
mx_noise_float(pos) // Perlin noise, returns float
mx_noise_vec3(pos) // Perlin noise, returns vec3
mx_fractal_noise_float(pos, octaves, lacunarity, diminish)
mx_cell_noise_float(pos) // Cell/Voronoi noise
mx_worley_noise_float(pos) // Worley noise
hash(value) // Deterministic hash
```
## Material Slots (Node Overrides)
```ts
const mat = new MeshStandardNodeMaterial();
// Color
mat.colorNode // Base color (replaces map)
mat.opacityNode // Alpha (replaces alphaMap)
mat.emissiveNode // Emissive color
mat.metalnessNode // Metalness (float)
mat.roughnessNode // Roughness (float)
// Normals
mat.normalNode // Normal map override
// Position
mat.positionNode // Vertex displacement
// Fragment output
mat.fragmentNode // Full fragment override (unlit)
mat.outputNode // Final output override (after lighting)
// Other
mat.shadowNode // Custom shadow
mat.aoNode // Ambient occlusion
mat.clearcoatNode // Clearcoat intensity
mat.clearcoatRoughnessNode
mat.sheenNode // Sheen color
mat.sheenRoughnessNode
mat.iorNode // Index of refraction
mat.transmissionNode // Transmission
mat.thicknessNode // Volume thickness
```
## Control Flow
```ts
// If statement (GPU branching)
If(condition, () => {
// true branch
}).ElseIf(otherCondition, () => {
// else-if branch
}).Else(() => {
// false branch
});
// Select (ternary)
select(condition, trueValue, falseValue)
// Loop
Loop(count, ({ i }) => {
// loop body, i is the index
});
Loop({ start: 0, end: 10, type: 'int' }, ({ i }) => {
// explicit range loop
});
```
## Custom TSL Functions
```ts
const myFunc = Fn(([param1, param2]) => {
return param1.mul(param2).add(float(1.0));
});
// Usage in material
mat.colorNode = myFunc(uv(), float(2.0));
```
## Compute Shaders
```ts
import { instancedArray, storage, compute, instanceIndex } from 'three/tsl';
// Create storage buffer
const posBuffer = instancedArray(count, 'vec3');
const velBuffer = instancedArray(count, 'vec3');
// Define compute kernel
const updateParticles = Fn(() => {
const pos = posBuffer.element(instanceIndex);
const vel = velBuffer.element(instanceIndex);
pos.addAssign(vel.mul(timerDelta()));
});
// Create compute node
const computeNode = updateParticles().compute(count);
// Dispatch in useFrame
useFrame(({ gl }) => {
(gl as WebGPURenderer).computeAsync(computeNode);
});
```
## Post-Processing Nodes
```ts
import { PostProcessing } from 'three/addons/renderers/common/PostProcessing.js';
import { pass, bloom, dotScreen, film, fxaa } from 'three/tsl';
const scenePass = pass(scene, camera);
const beautyPass = scenePass.getTextureNode();
// Chain effects
const postProcessing = new PostProcessing(renderer);
postProcessing.outputNode = beautyPass
.pipe(bloom({ strength: 0.5, radius: 0.4, threshold: 0.85 }))
.pipe(fxaa())
.pipe(film({ intensity: 0.1 }));
```
## Textures
```ts
import { texture, cubeTexture, triplanarTexture } from 'three/tsl';
const tex = texture(myTexture); // Sample texture
const texUV = texture(myTexture, uv()); // Explicit UV
tex.uv(customUV) // Custom UV coordinates
tex.level(mipLevel) // Specific mip level
tex.bias(lodBias) // LOD bias
const cube = cubeTexture(myCubeMap);
const triplanar = triplanarTexture(tex, null, null, normalWorld);
```
## SDF (Signed Distance Fields)
```ts
sdfCircle(uv().sub(0.5), float(0.3)) // Circle SDF
sdfBox(uv().sub(0.5), vec2(0.3, 0.2)) // Box SDF
sdfRoundedBox(uv().sub(0.5), vec2(0.3, 0.2), vec4(0.05))
```
## Color Operations
```ts
luminance(colorNode) // Perceived brightness
saturation(colorNode, amount) // Adjust saturation
vibrance(colorNode, amount) // Adjust vibrance
hue(colorNode, amount) // Rotate hue
```
## Screen/Viewport
```ts
screenSize // vec2 -- screen dimensions in pixels
screenCoordinate // vec2 -- fragment position in pixels
viewportSize // vec2 -- viewport dimensions
viewportCoordinate // vec2 -- normalized viewport coords (0..1)
```
## Common R3F + TSL Patterns
### Extend for JSX (optional, for frequently reused materials)
```tsx
import { extend } from '@react-three/fiber';
import { MeshStandardNodeMaterial } from 'three/webgpu';
extend({ MeshStandardNodeMaterial });
// Now usable as JSX (but cannot set node properties declaratively)
<mesh>
<boxGeometry />
<meshStandardNodeMaterial color="hotpink" />
</mesh>
```
### Ref-Based Node Updates
```tsx
function DynamicShader() {
const matRef = useRef<MeshStandardNodeMaterial>(null!);
const uHover = useMemo(() => uniform(0), []);
useFrame((_, delta) => {
uHover.value = THREE.MathUtils.lerp(uHover.value, hovered ? 1 : 0, delta * 5);
});
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
mat.colorNode = mix(color(0x1e293b), color(0x3b82f6), uHover);
return mat;
}, [uHover]);
return (
<mesh onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)}>
<sphereGeometry args={[1, 32, 32]} />
<primitive object={material} attach="material" ref={matRef} />
</mesh>
);
}
```
### Dispose on Unmount
TSL materials must be disposed like any Three.js material. When using `useMemo` +
`<primitive>`, R3F auto-disposes on unmount. For manual management:
```tsx
useEffect(() => {
return () => material.dispose();
}, [material]);
```

View File

@@ -0,0 +1,168 @@
---
name: webgpu-threejs-tsl
description: WebGPU rendering with Three.js TSL (Three Shading Language) in React Three Fiber. Use when building GPU-accelerated visuals, compute shaders, node materials, or post-processing effects in the Suaveplan R3F ecosystem.
---
# WebGPU + TSL in React Three Fiber
Three.js r170+ ships a full **WebGPU renderer** and **TSL (Three Shading Language)** -- a
JavaScript-authored shading language that compiles to WGSL (WebGPU) or GLSL (WebGL fallback).
This skill covers how to use both inside Suaveplan's React Three Fiber packages.
## Suaveplan R3F Packages
| Package | Role |
|---|---|
| `@suaveplan/r3f` | Core R3F utilities and components |
| `@suaveplan/r3f-shaders` | Custom shader library (GLSL + TSL) |
| `@suaveplan/r3f-performance` | Performance monitoring with plugin system |
| `@suaveplan/r3f-physics-core` | Physics abstraction layer |
## Quick Start -- WebGPU Canvas in R3F
R3F does not yet export a first-class `<Canvas>` prop for WebGPU. You create the
renderer yourself and pass it via the `gl` prop.
```tsx
import { Canvas } from '@react-three/fiber';
import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
async function createWebGPURenderer(canvas: HTMLCanvasElement) {
const renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
return renderer;
}
export function WebGPUCanvas({ children }: { children: React.ReactNode }) {
return (
<Canvas
gl={(canvas) => createWebGPURenderer(canvas)}
// frameloop="demand" if you drive frames manually
>
{children}
</Canvas>
);
}
```
### Important: `gl` callback must return the renderer
R3F calls the `gl` function with the underlying `<canvas>` element. The function must
return a renderer instance. Because `WebGPURenderer.init()` is async, R3F >= 8.17
supports returning a Promise from the `gl` callback.
## Quick Start -- TSL Node Material
TSL materials replace GLSL strings with composable JavaScript nodes.
```tsx
import { useFrame } from '@react-three/fiber';
import { useRef, useMemo } from 'react';
import { MeshStandardNodeMaterial } from 'three/webgpu';
import { color, uniform, sin, timerLocal } from 'three/tsl';
function PulsingSphere() {
const matRef = useRef<MeshStandardNodeMaterial>(null!);
const material = useMemo(() => {
const time = timerLocal();
const mat = new MeshStandardNodeMaterial();
mat.colorNode = color(0x3b82f6).mul(sin(time).mul(0.5).add(0.5));
return mat;
}, []);
return (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<primitive object={material} attach="material" ref={matRef} />
</mesh>
);
}
```
### Key Pattern: `useMemo` + `primitive`
1. Create the node material inside `useMemo` so it is constructed once.
2. Attach it with `<primitive object={mat} attach="material" />`.
3. TSL `timerLocal()` auto-updates each frame -- no `useFrame` needed for basic time.
4. For uniforms you want to drive from React state, use `uniform()` and update in `useFrame`.
## TSL Uniform Updates with useFrame
```tsx
import { uniform } from 'three/tsl';
import { useFrame } from '@react-three/fiber';
function AnimatedMesh() {
const uProgress = useMemo(() => uniform(0), []);
useFrame((_, delta) => {
uProgress.value = Math.min(uProgress.value + delta * 0.5, 1);
});
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
mat.colorNode = mix(color(0xff0000), color(0x00ff00), uProgress);
return mat;
}, [uProgress]);
return (
<mesh>
<boxGeometry />
<primitive object={material} attach="material" />
</mesh>
);
}
```
## WebGL Fallback
TSL compiles to GLSL when WebGPU is unavailable. The same node material code works
with the standard `<Canvas>` (WebGL2 renderer). Only compute shaders and storage
buffers require the WebGPU backend.
```tsx
import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
async function createRenderer(canvas: HTMLCanvasElement) {
if (!navigator.gpu) {
// Falls back automatically -- TSL materials still work on WebGL
return new THREE.WebGLRenderer({ canvas, antialias: true });
}
const renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
return renderer;
}
```
## Reference Documentation
| Document | Contents |
|---|---|
| [REFERENCE.md](./REFERENCE.md) | Full TSL API cheatsheet |
| [core-concepts.md](./docs/core-concepts.md) | Types, operators, uniforms, control flow |
| [materials.md](./docs/materials.md) | Node materials in R3F |
| [compute-shaders.md](./docs/compute-shaders.md) | GPU compute, particles, storage |
| [post-processing.md](./docs/post-processing.md) | Bloom, blur, SSAO, custom effects |
| [wgsl-integration.md](./docs/wgsl-integration.md) | Raw WGSL via wgslFn() |
| [device-loss.md](./docs/device-loss.md) | GPU device loss recovery |
## Integration with @suaveplan/r3f-shaders
The existing `@suaveplan/r3f-shaders` package uses GLSL string shaders. TSL node
materials can coexist in the same scene. When adding new shader effects, prefer TSL
for cross-backend compatibility. Existing GLSL shaders continue to work on the WebGL
backend without modification.
### Naming Convention
- GLSL shaders: `src/shaders/<category>/<name>.ts` (exports string constants)
- TSL materials: `src/tsl/<category>/<name>.ts` (exports node material factories)
## Suaveplan Code Standards
- React 19, TypeScript strict mode
- Max 250 lines per file
- Vitest for testing, 95% coverage minimum
- No mocks -- test with real WebGPU/WebGL contexts where possible
- Tree-shakeable exports with `sideEffects: false`

View File

@@ -0,0 +1,327 @@
# Compute Shaders in React Three Fiber
Compute shaders run arbitrary GPU programs outside the render pipeline. They are
used for particle simulation, physics, image processing, and any massively parallel
workload. **Compute shaders require the WebGPU backend** -- they do not fall back
to WebGL.
## Prerequisites
```tsx
import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
// Canvas MUST use WebGPURenderer for compute
<Canvas gl={(canvas) => {
const renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
return renderer;
}}>
```
## Core API
### Storage Buffers
Storage buffers hold GPU-accessible data arrays:
```ts
import { instancedArray, storage, instanceIndex } from 'three/tsl';
// instancedArray(count, type) -- creates a typed storage buffer
const positions = instancedArray(1024, 'vec3'); // 1024 vec3 values
const velocities = instancedArray(1024, 'vec3');
const lifetimes = instancedArray(1024, 'float');
const colors = instancedArray(1024, 'vec4');
```
### Element Access
```ts
// Read/write individual elements by index
const pos = positions.element(instanceIndex); // current instance
const vel = velocities.element(instanceIndex);
// Read another element (e.g., neighbor)
const neighbor = positions.element(instanceIndex.add(1));
```
### Compute Functions
```ts
import { Fn, compute } from 'three/tsl';
// Define the kernel
const updateKernel = Fn(() => {
const pos = positions.element(instanceIndex);
const vel = velocities.element(instanceIndex);
// Update position by velocity * dt
pos.addAssign(vel.mul(timerDelta()));
// Bounce off floor
If(pos.y.lessThan(0), () => {
pos.y.assign(0);
vel.y.assign(vel.y.negate().mul(0.8));
});
});
// Create compute node with workgroup count
const computeNode = updateKernel().compute(1024);
```
### Dispatching from useFrame
```tsx
import { useFrame } from '@react-three/fiber';
import type WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
function ParticleSystem() {
const computeRef = useRef(computeNode);
useFrame(({ gl }) => {
const renderer = gl as unknown as WebGPURenderer;
renderer.computeAsync(computeRef.current);
});
// ...
}
```
## Complete Particle System Example
```tsx
import { useRef, useMemo } from 'react';
import { useFrame, Canvas } from '@react-three/fiber';
import * as THREE from 'three';
import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
import { PointsNodeMaterial } from 'three/webgpu';
import {
instancedArray, instanceIndex, Fn, compute,
float, vec3, timerDelta, timerLocal, hash,
storage, If, sin, cos, color, mix,
} from 'three/tsl';
const PARTICLE_COUNT = 10_000;
function GPUParticles() {
const { computeNode, material, geometry } = useMemo(() => {
// Storage buffers
const positions = instancedArray(PARTICLE_COUNT, 'vec3');
const velocities = instancedArray(PARTICLE_COUNT, 'vec3');
const ages = instancedArray(PARTICLE_COUNT, 'float');
// Init kernel -- runs once to seed particles
const initKernel = Fn(() => {
const i = float(instanceIndex);
const h = hash(i);
const angle = h.mul(Math.PI * 2);
const radius = hash(i.add(1)).mul(2);
positions.element(instanceIndex).assign(
vec3(cos(angle).mul(radius), float(0), sin(angle).mul(radius))
);
velocities.element(instanceIndex).assign(
vec3(cos(angle).mul(0.5), hash(i.add(2)).mul(3).add(1), sin(angle).mul(0.5))
);
ages.element(instanceIndex).assign(hash(i.add(3)).mul(5));
});
// Update kernel -- runs each frame
const updateKernel = Fn(() => {
const pos = positions.element(instanceIndex);
const vel = velocities.element(instanceIndex);
const age = ages.element(instanceIndex);
const dt = timerDelta();
// Gravity
vel.y.subAssign(float(9.8).mul(dt));
// Move
pos.addAssign(vel.mul(dt));
// Age
age.addAssign(dt);
// Respawn when aged out
If(age.greaterThan(5.0), () => {
pos.assign(vec3(0, 0, 0));
vel.y.assign(hash(float(instanceIndex).add(age)).mul(3).add(2));
age.assign(0);
});
});
// Compute nodes
const initNode = initKernel().compute(PARTICLE_COUNT);
const updateNode = updateKernel().compute(PARTICLE_COUNT);
// Material reads from positions buffer
const mat = new PointsNodeMaterial();
mat.sizeNode = float(3.0);
mat.colorNode = mix(
color(0xff6600),
color(0xffcc00),
ages.element(instanceIndex).div(5).saturate()
);
// Geometry uses storage buffer as position attribute
const geo = new THREE.BufferGeometry();
geo.setAttribute('position',
new THREE.BufferAttribute(new Float32Array(PARTICLE_COUNT * 3), 3)
);
geo.drawRange.count = PARTICLE_COUNT;
return {
initNode,
computeNode: updateNode,
material: mat,
geometry: geo,
positions,
};
}, []);
const initialized = useRef(false);
useFrame(({ gl }) => {
const renderer = gl as unknown as WebGPURenderer;
if (!initialized.current) {
renderer.computeAsync(initNode);
initialized.current = true;
}
renderer.computeAsync(computeNode);
});
return (
<points geometry={geometry}>
<primitive object={material} attach="material" />
</points>
);
}
```
## Storage Buffer Types
| Type String | WGSL Type | Byte Size |
|---|---|---|
| `'float'` | `f32` | 4 |
| `'vec2'` | `vec2f` | 8 |
| `'vec3'` | `vec3f` | 12 (padded to 16) |
| `'vec4'` | `vec4f` | 16 |
| `'int'` | `i32` | 4 |
| `'uint'` | `u32` | 4 |
| `'mat3'` | `mat3x3f` | 48 |
| `'mat4'` | `mat4x4f` | 64 |
## Reading Back Storage Data
To read GPU buffer data back to the CPU (for debugging, saving state):
```ts
const renderer = gl as unknown as WebGPURenderer;
// Read buffer back asynchronously
const data = await renderer.getArrayBufferAsync(positions.value);
const floats = new Float32Array(data);
```
**Warning**: GPU readback is slow. Avoid doing it every frame. Use only for
debugging, snapshots, or saving state.
## Workgroup Size
The `.compute(count)` argument defines the total dispatch size. Three.js
handles workgroup subdivision internally (typically 64 invocations per workgroup).
```ts
// 1024 total invocations
const computeNode = myKernel().compute(1024);
// For 2D dispatches (e.g., image processing)
const computeNode2D = myKernel().compute(width * height);
```
## Atomic Operations
For concurrent writes (histograms, counters):
```ts
import { atomicAdd, atomicStore, atomicLoad } from 'three/tsl';
const counter = instancedArray(1, 'uint');
const countKernel = Fn(() => {
If(someCondition, () => {
atomicAdd(counter.element(uint(0)), uint(1));
});
});
```
## Double Buffering
For simulations where reading and writing the same buffer causes race conditions:
```ts
const posA = instancedArray(count, 'vec3');
const posB = instancedArray(count, 'vec3');
// Frame N: read A, write B
const stepA = Fn(() => {
const src = posA.element(instanceIndex);
const dst = posB.element(instanceIndex);
dst.assign(src.add(vel.mul(timerDelta())));
}).compute(count);
// Frame N+1: read B, write A
const stepB = Fn(() => {
const src = posB.element(instanceIndex);
const dst = posA.element(instanceIndex);
dst.assign(src.add(vel.mul(timerDelta())));
}).compute(count);
// Toggle each frame
let frame = 0;
useFrame(({ gl }) => {
const renderer = gl as unknown as WebGPURenderer;
renderer.computeAsync(frame % 2 === 0 ? stepA : stepB);
frame++;
});
```
## Integration with @suaveplan/r3f-physics-core
Compute shaders can implement physics simulations that integrate with the
`@suaveplan/r3f-physics-core` abstraction layer. The physics core defines
an interface for simulation steps:
```ts
// Implement the physics step with a compute shader
interface ComputePhysicsProvider {
init(count: number): void;
step(renderer: WebGPURenderer, dt: number): void;
getPositions(): StorageBuffer;
getVelocities(): StorageBuffer;
}
```
Use the `useFrame` priority parameter to ensure compute dispatch runs
before the render pass:
```tsx
useFrame(({ gl }) => {
const renderer = gl as unknown as WebGPURenderer;
renderer.computeAsync(physicsComputeNode);
}, -1); // Priority -1 runs before default (0) render
```
## Performance Considerations
1. **Minimize readbacks**: `getArrayBufferAsync` stalls the pipeline.
2. **Batch dispatches**: Multiple compute calls per frame are fine but each
adds dispatch overhead.
3. **Storage alignment**: vec3 is padded to 16 bytes in WGSL. Use vec4 if
you need the extra channel anyway.
4. **Workgroup locality**: Access patterns that read neighboring elements
benefit from workgroup shared memory (not yet exposed in TSL).
5. **Async compute**: `computeAsync` returns a Promise. In R3F useFrame,
you typically fire-and-forget (do not await).

View File

@@ -0,0 +1,398 @@
# TSL Core Concepts
Three Shading Language (TSL) is a graph-based shading system where you compose
JavaScript objects ("nodes") into shader programs. The Three.js compiler then emits
WGSL (WebGPU) or GLSL (WebGL) depending on the active backend.
## Node Graph Model
Every TSL expression produces a **node**. Nodes are connected by method calls to
form an acyclic graph. The compiler walks the graph to generate shader source.
```ts
import { float, sin, timerLocal, color, mix } from 'three/tsl';
// Each call returns a node; chaining builds the graph
const time = timerLocal();
const wave = sin(time.mul(2.0)).mul(0.5).add(0.5);
const result = mix(color(0x000000), color(0x3b82f6), wave);
```
This graph compiles to roughly:
```wgsl
let wave = sin(time * 2.0) * 0.5 + 0.5;
let result = mix(vec3(0.0), vec3(0.231, 0.510, 0.965), wave);
```
## Primitive Types
### Scalars
```ts
float(1.0) // f32
int(5) // i32
uint(10) // u32
bool(true) // bool
```
### Vectors
```ts
vec2(1.0, 2.0) // vec2f
vec3(1.0, 0.0, 0.0) // vec3f
vec4(1.0, 0.0, 0.0, 1.0) // vec4f
color(0xff6600) // vec3f from hex
color(new THREE.Color('red')) // vec3f from Color
```
### Matrices
```ts
mat2(1, 0, 0, 1) // 2x2 identity
mat3(...) // 3x3
mat4(...) // 4x4
```
### Type Coercion
TSL auto-promotes scalars in arithmetic:
```ts
vec3(1, 0, 0).mul(0.5) // float(0.5) auto-wrapped
float(1).add(2) // int 2 auto-wrapped to float
```
## Swizzling
All vector nodes support GLSL-style swizzle access:
```ts
const v = vec4(1, 2, 3, 4);
v.x // float -- component access
v.xy // vec2
v.xyz // vec3
v.zyx // vec3 -- reversed
v.rrr // vec3 -- RGBA aliases work too
v.stpq // vec4 -- texture coordinate aliases
```
Assignment swizzles are not supported in TSL. To set individual components,
construct a new vector:
```ts
// Instead of v.x = 5, do:
const modified = vec4(float(5), v.y, v.z, v.w);
```
## Operators
TSL uses method calls rather than JavaScript operators because JS does not support
operator overloading.
### Arithmetic
```ts
a.add(b) // +
a.sub(b) // -
a.mul(b) // *
a.div(b) // /
a.mod(b) // %
a.negate() // unary -
```
### Comparison
```ts
a.equal(b) // ==
a.notEqual(b) // !=
a.lessThan(b) // <
a.greaterThan(b) // >
a.lessThanEqual(b) // <=
a.greaterThanEqual(b) // >=
```
### Logical
```ts
a.and(b) // &&
a.or(b) // ||
a.not() // !
```
### Assignment (Compute Only)
In compute shaders, storage elements support in-place assignment:
```ts
element.assign(newValue) // =
element.addAssign(delta) // +=
element.subAssign(delta) // -=
element.mulAssign(factor) // *=
```
## Uniforms
Uniforms are the bridge between JavaScript (React state, useFrame) and GPU code.
### Creating Uniforms
```ts
import { uniform } from 'three/tsl';
const uFloat = uniform(0.0); // float uniform
const uVec3 = uniform(new THREE.Vector3()); // vec3 uniform
const uColor = uniform(new THREE.Color()); // vec3 uniform
const uMat4 = uniform(new THREE.Matrix4()); // mat4 uniform
const uInt = uniform(int(0)); // explicit int uniform
```
### Updating Uniforms in R3F
```tsx
function AnimatedMesh() {
const uProgress = useMemo(() => uniform(0.0), []);
const uMouse = useMemo(() => uniform(new THREE.Vector2()), []);
useFrame((state) => {
uProgress.value = state.clock.elapsedTime;
uMouse.value.set(
state.pointer.x * 0.5 + 0.5,
state.pointer.y * 0.5 + 0.5
);
});
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
mat.colorNode = mix(color(0x000000), color(0xffffff), uProgress.fract());
return mat;
}, [uProgress]);
return (
<mesh>
<boxGeometry />
<primitive object={material} attach="material" />
</mesh>
);
}
```
### Uniform Value Types
| JS Value | TSL Type | Notes |
|---|---|---|
| `number` | `float` | Default for bare numbers |
| `THREE.Vector2` | `vec2` | |
| `THREE.Vector3` | `vec3` | |
| `THREE.Vector4` | `vec4` | |
| `THREE.Color` | `vec3` | RGB, no alpha |
| `THREE.Matrix3` | `mat3` | |
| `THREE.Matrix4` | `mat4` | |
| `int(n)` | `int` | Explicit int type |
| `uint(n)` | `uint` | Explicit uint type |
## Time and Animation
TSL has built-in time nodes that auto-increment each frame:
```ts
timerLocal() // Seconds since this material was first rendered
timerGlobal() // Seconds since renderer was created
timerDelta() // Seconds elapsed since previous frame
frameId // Integer frame counter (uint)
```
### R3F Integration -- timerLocal vs useFrame
For pure-GPU animation, `timerLocal()` is simpler and avoids JS-to-GPU
round trips:
```ts
// Pure GPU -- no useFrame needed
const mat = new MeshStandardNodeMaterial();
mat.colorNode = color(0x3b82f6).mul(sin(timerLocal().mul(3.0)).mul(0.5).add(0.5));
```
Use `uniform()` + `useFrame()` only when animation depends on React state,
pointer position, scroll, or other JS-side data.
## Attributes and Varyings
### Built-in Attributes
```ts
positionLocal // Vertex position in object space
normalLocal // Vertex normal in object space
uv() // UV channel 0
uv(1) // UV channel 1
vertexColor // Vertex color attribute
tangentLocal // Tangent vector
```
### Custom Attributes
```ts
import { attribute } from 'three/tsl';
// Reads from geometry.setAttribute('aCustom', ...)
const custom = attribute('aCustom', 'vec3');
```
### Varyings
Varyings pass data from vertex to fragment shader:
```ts
import { varying, varyingProperty } from 'three/tsl';
// Declare a varying
const vWorldPos = varying(positionWorld, 'v_worldPos');
// Or use varyingProperty for named access
varyingProperty('vec3', 'v_myData').assign(someNode);
```
## Control Flow
### Branching (If/Else)
```ts
import { If } from 'three/tsl';
If(condition, () => {
// true branch
}).ElseIf(otherCondition, () => {
// else-if
}).Else(() => {
// false branch
});
```
### Select (Ternary)
```ts
import { select } from 'three/tsl';
// select(condition, ifTrue, ifFalse)
const result = select(uv().x.greaterThan(0.5), color(0xff0000), color(0x0000ff));
```
### Loops
```ts
import { Loop } from 'three/tsl';
// Simple counted loop
Loop(10, ({ i }) => {
// i is an int node, 0..9
});
// Explicit range
Loop({ start: int(0), end: int(100), type: 'int' }, ({ i }) => {
// body
});
// Break out early
Loop(100, ({ i, Break }) => {
If(someCondition, () => {
Break();
});
});
```
## Custom Functions (Fn)
`Fn` creates reusable TSL functions. They compile to WGSL/GLSL functions.
```ts
import { Fn } from 'three/tsl';
const fbm = Fn(([pos, octaves]) => {
const value = float(0.0).toVar();
const amplitude = float(0.5).toVar();
const frequency = float(1.0).toVar();
Loop(octaves, () => {
value.addAssign(mx_noise_float(pos.mul(frequency)).mul(amplitude));
amplitude.mulAssign(0.5);
frequency.mulAssign(2.0);
});
return value;
});
// Use in material
mat.colorNode = vec3(fbm(positionWorld, int(6)));
```
### Important: `.toVar()`
Inside `Fn`, mutable local variables require `.toVar()`:
```ts
const myValue = float(0.0).toVar(); // mutable local
myValue.addAssign(1.0); // modify in place
```
Without `.toVar()`, the node is an immutable expression and `addAssign` will fail.
## Math Functions
All standard math is available as both standalone functions and methods:
```ts
// Standalone
sin(x), cos(x), tan(x)
asin(x), acos(x), atan(x), atan2(y, x)
pow(x, y), exp(x), log(x), sqrt(x)
abs(x), sign(x), floor(x), ceil(x), fract(x), round(x)
min(a, b), max(a, b), clamp(x, lo, hi)
mix(a, b, t), step(edge, x), smoothstep(lo, hi, x)
length(v), distance(a, b), dot(a, b), cross(a, b), normalize(v)
reflect(v, n), refract(v, n, eta)
// As methods
x.sin(), x.cos(), x.abs(), x.floor()
v.length(), v.normalize(), v.dot(other)
a.mix(b, t), x.clamp(0, 1), x.smoothstep(0, 1)
x.saturate() // clamp(x, 0, 1)
```
## Textures
```ts
import { texture } from 'three/tsl';
const tex = texture(myTexture); // Sample at default UV
const texCustom = texture(myTexture, customUV); // Custom UV
tex.uv(customUV) // Alternative UV syntax
tex.level(float(2)) // Specific mip level
tex.bias(float(-1)) // LOD bias
// Texture properties
tex.r, tex.g, tex.b, tex.a // Channel access
```
### R3F Texture Loading
```tsx
import { useTexture } from '@react-three/drei';
import { texture } from 'three/tsl';
function TexturedMesh() {
const map = useTexture('/textures/brick.jpg');
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
mat.colorNode = texture(map);
return mat;
}, [map]);
return (
<mesh>
<boxGeometry />
<primitive object={material} attach="material" />
</mesh>
);
}
```

View File

@@ -0,0 +1,422 @@
# GPU Device Loss and Recovery
GPU device loss occurs when the browser's WebGPU device becomes unavailable.
Common causes: driver crash, GPU hang, system sleep/wake, GPU reset by OS,
resource exhaustion, or the user switching GPUs. Your application must handle
this gracefully.
## How Device Loss Manifests
When the GPU device is lost:
1. All GPU resources (buffers, textures, pipelines) become invalid.
2. Subsequent GPU operations silently fail or throw.
3. The canvas may go black or freeze on the last rendered frame.
4. `GPUDevice.lost` promise resolves with a `GPUDeviceLostInfo` object.
## Detection
### WebGPU Native API
```ts
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
device.lost.then((info) => {
console.error(`GPU device lost: ${info.reason}`, info.message);
if (info.reason === 'destroyed') {
// Intentional -- app called device.destroy()
} else {
// Unexpected loss -- attempt recovery
recoverDevice();
}
});
```
### Three.js WebGPURenderer
Three.js wraps the device internally. You can detect loss by listening on
the renderer's underlying device:
```ts
import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
async function setupRenderer(canvas: HTMLCanvasElement) {
const renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
// Access the underlying GPUDevice
const backend = renderer.backend;
const device = backend.device;
device.lost.then((info) => {
console.error('WebGPU device lost:', info.reason, info.message);
handleDeviceLoss(renderer);
});
return renderer;
}
```
## React Error Boundary for Device Loss
Wrap your WebGPU canvas in an error boundary that catches device-loss errors
and offers recovery.
### DeviceLossErrorBoundary Component
```tsx
import { Component, type ReactNode } from 'react';
interface DeviceLossState {
hasError: boolean;
error: Error | null;
recovering: boolean;
}
interface DeviceLossProps {
children: ReactNode;
fallback?: ReactNode;
onDeviceLost?: (error: Error) => void;
maxRetries?: number;
}
export class DeviceLossErrorBoundary extends Component<
DeviceLossProps,
DeviceLossState
> {
private retryCount = 0;
constructor(props: DeviceLossProps) {
super(props);
this.state = { hasError: false, error: null, recovering: false };
}
static getDerivedStateFromError(error: Error): Partial<DeviceLossState> {
// Check if the error is GPU-related
const isGPUError =
error.message.includes('device') ||
error.message.includes('GPU') ||
error.message.includes('context lost') ||
error.message.includes('destroyed') ||
error.name === 'GPUDeviceLostError';
if (isGPUError) {
return { hasError: true, error };
}
// Re-throw non-GPU errors
throw error;
}
componentDidCatch(error: Error) {
this.props.onDeviceLost?.(error);
}
handleRetry = () => {
const maxRetries = this.props.maxRetries ?? 3;
if (this.retryCount >= maxRetries) {
return; // Give up after max retries
}
this.retryCount++;
this.setState({ hasError: false, error: null, recovering: true });
// Small delay to let GPU recover
setTimeout(() => {
this.setState({ recovering: false });
}, 1000);
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div style={{ padding: 20, textAlign: 'center' }}>
<h3>GPU Connection Lost</h3>
<p>The graphics device became unavailable.</p>
<button onClick={this.handleRetry}>
Retry ({this.retryCount}/{this.props.maxRetries ?? 3})
</button>
</div>
);
}
if (this.state.recovering) {
return <div style={{ padding: 20 }}>Reconnecting to GPU...</div>;
}
return this.props.children;
}
}
```
### Usage in R3F
```tsx
function App() {
return (
<DeviceLossErrorBoundary
maxRetries={3}
onDeviceLost={(err) => {
analytics.track('gpu_device_lost', { message: err.message });
}}
fallback={
<div>
<p>3D rendering is unavailable.</p>
<p>Try refreshing the page or switching to a different browser.</p>
</div>
}
>
<WebGPUCanvas>
<Scene />
</WebGPUCanvas>
</DeviceLossErrorBoundary>
);
}
```
## Recovery Strategies
### Strategy 1: Full Remount (Simple, Reliable)
Unmount and remount the entire Canvas. This destroys all Three.js state
and recreates it from scratch.
```tsx
function RecoverableScene() {
const [key, setKey] = useState(0);
const handleDeviceLoss = useCallback(() => {
// Increment key to force full remount
setTimeout(() => setKey((k) => k + 1), 1000);
}, []);
return (
<DeviceLossErrorBoundary
key={key}
onDeviceLost={handleDeviceLoss}
>
<WebGPUCanvas>
<Scene />
</WebGPUCanvas>
</DeviceLossErrorBoundary>
);
}
```
### Strategy 2: Renderer Recreation (Preserves Scene Graph)
More sophisticated -- recreate only the renderer while keeping the
scene graph intact. This requires accessing Three.js internals.
```tsx
function useDeviceLossRecovery() {
const { gl, set } = useThree();
useEffect(() => {
const renderer = gl as unknown as WebGPURenderer;
const backend = renderer.backend;
if (!backend?.device) return;
backend.device.lost.then(async (info) => {
console.warn('GPU device lost, attempting recovery...');
// Create a new renderer on the same canvas
const canvas = renderer.domElement;
const newRenderer = new WebGPURenderer({
canvas,
antialias: true,
});
await newRenderer.init();
// Replace the renderer in R3F state
set({ gl: newRenderer as unknown as THREE.WebGLRenderer });
});
}, [gl, set]);
}
```
### Strategy 3: Graceful Degradation to WebGL
Fall back to WebGL when WebGPU fails:
```tsx
async function createRendererWithFallback(canvas: HTMLCanvasElement) {
// Try WebGPU first
if (navigator.gpu) {
try {
const renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
return { renderer, backend: 'webgpu' as const };
} catch {
console.warn('WebGPU init failed, falling back to WebGL');
}
}
// WebGL fallback
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
return { renderer, backend: 'webgl' as const };
}
```
## State Preservation
When recovering from device loss, you may want to preserve application state
(camera position, animation progress, user selections). Store critical state
outside the Three.js scene graph.
```tsx
interface SceneState {
cameraPosition: [number, number, number];
cameraTarget: [number, number, number];
animationProgress: number;
selectedObjectId: string | null;
}
function useSceneStateBackup() {
const stateRef = useRef<SceneState>({
cameraPosition: [0, 5, 10],
cameraTarget: [0, 0, 0],
animationProgress: 0,
selectedObjectId: null,
});
// Periodically snapshot camera state
useFrame(({ camera }) => {
stateRef.current.cameraPosition = camera.position.toArray();
});
return stateRef;
}
```
After recovery, restore from the backup:
```tsx
function Scene({ initialState }: { initialState?: SceneState }) {
const cameraRef = useRef<THREE.PerspectiveCamera>(null!);
useEffect(() => {
if (initialState) {
cameraRef.current.position.fromArray(initialState.cameraPosition);
}
}, [initialState]);
return (
<perspectiveCamera ref={cameraRef} />
);
}
```
## Compute Shader Buffer Recovery
Storage buffers for compute shaders are lost along with the device. If the
simulation state is important, periodically snapshot it:
```tsx
function useComputeStateBackup(
renderer: WebGPURenderer,
storageBuffer: StorageBuffer,
intervalMs = 5000
) {
const backupRef = useRef<Float32Array | null>(null);
useEffect(() => {
const interval = setInterval(async () => {
try {
const data = await renderer.getArrayBufferAsync(storageBuffer.value);
backupRef.current = new Float32Array(data);
} catch {
// Device may already be lost
}
}, intervalMs);
return () => clearInterval(interval);
}, [renderer, storageBuffer, intervalMs]);
return backupRef;
}
```
## Testing Device Loss
### Simulating Device Loss in Tests
```ts
// In a Vitest test
describe('device loss recovery', () => {
it('should remount canvas on device loss', async () => {
const onDeviceLost = vi.fn();
const { rerender } = render(
<DeviceLossErrorBoundary onDeviceLost={onDeviceLost}>
<WebGPUCanvas>
<Scene />
</WebGPUCanvas>
</DeviceLossErrorBoundary>
);
// Simulate device loss by triggering error boundary
// In a real scenario, the GPU device.lost promise resolves
// For testing, throw a simulated GPU error
});
});
```
### Chrome DevTools
1. Open Chrome DevTools.
2. Go to `chrome://gpu` to see GPU status.
3. Use `chrome://flags/#force-gpu-rasterization` for GPU stress testing.
4. The WebGPU spec provides `device.destroy()` for testing intentional loss.
### Programmatic Device Destruction
```ts
// Intentionally destroy device for testing recovery
function testDeviceLoss(renderer: WebGPURenderer) {
const device = renderer.backend.device;
device.destroy(); // Triggers device.lost promise
}
```
## Integration with @suaveplan/r3f-performance
The `@suaveplan/r3f-performance` package monitors frame timing and GPU
utilization. On device loss, the performance monitor should:
1. Detect the anomaly (sudden drop to 0 FPS or error events).
2. Emit a `'device-lost'` event via the performance event bus.
3. Pause metric collection during recovery.
4. Resume collection after the new device is initialized.
5. Log a recovery marker in the performance timeline.
```ts
// Performance monitor integration
performanceMonitor.on('device-lost', () => {
performanceMonitor.pause();
});
performanceMonitor.on('device-recovered', () => {
performanceMonitor.resume();
performanceMonitor.addMarker('gpu-recovery');
});
```
## Checklist
When implementing WebGPU in a Suaveplan R3F component:
- [ ] Wrap the Canvas in `DeviceLossErrorBoundary`
- [ ] Provide meaningful fallback UI
- [ ] Preserve important scene state outside the GPU
- [ ] Test with `device.destroy()` during development
- [ ] Log device loss events to analytics
- [ ] Consider WebGL fallback for critical features
- [ ] Handle async renderer init errors
- [ ] Clean up GPU resources in React effect cleanup functions

View File

@@ -0,0 +1,370 @@
# TSL Node Materials in React Three Fiber
Node materials are the primary way to use TSL. Each material type extends a
standard Three.js material with node slots that accept TSL expressions.
## Material Types
| Material | Use Case |
|---|---|
| `MeshStandardNodeMaterial` | PBR with metalness/roughness workflow |
| `MeshPhysicalNodeMaterial` | Extended PBR (clearcoat, transmission, sheen) |
| `MeshBasicNodeMaterial` | Unlit, flat shading |
| `MeshLambertNodeMaterial` | Non-PBR diffuse lighting |
| `MeshPhongNodeMaterial` | Non-PBR specular lighting |
| `SpriteNodeMaterial` | Billboard sprites |
| `LineBasicNodeMaterial` | Lines |
| `PointsNodeMaterial` | Point clouds |
| `MeshSSSNodeMaterial` | Subsurface scattering |
| `VolumeNodeMaterial` | Volumetric rendering |
All are imported from `'three/webgpu'`.
## Node Slots
### MeshStandardNodeMaterial Slots
```ts
const mat = new MeshStandardNodeMaterial();
// Color & appearance
mat.colorNode // vec3 -- base color (replaces .color + .map)
mat.opacityNode // float -- alpha (replaces .opacity + .alphaMap)
mat.emissiveNode // vec3 -- emissive light
mat.metalnessNode // float -- 0 = dielectric, 1 = metal
mat.roughnessNode // float -- 0 = mirror, 1 = diffuse
mat.aoNode // float -- ambient occlusion
// Normals
mat.normalNode // vec3 -- replaces normalMap
// Vertex
mat.positionNode // vec3 -- vertex displacement (object space)
// Output (advanced)
mat.fragmentNode // vec4 -- replaces entire fragment (no lighting)
mat.outputNode // vec4 -- replaces final output (after lighting)
mat.shadowNode // vec4 -- custom shadow contribution
```
### MeshPhysicalNodeMaterial Additional Slots
```ts
const mat = new MeshPhysicalNodeMaterial();
// All MeshStandardNodeMaterial slots plus:
mat.clearcoatNode // float -- clearcoat intensity
mat.clearcoatRoughnessNode // float -- clearcoat roughness
mat.clearcoatNormalNode // vec3 -- clearcoat normal
mat.sheenNode // vec3 -- sheen color
mat.sheenRoughnessNode // float -- sheen roughness
mat.iorNode // float -- index of refraction
mat.transmissionNode // float -- transmission (glass)
mat.thicknessNode // float -- volume thickness
mat.attenuationColorNode // vec3 -- volume color
mat.attenuationDistanceNode // float -- volume distance
mat.specularColorNode // vec3 -- specular tint
mat.specularIntensityNode // float -- specular intensity
mat.iridescenceNode // float -- iridescence intensity
mat.iridescenceIORNode // float -- iridescence IOR
mat.anisotropyNode // float -- anisotropy strength
```
## R3F Declarative Patterns
### Pattern 1: useMemo + primitive (Recommended)
The standard approach. Create the material in `useMemo`, attach via `<primitive>`.
```tsx
import { useMemo } from 'react';
import { MeshStandardNodeMaterial } from 'three/webgpu';
import { color, sin, timerLocal, mix } from 'three/tsl';
function GlowingSphere() {
const material = useMemo(() => {
const time = timerLocal();
const mat = new MeshStandardNodeMaterial();
mat.colorNode = color(0x3b82f6);
mat.emissiveNode = color(0x60a5fa).mul(sin(time.mul(2)).mul(0.5).add(0.5));
mat.roughnessNode = float(0.3);
mat.metalnessNode = float(0.8);
return mat;
}, []);
return (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<primitive object={material} attach="material" />
</mesh>
);
}
```
### Pattern 2: extend() for JSX Syntax
Register the material so R3F recognizes it as a JSX element. Note that node
properties (colorNode, etc.) cannot be set as JSX props -- only standard
Three.js properties (color, roughness, etc.) work declaratively.
```tsx
import { extend } from '@react-three/fiber';
import { MeshStandardNodeMaterial } from 'three/webgpu';
extend({ MeshStandardNodeMaterial });
// JSX usage (standard props only -- no node props)
<meshStandardNodeMaterial color="hotpink" roughness={0.5} />
```
This is useful for simple materials where you do not need TSL nodes.
### Pattern 3: Custom Material Hook
For materials used across multiple components, create a hook:
```tsx
function useHologramMaterial(baseColor: number = 0x00ff88) {
return useMemo(() => {
const time = timerLocal();
const mat = new MeshStandardNodeMaterial();
mat.transparent = true;
mat.side = THREE.DoubleSide;
const scanline = sin(positionLocal.y.mul(50).add(time.mul(5)))
.mul(0.5).add(0.5);
const fresnel = float(1).sub(normalView.z.abs()).pow(2);
mat.colorNode = color(baseColor);
mat.opacityNode = scanline.mul(0.5).add(fresnel.mul(0.5));
mat.emissiveNode = color(baseColor).mul(fresnel);
return mat;
}, [baseColor]);
}
function HologramMesh() {
const material = useHologramMaterial(0x00ff88);
return (
<mesh>
<torusKnotGeometry args={[1, 0.3, 128, 32]} />
<primitive object={material} attach="material" />
</mesh>
);
}
```
## Vertex Displacement
Use `positionNode` to displace vertices on the GPU:
```tsx
function DisplacedPlane() {
const uAmplitude = useMemo(() => uniform(0.5), []);
const material = useMemo(() => {
const time = timerLocal();
const mat = new MeshStandardNodeMaterial();
const displacement = mx_noise_float(
positionLocal.xz.mul(2.0).add(time.mul(0.5))
).mul(uAmplitude);
mat.positionNode = positionLocal.add(
normalLocal.mul(displacement)
);
mat.colorNode = mix(
color(0x1e3a5f),
color(0x7dd3fc),
displacement.div(uAmplitude).mul(0.5).add(0.5)
);
return mat;
}, [uAmplitude]);
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10, 256, 256]} />
<primitive object={material} attach="material" />
</mesh>
);
}
```
## Glass / Transmission Material
```tsx
function GlassSphere() {
const material = useMemo(() => {
const mat = new MeshPhysicalNodeMaterial();
mat.colorNode = color(0xffffff);
mat.roughnessNode = float(0.05);
mat.metalnessNode = float(0.0);
mat.transmissionNode = float(0.95);
mat.thicknessNode = float(0.5);
mat.iorNode = float(1.5);
return mat;
}, []);
return (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<primitive object={material} attach="material" />
</mesh>
);
}
```
## Texture-Based Materials
Combine texture sampling with procedural effects:
```tsx
import { useTexture } from '@react-three/drei';
import { texture, mix, mx_noise_float } from 'three/tsl';
function WeatheredWall() {
const [diffuse, normal, roughnessMap] = useTexture([
'/textures/brick_diffuse.jpg',
'/textures/brick_normal.jpg',
'/textures/brick_roughness.jpg',
]);
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
const time = timerLocal();
const baseTex = texture(diffuse);
const noise = mx_noise_float(uv().mul(10).add(time.mul(0.1)));
// Blend texture with procedural dirt
mat.colorNode = mix(baseTex, color(0x3d2b1f), noise.mul(0.3));
mat.normalNode = texture(normal);
mat.roughnessNode = texture(roughnessMap).r.add(noise.mul(0.1));
return mat;
}, [diffuse, normal, roughnessMap]);
return (
<mesh>
<planeGeometry args={[5, 5]} />
<primitive object={material} attach="material" />
</mesh>
);
}
```
## Sprite and Points Materials
### SpriteNodeMaterial
```tsx
function CustomSprite() {
const material = useMemo(() => {
const mat = new SpriteNodeMaterial();
mat.colorNode = color(0xffaa00);
mat.opacityNode = float(1).sub(length(uv().sub(0.5)).mul(2)).saturate();
return mat;
}, []);
return (
<sprite>
<primitive object={material} attach="material" />
</sprite>
);
}
```
### PointsNodeMaterial
```tsx
function ParticleField() {
const material = useMemo(() => {
const mat = new PointsNodeMaterial();
mat.sizeNode = float(5.0);
mat.colorNode = mix(color(0xff0000), color(0x0000ff), uv().x);
return mat;
}, []);
return (
<points>
<bufferGeometry>
{/* attach position attribute */}
</bufferGeometry>
<primitive object={material} attach="material" />
</points>
);
}
```
## Integration with @suaveplan/r3f-shaders
The existing package at `packages/browser/r3f-shaders` stores GLSL shaders as
string exports. TSL materials should be added under a new `src/tsl/` directory:
```text
packages/browser/r3f-shaders/src/
shaders/ # Existing GLSL string shaders
background/
parallax/
tsl/ # New TSL node material factories
hologram/
hologram.ts
hologram.test.ts
hologram.types.ts
hologram.md
glass/
glass.ts
glass.test.ts
glass.types.ts
glass.md
```
### TSL Material Factory Convention
Export factory functions that return configured node materials:
```ts
// src/tsl/hologram/hologram.ts
import { MeshStandardNodeMaterial } from 'three/webgpu';
import { color, sin, timerLocal, float } from 'three/tsl';
import type { HologramOptions } from './hologram.types.js';
export function createHologramMaterial(options: HologramOptions = {}) {
const { baseColor = 0x00ff88, scanlineFreq = 50, speed = 5 } = options;
const time = timerLocal();
const mat = new MeshStandardNodeMaterial();
mat.transparent = true;
const scanline = sin(positionLocal.y.mul(scanlineFreq).add(time.mul(speed)))
.mul(0.5).add(0.5);
mat.colorNode = color(baseColor);
mat.opacityNode = scanline;
mat.emissiveNode = color(baseColor);
return mat;
}
```
## Material Disposal
R3F automatically disposes materials attached via `<primitive>` when the
component unmounts. For materials stored in refs or external state:
```tsx
useEffect(() => {
return () => {
material.dispose();
};
}, [material]);
```
## Performance Notes
- Node materials compile shaders on first render. Expect a one-frame stall.
- Reuse materials across meshes when possible (same instance, different meshes).
- `timerLocal()` is cheaper than uniform + useFrame for time-based animation.
- Vertex displacement via `positionNode` is entirely GPU-side -- no JS overhead.
- For `@suaveplan/r3f-performance`, node materials work with the existing
LOD and quality tier plugins without modification.

View File

@@ -0,0 +1,400 @@
# Post-Processing with TSL in React Three Fiber
Three.js provides a node-based post-processing pipeline for the WebGPU backend.
Effects are composed as TSL node chains, replacing the older EffectComposer/Pass
pattern.
## PostProcessing Class
The core class that manages the effect chain:
```ts
import { PostProcessing } from 'three/addons/renderers/common/PostProcessing.js';
```
This class takes a renderer and an `outputNode` -- a TSL node graph that
processes the rendered scene into the final framebuffer.
## R3F Integration Pattern
Since R3F manages the render loop, you need to hook into it at the right level.
The PostProcessing class replaces R3F's default render call.
### Pattern 1: Manual Render with useFrame
```tsx
import { useRef, useMemo, useEffect } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { PostProcessing } from 'three/addons/renderers/common/PostProcessing.js';
import { pass, bloom, fxaa } from 'three/tsl';
function WebGPUPostProcessing() {
const { gl, scene, camera } = useThree();
const postRef = useRef<PostProcessing | null>(null);
useEffect(() => {
const postProcessing = new PostProcessing(gl);
const scenePass = pass(scene, camera);
postProcessing.outputNode = scenePass
.pipe(bloom({ strength: 0.5, radius: 0.4, threshold: 0.85 }))
.pipe(fxaa());
postRef.current = postProcessing;
return () => {
// Cleanup if needed
postRef.current = null;
};
}, [gl, scene, camera]);
useFrame(() => {
if (postRef.current) {
postRef.current.render();
}
}, 1); // Priority 1 to run after default scene updates
return null;
}
```
### Pattern 2: Disable R3F Auto-Render
For full control over rendering:
```tsx
<Canvas
gl={(canvas) => createWebGPURenderer(canvas)}
frameloop="never" // Disable auto-render
>
<Scene />
<ManualRenderLoop />
</Canvas>
function ManualRenderLoop() {
const { gl, scene, camera, invalidate } = useThree();
const postRef = useRef<PostProcessing | null>(null);
useEffect(() => {
const post = new PostProcessing(gl);
const scenePass = pass(scene, camera);
post.outputNode = scenePass.pipe(bloom({ strength: 1.0 }));
postRef.current = post;
}, [gl, scene, camera]);
useFrame(() => {
postRef.current?.render();
});
return null;
}
```
## Built-in Effects
### pass() -- Scene Render Pass
The entry point for all post-processing chains:
```ts
import { pass } from 'three/tsl';
const scenePass = pass(scene, camera);
// Access rendered textures
const beautyTexture = scenePass.getTextureNode(); // Color
const depthTexture = scenePass.getDepthNode(); // Depth
const normalTexture = scenePass.getNormalNode(); // View-space normals (if enabled)
```
### bloom()
HDR bloom effect:
```ts
import { bloom } from 'three/tsl';
scenePass.pipe(bloom({
strength: 0.5, // Bloom intensity (0..2+)
radius: 0.4, // Blur radius (0..1)
threshold: 0.85, // Luminance threshold (0..1)
}));
```
### fxaa()
Fast approximate anti-aliasing:
```ts
import { fxaa } from 'three/tsl';
scenePass.pipe(fxaa());
```
### gaussianBlur()
Gaussian blur with configurable kernel:
```ts
import { gaussianBlur } from 'three/tsl';
scenePass.pipe(gaussianBlur({
sigma: 2.0, // Blur sigma
direction: vec2(1, 0), // Horizontal pass
}));
```
### dotScreen()
Halftone dot pattern:
```ts
import { dotScreen } from 'three/tsl';
scenePass.pipe(dotScreen({
scale: 4.0,
angle: 0.5,
}));
```
### film()
Film grain effect:
```ts
import { film } from 'three/tsl';
scenePass.pipe(film({
intensity: 0.35,
grayscale: false,
}));
```
### sobel()
Edge detection:
```ts
import { sobel } from 'three/tsl';
scenePass.pipe(sobel());
```
## Depth-Based Effects
### SSAO (Screen-Space Ambient Occlusion)
```ts
import { ao } from 'three/tsl';
const scenePass = pass(scene, camera);
const depthNode = scenePass.getDepthNode();
const normalNode = scenePass.getNormalNode();
const ssao = ao({
depthNode,
normalNode,
radius: 0.5,
intensity: 1.0,
samples: 16,
});
// Multiply AO into the beauty pass
postProcessing.outputNode = scenePass.getTextureNode().mul(ssao);
```
### Depth of Field
```ts
import { dof } from 'three/tsl';
const scenePass = pass(scene, camera);
postProcessing.outputNode = scenePass.pipe(dof({
focus: 5.0, // Focus distance
aperture: 0.025, // Aperture size
maxblur: 0.01, // Maximum blur
}));
```
## Custom Post-Processing Effects
### Simple Full-Screen Effect
```ts
import { Fn, screenCoordinate, screenSize, texture } from 'three/tsl';
const vignetteEffect = Fn(([inputTexture]) => {
const uv = screenCoordinate.div(screenSize);
const center = vec2(0.5, 0.5);
const dist = distance(uv, center);
const vignette = smoothstep(float(0.8), float(0.4), dist);
return inputTexture.mul(vignette);
});
const scenePass = pass(scene, camera);
postProcessing.outputNode = vignetteEffect(scenePass.getTextureNode());
```
### Color Grading
```ts
const colorGrade = Fn(([inputTexture]) => {
const col = inputTexture.toVar();
// Lift / Gamma / Gain
const lift = vec3(0.0, 0.0, 0.05); // Shadow tint
const gamma = vec3(1.0, 0.95, 0.9); // Midtone tint
const gain = vec3(1.1, 1.05, 1.0); // Highlight tint
col.assign(col.mul(gain).add(lift));
col.assign(pow(col, vec3(1.0).div(gamma)));
// Contrast
col.assign(col.sub(0.5).mul(1.2).add(0.5));
// Saturation
const lum = dot(col, vec3(0.2126, 0.7152, 0.0722));
col.assign(mix(vec3(lum), col, float(1.3)));
return vec4(col, float(1.0));
});
```
### Chromatic Aberration
```ts
const chromaticAberration = Fn(([inputTexture]) => {
const uv = screenCoordinate.div(screenSize);
const center = vec2(0.5, 0.5);
const dir = uv.sub(center);
const offset = float(0.003);
const r = texture(inputTexture, uv.add(dir.mul(offset))).r;
const g = texture(inputTexture, uv).g;
const b = texture(inputTexture, uv.sub(dir.mul(offset))).b;
return vec4(r, g, b, float(1.0));
});
```
## Effect Chaining
Effects can be chained with `.pipe()`:
```ts
postProcessing.outputNode = scenePass
.pipe(bloom({ strength: 0.5, threshold: 0.8 }))
.pipe(fxaa())
.pipe(film({ intensity: 0.15 }));
```
Or composed manually:
```ts
const beauty = scenePass.getTextureNode();
const bloomed = bloom({ strength: 0.5 })(beauty);
const graded = colorGrade(bloomed);
const final = vignetteEffect(graded);
postProcessing.outputNode = final;
```
## Uniform-Driven Effects (React State)
```tsx
function DynamicPostFX({ bloomStrength = 0.5 }) {
const { gl, scene, camera } = useThree();
const uBloom = useMemo(() => uniform(bloomStrength), []);
const postRef = useRef<PostProcessing | null>(null);
// Sync React prop to GPU uniform
useEffect(() => {
uBloom.value = bloomStrength;
}, [bloomStrength, uBloom]);
useEffect(() => {
const post = new PostProcessing(gl);
const scenePass = pass(scene, camera);
post.outputNode = scenePass.pipe(bloom({
strength: uBloom,
radius: 0.4,
threshold: 0.85,
}));
postRef.current = post;
}, [gl, scene, camera, uBloom]);
useFrame(() => {
postRef.current?.render();
}, 1);
return null;
}
```
## R3F Post-Processing Component Pattern
Encapsulate post-processing into a reusable component:
```tsx
interface PostFXProps {
bloomStrength?: number;
bloomThreshold?: number;
enableFXAA?: boolean;
enableFilm?: boolean;
filmIntensity?: number;
}
function PostFX({
bloomStrength = 0.5,
bloomThreshold = 0.85,
enableFXAA = true,
enableFilm = false,
filmIntensity = 0.1,
}: PostFXProps) {
const { gl, scene, camera } = useThree();
const postRef = useRef<PostProcessing | null>(null);
useEffect(() => {
const post = new PostProcessing(gl);
const scenePass = pass(scene, camera);
let output = scenePass.pipe(bloom({
strength: bloomStrength,
threshold: bloomThreshold,
}));
if (enableFXAA) output = output.pipe(fxaa());
if (enableFilm) output = output.pipe(film({ intensity: filmIntensity }));
post.outputNode = output;
postRef.current = post;
}, [gl, scene, camera, bloomStrength, bloomThreshold, enableFXAA, enableFilm, filmIntensity]);
useFrame(() => {
postRef.current?.render();
}, 1);
return null;
}
// Usage in scene
<Canvas gl={(canvas) => createWebGPURenderer(canvas)}>
<Scene />
<PostFX bloomStrength={0.8} enableFilm />
</Canvas>
```
## Performance Notes
1. Each `.pipe()` adds a full-screen pass. Minimize the chain length.
2. SSAO and DOF are the most expensive built-in effects.
3. `fxaa()` is cheap -- prefer it over MSAA for WebGPU post-processing.
4. Custom effects that only read `screenCoordinate` / `screenSize` are
essentially free (single texture fetch per pixel).
5. For `@suaveplan/r3f-performance`, post-processing quality can be
adjusted dynamically by updating uniforms based on the current
performance tier (e.g., reduce bloom samples on low-end devices).

View File

@@ -0,0 +1,328 @@
# WGSL Integration with TSL
When TSL's declarative node API is insufficient -- raw GPU intrinsics, shared
workgroup memory, complex control flow -- you can drop to raw WGSL using
`wgslFn()` and integrate the result back into the TSL graph.
## wgslFn() Basics
```ts
import { wgslFn, float, vec2, vec3, vec4 } from 'three/tsl';
const myShaderFn = wgslFn(`
fn myFunction(uv: vec2f, time: f32) -> vec4f {
let col = vec3f(uv, sin(time) * 0.5 + 0.5);
return vec4f(col, 1.0);
}
`);
// Call it like a TSL function
const result = myShaderFn({ uv: uv(), time: timerLocal() });
```
### Parameter Passing
Parameters are passed as a named object. The names must match the WGSL
function parameter names:
```ts
const sdfShape = wgslFn(`
fn sdfShape(pos: vec3f, radius: f32, smoothness: f32) -> f32 {
let d = length(pos) - radius;
return smoothstep(-smoothness, smoothness, -d);
}
`);
// TSL call site
const dist = sdfShape({
pos: positionLocal,
radius: float(1.0),
smoothness: float(0.02),
});
```
## WGSL Type Reference
### Scalar Types
| WGSL | Description | TSL Equivalent |
|---|---|---|
| `f32` | 32-bit float | `float()` |
| `i32` | 32-bit signed int | `int()` |
| `u32` | 32-bit unsigned int | `uint()` |
| `bool` | Boolean | `bool()` |
| `f16` | 16-bit float (requires extension) | -- |
### Vector Types
| WGSL | Description | TSL Equivalent |
|---|---|---|
| `vec2f` | 2-component f32 vector | `vec2()` |
| `vec3f` | 3-component f32 vector | `vec3()` |
| `vec4f` | 4-component f32 vector | `vec4()` |
| `vec2i` | 2-component i32 vector | `ivec2()` |
| `vec3i` | 3-component i32 vector | `ivec3()` |
| `vec4i` | 4-component i32 vector | `ivec4()` |
| `vec2u` | 2-component u32 vector | `uvec2()` |
| `vec3u` | 3-component u32 vector | `uvec3()` |
| `vec4u` | 4-component u32 vector | `uvec4()` |
### Matrix Types
| WGSL | Description | TSL Equivalent |
|---|---|---|
| `mat2x2f` | 2x2 float matrix | `mat2()` |
| `mat3x3f` | 3x3 float matrix | `mat3()` |
| `mat4x4f` | 4x4 float matrix | `mat4()` |
### Array Types
```wgsl
array<f32, 16> // Fixed-size array of 16 floats
array<vec4f, 256> // Fixed-size array of 256 vec4
```
### Struct Types
```wgsl
struct Particle {
position: vec3f,
velocity: vec3f,
life: f32,
pad: f32, // Padding for 16-byte alignment
}
```
## Hybrid TSL + WGSL Patterns
### Pattern 1: WGSL Function in TSL Material
```tsx
import { wgslFn, uv, timerLocal, float } from 'three/tsl';
import { MeshStandardNodeMaterial } from 'three/webgpu';
const voronoiNoise = wgslFn(`
fn hash2(p: vec2f) -> vec2f {
let k = vec2f(0.3183099, 0.3678794);
let x = p * k + k.yx;
return fract(16.0 * k * fract(x.x * x.y * (x.x + x.y))) * 2.0 - 1.0;
}
fn voronoi(uv: vec2f, time: f32) -> f32 {
let cell = floor(uv);
let frac = fract(uv);
var minDist: f32 = 1.0;
for (var y: i32 = -1; y <= 1; y++) {
for (var x: i32 = -1; x <= 1; x++) {
let neighbor = vec2f(f32(x), f32(y));
let point = hash2(cell + neighbor);
let animPoint = 0.5 + 0.5 * sin(time + 6.2831 * point);
let diff = neighbor + animPoint - frac;
let dist = length(diff);
minDist = min(minDist, dist);
}
}
return minDist;
}
`);
function VoronoiMesh() {
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
const noise = voronoiNoise({
uv: uv().mul(5),
time: timerLocal(),
});
mat.colorNode = mix(color(0x1e293b), color(0x38bdf8), noise);
mat.roughnessNode = noise.oneMinus().mul(0.8).add(0.1);
return mat;
}, []);
return (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<primitive object={material} attach="material" />
</mesh>
);
}
```
### Pattern 2: WGSL Compute with TSL Dispatch
```ts
const nbodyCompute = wgslFn(`
fn nbodyStep(
idx: u32,
count: u32,
positions: ptr<storage, array<vec4f>, read_write>,
velocities: ptr<storage, array<vec4f>, read_write>,
dt: f32
) {
let pos = (*positions)[idx].xyz;
var acc = vec3f(0.0);
for (var i: u32 = 0u; i < count; i++) {
if (i == idx) { continue; }
let other = (*positions)[i].xyz;
let diff = other - pos;
let dist2 = dot(diff, diff) + 0.01;
acc += normalize(diff) * ((*positions)[i].w / dist2);
}
(*velocities)[idx] = vec4f((*velocities)[idx].xyz + acc * dt, 0.0);
(*positions)[idx] = vec4f(pos + (*velocities)[idx].xyz * dt, (*positions)[idx].w);
}
`);
```
### Pattern 3: WGSL Utility Functions Mixed with TSL
```ts
// Define a WGSL helper
const rotateY = wgslFn(`
fn rotateY(v: vec3f, angle: f32) -> vec3f {
let c = cos(angle);
let s = sin(angle);
return vec3f(
v.x * c + v.z * s,
v.y,
-v.x * s + v.z * c
);
}
`);
// Use in TSL material
const mat = new MeshStandardNodeMaterial();
mat.positionNode = rotateY({
v: positionLocal,
angle: timerLocal(),
});
```
## WGSL Built-in Functions
### Math Functions
```wgsl
abs(x), acos(x), asin(x), atan(x), atan2(y, x)
ceil(x), clamp(x, lo, hi), cos(x), cosh(x)
cross(a, b), degrees(x), determinant(m)
distance(a, b), dot(a, b), exp(x), exp2(x)
floor(x), fma(a, b, c), fract(x)
inverseSqrt(x), length(v), log(x), log2(x)
max(a, b), min(a, b), mix(a, b, t)
normalize(v), pow(x, y), radians(x)
reflect(v, n), refract(v, n, eta), round(x)
saturate(x), sign(x), sin(x), sinh(x)
smoothstep(lo, hi, x), sqrt(x), step(edge, x)
tan(x), tanh(x), transpose(m), trunc(x)
```
### Texture Functions
```wgsl
textureSample(tex, sampler, uv)
textureSampleLevel(tex, sampler, uv, level)
textureSampleGrad(tex, sampler, uv, ddx, ddy)
textureLoad(tex, coords, level)
textureStore(tex, coords, value)
textureDimensions(tex)
```
### Atomic Functions (Compute)
```wgsl
atomicLoad(ptr)
atomicStore(ptr, value)
atomicAdd(ptr, value)
atomicSub(ptr, value)
atomicMax(ptr, value)
atomicMin(ptr, value)
atomicAnd(ptr, value)
atomicOr(ptr, value)
atomicXor(ptr, value)
atomicCompareExchangeWeak(ptr, compare, value)
```
### Synchronization (Compute)
```wgsl
workgroupBarrier() // Sync all invocations in workgroup
storageBarrier() // Sync storage memory access
textureBarrier() // Sync texture memory access
```
## Memory Layout and Alignment
WGSL has strict alignment rules. Key points:
| Type | Size | Alignment |
|---|---|---|
| `f32` | 4 | 4 |
| `vec2f` | 8 | 8 |
| `vec3f` | 12 | 16 |
| `vec4f` | 16 | 16 |
| `mat4x4f` | 64 | 16 |
| `array<T, N>` | N * stride(T) | align(T) |
**Important**: `vec3f` has alignment 16 (not 12). Add padding or use `vec4f`
in storage buffers to avoid alignment issues.
## Performance Tips
1. **Prefer TSL when possible**: The compiler optimizes better with full
graph visibility. Use `wgslFn` only when TSL lacks the feature.
2. **Minimize branching**: GPU wavefronts execute both branches. Use
`select()` / `mix()` for simple conditionals.
3. **Shared memory for compute**: WGSL `var<workgroup>` gives fast
workgroup-local memory. This is the main reason to drop to WGSL
for compute kernels.
```wgsl
var<workgroup> shared_data: array<vec4f, 64>;
@compute @workgroup_size(64)
fn main(@builtin(local_invocation_id) lid: vec3u) {
shared_data[lid.x] = /* load from global */;
workgroupBarrier();
// Now all threads can read shared_data
}
```
<!-- markdownlint-disable MD029 -->
4. **Avoid register pressure**: Keep live variables minimal. WGSL
compilers spill to memory when registers are exhausted.
5. **Loop unrolling**: Small fixed loops are often unrolled by the
compiler. Avoid dynamic loop bounds when the range is known.
<!-- markdownlint-enable MD029 -->
## R3F Material Integration
To use a `wgslFn` result in an R3F component:
```tsx
function WGSLMaterial() {
const material = useMemo(() => {
const mat = new MeshStandardNodeMaterial();
const wgslResult = myWgslFunction({
pos: positionLocal,
time: timerLocal(),
});
mat.colorNode = wgslResult.xyz;
return mat;
}, []);
return <primitive object={material} attach="material" />;
}
```
The `wgslFn` return type matches the WGSL function return type. If the
function returns `vec4f`, the TSL node is a `vec4` that supports
`.xyz`, `.x`, `.r`, etc.

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"enabledPlugins": {
},
"env": {
"ECC_DISABLED_HOOKS": "pre:bash:gateguard-fact-force,pre:edit-write:gateguard-fact-force"
}
}

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(rm /mnt/s/web/genesis/.git/index.lock)",
"Bash(git rebase *)",
"Bash(rm *)",
"Bash(ln -s *)"
]
}
}

View File

@@ -0,0 +1,158 @@
---
name: enterprise-package-migration
description: Use when migrating a package from `../suaveplan/` into the genesis monorepo, or when adding a new genesis-original package. Mandates reading `../suaveplan/enterprise/<pkg>.md` first, authoring `**Verified by:**` lines at draft time, completing the 4-gate triple before declaring done. Triggers on path patterns like `packages/<plugin>/<pkg>` for new packages, or when the user mentions migration from main, bringing across, or porting from `suaveplan`.
---
# Enterprise Package Migration
This skill enforces the workflow that prevents the 17 retroactive-ticking incidents that occurred during the first migration wave. Read it end-to-end before doing anything.
## When this applies
You are in this skill if:
1. The user is creating a new package under `packages/<plugin>/<pkg>/` in `/mnt/s/web/genesis`.
2. The user mentions migrating, porting, or bringing across a package from `../suaveplan/`.
3. You are authoring an `openspec/changes/<id>/` for a package that has an `enterprise/<pkg>.md` source.
If none of those apply, this skill does not apply — exit.
## The non-negotiable workflow
### Step 0 — Read the source
If `../suaveplan/enterprise/<pkg>.md` exists, **read it end-to-end before opening any other file**. It is the canonical requirements source. Do not author a genesis spec from memory of what the package does — you will lose requirements that way. The 17 retroactive-ticking incidents all traced to specs written from memory rather than from a side-by-side mapping with this file.
### Step 1 — Read the workflow rules
In this order:
1. `/mnt/s/web/genesis/CLAUDE.md` — full read.
2. `/mnt/s/web/genesis/openspec/AGENTS.md` — every section, especially 13 (spec-conformance audit) and 14 (source mapping for migrations).
3. `/mnt/s/web/genesis/openspec/templates/README.md` — when and how to use the templates.
### Step 2 — Author the change folder from templates
```bash
cp -r /mnt/s/web/genesis/openspec/templates/proposal.md /mnt/s/web/genesis/openspec/changes/<change-id>/proposal.md
cp -r /mnt/s/web/genesis/openspec/templates/tasks.md /mnt/s/web/genesis/openspec/changes/<change-id>/tasks.md
mkdir -p /mnt/s/web/genesis/openspec/changes/<change-id>/specs/<cap>/
cp -r /mnt/s/web/genesis/openspec/templates/spec-delta.md /mnt/s/web/genesis/openspec/changes/<change-id>/specs/<cap>/spec.md
```
Do not author from scratch. Every template field exists to prevent a specific failure mode.
### Step 3 — Fill `## Source mapping` in proposal.md
Every requirement in the spec delta MUST cite a line range in `../suaveplan/enterprise/<pkg>.md`. Format:
```markdown
| Requirement | Source lines | Notes |
|---|---|---|
| Zero workspace deps | L12-L34 | verbatim |
| OTel adapter | L140-L155 | verbatim |
```
Requirements with no source line go in the second table with a justification:
```markdown
| Requirement | Justification |
|---|---|
| 100% coverage threshold | Genesis-wide policy, not package-specific |
```
"Forgot to map" is not a justification.
### Step 4 — Fill `**Verified by:**` lines in spec-delta.md
Every requirement gets a line listing the concrete `it(...)` titles that pin every scenario:
```markdown
**Verified by:** `src/feature/feature.test.ts::"<exact title>"` AND `src/feature/feature.spec-conformance.test.ts::"<exact title>"`
```
This is mandatory at authoring time, not after the gates pass. If you don't know what to put, write the test name first, then write the test. Don't ship the spec without the line filled in.
### Step 5 — Implement, test, document
Standard CLAUDE.md rules apply:
- Five files per feature module (`feature.ts`, `feature.test.ts`, `feature.types.ts`, `feature.md`, `index.ts`).
- 100% test coverage from day one (`provider: 'istanbul'`, never v8).
- Sibling `.md` for every implementation file (≥200 words, basic + advanced examples, full API reference).
- `{ timeout: 5000 }` on every `it()`/`test()`.
- NEVER use mocks — use injectable `Clock`/`Rng`/`Logger`/etc from `@suaveplan/types`.
- Check the `@suaveplan/error` consumers rule: every package extends `SuaveplanError` EXCEPT the three testing packages.
### Step 6 — Pass all 5 gates
Run in order. Stop on the first red:
```bash
cd /mnt/s/web/genesis
bun run check # gate 1 — Biome 0/0/0 across monorepo
bunx turbo lint typecheck test --filter=@suaveplan/<pkg> # gate 2
bunx turbo test:coverage --filter=@suaveplan/<pkg> # gate 3 — 100/100/100/100
bun scripts/openspec-conformance.ts @suaveplan/<pkg> # gate 4 — 0 errors
bunx turbo build --filter=@suaveplan/<pkg> # gate 5
```
Never add a `biome-ignore` directive on faith. Workflow:
1. Run `bun run check` to see what Biome actually flags.
2. Copy the exact rule path from Biome's output (e.g. `lint/suspicious/noExplicitAny`). Guessing produces parse errors — `lint/suspicious/noThrowLiterals` does not exist in Biome 2.x.
3. Add the suppression, re-run `bun run check`, confirm it's now used.
4. If you remove the underlying flag later, also remove the suppression — unused suppressions are errors.
If gate 3 reports drift, FIX IT INLINE. Do not punt to a follow-up change. The acceptable patterns:
- Implementation drifts from spec → align impl to spec, OR align spec to impl if the impl is the intended design.
- Error code registered but unused → wire it into a real production code path.
- OTel instrument declared but not emitted → wire it into the production hot path; assert with a real in-memory `MeterProvider`, not `vi.mock`.
- Public type returns `: unknown` instead of a generic → thread `<T = unknown>` through the public surface.
### Step 7 — Pre-archive 8-row checklist
Before folding the spec delta:
| # | Check |
|---|---|
| 1 | Generics threaded; `expectTypeOf` assertions on every literal-type claim |
| 2 | Zod schemas wired at every declared boundary |
| 3 | Every error code in `error-codes.ts` thrown by ≥1 production code path |
| 4 | Strict-mode behaviors tested both on and off |
| 5 | OTel instruments emitted from production paths and asserted with real `MeterProvider` |
| 6 | Lifecycle dedup tested where claimed (in-flight Promise, idempotent set/load) |
| 7 | No retroactive tick — every `tasks.md` checkbox reflects work actually done in this change |
| 8 | **No `export *` in any feature-module barrel** (§6a). Run `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" packages/<plugin>/<pkg>/src/ --include="index.ts" \| grep -v "/src/index\.ts:" \| grep -v "\.select/" \| grep -v "\.native/index\.ts:" \| grep -v "\.noble/index\.ts:" \| grep -v "export \* as"`. Zero output expected. If the upstream library used `export *` barrels, **convert them in this migration** — every `<feature>/index.ts` must list its symbols by name. |
| 9 | **No direct `vitest` / `bun:test` imports in test files** (CLAUDE.md rule 11 / §7 / §13 step 4b). Run `grep -rnE "from ['\"](vitest\|bun:test)['\"]" packages/<plugin>/<pkg>/src/ --include="*.ts" --include="*.tsx"`. Zero output expected for any non-exempt package. The exempt allowlist is exactly five testing-infra packages (`@suaveplan/testing`, `testing-dom`, `testing-e2e`, `testing-integration`, `testing-integration-step-ca`). If the upstream library imported runner primitives directly from `vitest`, **rewrite every line to `from "@suaveplan/testing/runner"` in this migration** — not as a follow-up. The repo-wide CI gate `bun scripts/lint/check-test-runner-imports.ts` catches regressions but the migration commit must already be clean. |
### Step 8 — Archive
```bash
# fold the delta
# (manual: copy ADDED/MODIFIED/REMOVED requirement bodies into openspec/specs/<cap>/spec.md)
mv /mnt/s/web/genesis/openspec/changes/<change-id> /mnt/s/web/genesis/openspec/archive/$(date +%Y-%m-%d)-<change-id>
```
Refresh `MANIFEST.md` if package set or deps changed:
```bash
cd /mnt/s/web/genesis && bun scripts/folder-reorg/generate-manifest.ts
```
Update the package listing in `CLAUDE.md`.
## Red flags — stop and ask
- The user says "just stub it" / "we'll wire it later" / "ship it 95% done" — refuse. Genesis ships at 100% or not at all.
- You discover the source `enterprise/<pkg>.md` references functionality not in scope (e.g., the file lists 5 things but the user only wants 3) — ask before scoping down. The default is "match the source"; partial migration is a judgment call.
- A required dependency package isn't migrated yet → check `MANIFEST.md` for dep order. Migrate the dep first, or stop and ask.
- The conformance script reports drift you can't fix without a breaking change to public surface → stop and ask. Breaking changes need explicit user approval.
- The user pushes back on writing `**Verified by:**` lines at draft time — show them this skill and `openspec/AGENTS.md` Section 13. Do not skip the lines.
## Reference materials
- Canonical example: `openspec/changes/error-spec-conformance/` and `packages/core/error/src/base/suaveplan-error.spec-conformance.test.ts`
- The 17 retroactive-ticking failure modes: `openspec/AGENTS.md` Section 12 (banned archival practices) and Section 13 (audit checklist)
- Source mapping example: any `proposal.md` in `openspec/changes/*-spec-conformance/` after the conformance sweep

View File

@@ -0,0 +1,159 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```text
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```text
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```text
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,116 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```text
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,299 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```text
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```text
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```text
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
▼
┌───────────┐
│ Session │
└─────┬─────┘
│
▼
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```text
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```text
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```text
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,120 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

126
.claude/templates/README.md Normal file
View File

@@ -0,0 +1,126 @@
# README-generation templates
This directory holds `@suaveplan/templates` engine templates for auto-generating
`README.md` files at three levels of the `packages/` tree.
| Template | Target path | Purpose |
|----------|-------------|---------|
| `package-readme.template.md` | `packages/<category>/<plugin>/<pkg>/README.md` | Stand-alone npm-package readme |
| `plugin-readme.template.md` | `packages/<category>/<plugin>/README.md` | Index of packages inside one plugin |
| `category-readme.template.md` | `packages/<category>/README.md` | Index of all packages in a category |
## How to render
Load the template file as a string, build a context object, and pass both to
`TemplateEngine` with the **loop-aware preset**:
```ts
import {
TemplateEngine,
fullPresetWithLoops,
} from "@suaveplan/templates";
import type { ParseState } from "@suaveplan/templates";
const engine = new TemplateEngine();
engine.usePreset(
fullPresetWithLoops(
(tpl, ctx, state) =>
(engine as unknown as { parseInternal: Function }).parseInternal(
tpl,
ctx,
state
)
)
);
const template = await Bun.file(
".claude/templates/package-readme.template.md"
).text();
const context = {
packageName: "@suaveplan/api-plugin-vault",
shortDescription: "Per-user vault HTTP plugin for `@suaveplan/api`.",
summary: "Per-user vault HTTP plugin...",
installCommand: "bun add @suaveplan/api-plugin-vault",
quickStartCode: `import { createVaultPlugin } from "@suaveplan/api-plugin-vault";`,
quickStartDescription: "After `registry.start()`, the vault plugin...",
features: [
"Persists `site` records under SurrealDB...",
"Enforces per-organization and per-site caps...",
],
modules: [
{ name: "error-codes/error-codes", path: "./src/error-codes/error-codes.md", description: "9 stable `VAULT_PLUGIN_*` codes." },
{ name: "plugin/plugin", path: "./src/plugin/plugin.md", description: "`createVaultPlugin` factory." },
],
otelInstruments: [
{ type: "Counter", name: "webhook_plugin.deliveries_total", labels: "{event,status,endpoint_id}" },
],
otelStatusValues: "`delivered`, `exhausted`, `not_found`",
license: "MIT",
};
const readme = engine.parse(template, context);
```
## Context shapes
### `package-readme.template.md`
| Key | Type | Required | Notes |
|-----|------|----------|-------|
| `packageName` | `string` | ✅ | Full scoped name, e.g. `@suaveplan/templates` |
| `shortDescription` | `string` | ✅ | ≤120 characters; appended to H1 |
| `summary` | `string` | ✅ | Multi-line paragraph(s) |
| `installCommand` | `string` | ✅ | Shell command, e.g. `bun add @suaveplan/templates` |
| `quickStartCode` | `string` | ✅ | Code inside the `ts` fence (no backticks) |
| `quickStartDescription` | `string` | ❌ | Paragraph after the quick-start block |
| `features` | `string[]` | ❌ | Renders the "What it does" section |
| `modules` | `{name,path,description}[]` | ✅ | Module index bullets |
| `otelInstruments` | `{type,name,labels?}[]` | ❌ | Renders the "OpenTelemetry" section |
| `otelStatusValues` | `string` | ❌ | Sentence fragment after the instrument list |
| `license` | `string` | ✅ | e.g. `MIT` |
### `plugin-readme.template.md`
| Key | Type | Required |
|-----|------|----------|
| `pluginName` | `string` | ✅ |
| `pluginDescription` | `string` | ✅ |
| `packages` | `{name,path,description}[]` | ✅ |
### `category-readme.template.md`
| Key | Type | Required |
|-----|------|----------|
| `categoryName` | `string` | ✅ |
| `categoryDescription` | `string` | ✅ |
| `packages` | `{name,path,description}[]` | ✅ |
## Brace-escaping caveat
The `@suaveplan/templates` engine scans the **entire** template (including text
inside code blocks) for `{identifier}` tokens. If a context string contains a
literal brace pair that looks like a variable — e.g. `GET /sites/{id}/public` —
the engine will try to resolve it and, when the identifier is absent from
context, replace it with an empty string.
When building context objects, avoid bare `{foo}` patterns. Safe work-arounds:
1. **Add a space** — `{ id }` does not match any plugin regex.
2. **Back-tick wrapping** — `` `{id}` `` is still scanned, but if the value is
not in context the engine replaces it with `""`. Only use this when the
loss of the braces is acceptable.
3. **Post-process** — after rendering, run a regex replace that restores any
sentinels you placed in the context strings (e.g. `«LBRACE»` → `{`).
Because the package-level readme is aimed at npm consumers with no knowledge of
the monorepo, the generated text should already avoid monorepo-specific tags
(`genesis`, migration phases, universal tiers, etc.). The AI generating the
context object is responsible for stripping that content before rendering.
## Conditional nesting
The `@suaveplan/templates` conditional plugin (`{if:…}…{/if}`) does not support
nesting. The package template keeps all conditionals at the top level so they
never wrap each other. Loop bodies may contain inner conditionals safely because
the loop plugin resolves them recursively before the outer conditional runs.

View File

@@ -0,0 +1,8 @@
# {categoryName}
{categoryDescription}
## Packages
{each:packages:pkg}- [`{pkg.name}`]({pkg.path}) — {pkg.description}
{/each}

View File

@@ -0,0 +1,237 @@
# [Module Name]
## Purpose
Brief description of what this module does and why it exists. Explain the problem it solves and the value it provides.
## Features
- Feature 1 - Brief description
- Feature 2 - Brief description
- Feature 3 - Brief description
- Additional features as needed
## Architecture
High-level architecture explanation
- Design patterns used (e.g., Factory, Singleton, Observer)
- Key abstractions and interfaces
- How components interact
- Performance considerations
## Usage
### Basic Example
```typescript
import { MyClass } from '@suaveplan/package';
// Basic usage example
const instance = new MyClass();
instance.doSomething();
```
### Advanced Example
```typescript
import { MyClass, type MyOptions } from '@suaveplan/package';
// More complex usage patterns
const options: MyOptions = {
// Configuration options
};
const instance = new MyClass(options);
// Advanced usage with callbacks, generics, etc.
instance.advancedMethod((result) => {
console.log('Result:', result);
});
```
### Integration Example
```typescript
// Example showing how this module integrates with other modules
import { MyClass } from '@suaveplan/package';
import { RelatedClass } from '@suaveplan/related-package';
const myInstance = new MyClass();
const relatedInstance = new RelatedClass(myInstance);
```
## API Reference
### ClassName
Main class description and purpose.
#### constructor(options?: Options)
**`Parameters:`**
- `options` (optional) - Configuration object
- `option1: string` - Description of option1
- `option2: number` - Description of option2
- `option3?: boolean` - Description of optional option3
**Returns:** Instance of ClassName
**`Example:`**
```typescript
const instance = new ClassName({
option1: 'value',
option2: 42
});
```
#### method(param: Type): ReturnType
Description of what the method does.
**`Parameters:`**
- `param: Type` - Description of the parameter
- Include constraints or validation rules
- Mention default values if applicable
**Returns:** Description of the return value
**`Throws:`**
- `ErrorType` - When this error occurs
**`Example:`**
```typescript
const result = instance.method('value');
```
#### asyncMethod(param: Type): Promise<ReturnType>
Description of what the async method does.
**`Parameters:`**
- `param: Type` - Description
**Returns:** Promise that resolves to description
**`Example:`**
```typescript
const result = await instance.asyncMethod('value');
```
### InterfaceName
Description of the interface and when to implement it.
```typescript
interface InterfaceName {
property: Type;
method(param: Type): ReturnType;
}
```
**`Properties:`**
- `property: Type` - Description of the property
**`Methods:`**
- `method(param: Type): ReturnType` - Description of the method
### TypeName
Description of the type and its use cases.
```typescript
type TypeName = /* type definition */;
```
**`Usage:`**
```typescript
const example: TypeName = /* example value */;
```
## Implementation Notes
### Design Decisions
- **Decision 1**: Explanation of why this approach was chosen
- Alternatives considered
- Trade-offs made
- **Decision 2**: Another important design decision
- Technical rationale
- Impact on performance or maintainability
### Performance Considerations
- **Time Complexity**: O(n) for operation X
- **Space Complexity**: O(1) for operation Y
- **Optimizations**: Specific optimizations implemented (caching, memoization, etc.)
- **Benchmarks**: Performance metrics if available
### Known Limitations
- **Limitation 1**: Description and potential workarounds
- **Limitation 2**: Description and potential workarounds
- **Browser Compatibility**: Any browser-specific limitations
- **Platform Support**: Node.js, Deno, Browser support notes
### Testing Strategy
- Unit tests cover: X, Y, Z
- Integration tests cover: A, B, C
- E2E tests cover: P, Q, R
- Test coverage: XX%
### Migration Guide (if applicable)
If this replaces or significantly changes an existing module
```typescript
// Old way (deprecated)
import { OldClass } from '@suaveplan/package';
const old = new OldClass();
// New way (recommended)
import { NewClass } from '@suaveplan/package';
const updated = new NewClass();
```
## See Also
- [Related Module 1](../related-module/related-module.md) - Brief description of relationship
- [Related Module 2](../../other-package/module/module.md) - Brief description of relationship
- [External Documentation](https://example.com/docs) - Relevant external resources
- [RFC/ADR](../../docs/adr/001-decision.md) - Architecture decision records if applicable
## Changelog
### Version X.Y.Z (YYYY-MM-DD)
- **Added**: New features
- **Changed**: Changes to existing features
- **Deprecated**: Soon-to-be removed features
- **Removed**: Removed features
- **Fixed**: Bug fixes
- **Security**: Security improvements
## Contributing
Guidelines specific to this module
- Code style preferences
- Testing requirements (maintain XX% coverage)
- Documentation requirements
- Review process
## License
Indicate if different from project license, otherwise remove this section.

View File

@@ -0,0 +1,37 @@
# {packageName} - {shortDescription}
## Summary
{summary}
## Install
```bash
{installCommand}
```
## Quick start
```ts
{quickStartCode}
```
{quickStartDescription}
{if:features}## What it does
{each:features:feature}- {feature}
{/each}
{/if}{if:modules}## Modules
{each:modules:mod}- [`{mod.name}`]({mod.path}) — {mod.description}
{/each}
{/if}{if:otelInstruments}## OpenTelemetry
{each:otelInstruments:inst}- {inst.type} `{inst.name}{if:inst.labels}{inst.labels}{/if}`
{/each}
{/if}{if:otelStatusValues}Status label values: {otelStatusValues}.
{/if}
## License
{license}

View File

@@ -0,0 +1,8 @@
# {pluginName}
{pluginDescription}
## Packages
{each:packages:pkg}- [`{pkg.name}`]({pkg.path}) — {pkg.description}
{/each}