Initial Commit
This commit is contained in:
155
.claude/commands/opsx/apply.md
Normal file
155
.claude/commands/opsx/apply.md
Normal 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
|
||||
159
.claude/commands/opsx/archive.md
Normal file
159
.claude/commands/opsx/archive.md
Normal 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
|
||||
181
.claude/commands/opsx/explore.md
Normal file
181
.claude/commands/opsx/explore.md
Normal 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
|
||||
116
.claude/commands/opsx/propose.md
Normal file
116
.claude/commands/opsx/propose.md
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-bitpacker",
|
||||
"version": "1.0.0",
|
||||
"description": "BitPacker binary serialization schema design for Suaveplan"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-doc-coauthoring",
|
||||
"version": "1.0.0",
|
||||
"description": "Structured workflow for co-authoring Suaveplan package documentation"
|
||||
}
|
||||
@@ -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`
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-frontend-design",
|
||||
"version": "1.0.0",
|
||||
"description": "Suaveplan-specific frontend design skill for React + Tailwind + shadcn/ui"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-mcp-builder",
|
||||
"version": "1.0.0",
|
||||
"description": "Create MCP servers exposing Suaveplan plugin functionality"
|
||||
}
|
||||
@@ -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>`
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-package-structure",
|
||||
"version": "1.0.0",
|
||||
"description": "Library package structure patterns and code organization rules for Suaveplan monorepo"
|
||||
}
|
||||
@@ -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/)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-skill-creator",
|
||||
"version": "1.0.0",
|
||||
"description": "Guide for creating project-local skills in the suaveplan-plugins marketplace"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-testing-workflow",
|
||||
"version": "1.0.0",
|
||||
"description": "Testing commands, CI/CD integration, turborepo filtering, and troubleshooting for Suaveplan"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-theme-factory",
|
||||
"version": "1.0.0",
|
||||
"description": "Tailwind CSS + shadcn/ui theming toolkit with curated themes"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-threejs",
|
||||
"version": "1.0.0",
|
||||
"description": "React Three Fiber development skills for Suaveplan 3D packages"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-web-artifacts",
|
||||
"version": "1.0.0",
|
||||
"description": "Build production-grade React artifacts using Suaveplan component library"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-webapp-testing",
|
||||
"version": "1.0.0",
|
||||
"description": "Playwright E2E testing toolkit for Suaveplan browser packages"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-webgpu",
|
||||
"version": "1.0.0",
|
||||
"description": "WebGPU rendering with Three.js TSL in React Three Fiber"
|
||||
}
|
||||
@@ -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]);
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
},
|
||||
"env": {
|
||||
"ECC_DISABLED_HOOKS": "pre:bash:gateguard-fact-force,pre:edit-write:gateguard-fact-force"
|
||||
}
|
||||
}
|
||||
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rm /mnt/s/web/genesis/.git/index.lock)",
|
||||
"Bash(git rebase *)",
|
||||
"Bash(rm *)",
|
||||
"Bash(ln -s *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
158
.claude/skills/enterprise-package-migration/SKILL.md
Normal file
158
.claude/skills/enterprise-package-migration/SKILL.md
Normal 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
|
||||
159
.claude/skills/openspec-apply-change/SKILL.md
Normal file
159
.claude/skills/openspec-apply-change/SKILL.md
Normal 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
|
||||
116
.claude/skills/openspec-archive-change/SKILL.md
Normal file
116
.claude/skills/openspec-archive-change/SKILL.md
Normal 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
|
||||
299
.claude/skills/openspec-explore/SKILL.md
Normal file
299
.claude/skills/openspec-explore/SKILL.md
Normal 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
|
||||
120
.claude/skills/openspec-propose/SKILL.md
Normal file
120
.claude/skills/openspec-propose/SKILL.md
Normal 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
126
.claude/templates/README.md
Normal 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.
|
||||
8
.claude/templates/category-readme.template.md
Normal file
8
.claude/templates/category-readme.template.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# {categoryName}
|
||||
|
||||
{categoryDescription}
|
||||
|
||||
## Packages
|
||||
|
||||
{each:packages:pkg}- [`{pkg.name}`]({pkg.path}) — {pkg.description}
|
||||
{/each}
|
||||
237
.claude/templates/module-docs.md
Normal file
237
.claude/templates/module-docs.md
Normal 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.
|
||||
37
.claude/templates/package-readme.template.md
Normal file
37
.claude/templates/package-readme.template.md
Normal 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}
|
||||
8
.claude/templates/plugin-readme.template.md
Normal file
8
.claude/templates/plugin-readme.template.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# {pluginName}
|
||||
|
||||
{pluginDescription}
|
||||
|
||||
## Packages
|
||||
|
||||
{each:packages:pkg}- [`{pkg.name}`]({pkg.path}) — {pkg.description}
|
||||
{/each}
|
||||
Reference in New Issue
Block a user