name: vizcraft-sandbox-plugin description: "Build sandbox-style VizCraft plugins where users dynamically add/remove components and the step narrative adapts. USE WHEN: the user wants an interactive architecture builder, dynamic step generation, togglable infrastructure components, or a Controls panel. USE FOR: creating sandbox plugins with declarative flow engines, dynamic scenes, TaggedStep/StepKey patterns, component prerequisite/cascade systems, capacity models, Controls slot wiring, client scaling, and configuration-driven animation hooks. DO NOT USE FOR: linear fixed-step plugins (use vizcraft-playground skill), non-VizCraft projects."
VizCraft Sandbox Plugin Skill
Use this skill when building a sandbox-style VizCraft plugin — one where the user can dynamically add and remove scene components (nodes, infrastructure, actors) and the step narrative, scene layout, and animations all adapt reactively.
This extends the base vizcraft-playground skill. Read that skill first for foundational patterns (plugin anatomy, builder API, signals, mounting, SCSS). This skill covers only what is different or additional for sandbox plugins.
When to use this skill
- User wants an interactive architecture builder (add DB, LB, cache, etc.)
- Steps should change based on what the user has toggled on/off
- User wants a Controls panel for adding/removing entities
- User wants metrics that react to composition (capacity, throughput, CPU)
- The scene layout must adapt dynamically to component presence
Scaffolding
Use the generator with the --sandbox flag:
npm run generate my-plugin --sandbox --category "My Category"
This generates 8 files with the declarative flow engine, Controls panel, component-aware slice, and a generic animation hook. See the "Sandbox plugin anatomy" section below for details.
Sandbox plugin anatomy
A sandbox plugin has 8 files (two more than standard):
| File | Purpose |
|---|---|
flow-engine.ts |
Single source of truth — declarative STEPS config, FlowBeat, token expansion |
index.ts |
Plugin registration — re-exports from flow-engine, uses buildSteps(state) |
{name}Slice.ts |
State with togglable components, prerequisites, cascade, capacity model |
use{Name}Animation.ts |
Generic executor — reads STEPS config, no per-step imperative code |
main.tsx |
Dynamic scene builder — layout adapts to component state |
controls.tsx |
Controls component — rendered in Shell's Controls slot |
concepts.tsx |
Concept definitions (same as standard) |
main.scss |
Plugin styles + controls styles |
Declarative Flow Engine (flow-engine.ts)
The flow engine is the core innovation of sandbox plugins. Instead of writing imperative animation code per step (which leads to repeated signal paths, out-of-sync animations, and fragile switch statements), you define steps and their animation flows as data.
Why declarative?
Imperative animation code breaks in sandbox plugins because:
- Adding a component changes the step list and shifts indices
- Multiple steps end up animating the same signal path (e.g. "send-traffic" sends from clients→server→DB, then "db-flow" repeats server→DB)
- Bug fixes in one step can break another
The declarative approach solves this:
- Each step has a unique flow — no two steps share signal paths
- The engine handles token expansion, signal routing, and hot-zone derivation
- Adding a new step = adding an entry to the STEPS array. Zero animation code needed.
Token expansion
Use $-prefixed tokens as shorthand for dynamic node sets. The engine expands them at runtime:
export function expandToken(token: string, state: MyState): string[] {
if (token === "$clients") return state.clients.map((c) => c.id);
if (token === "$servers") {
const count = 1 + state.components.extraServers;
return Array.from({ length: count }, (_, i) => `server-${i}`);
}
return [token]; // literal node ID
}
FlowBeat — one animation segment
export interface FlowBeat {
from: string; // node ID or $token
to: string; // node ID or $token
when?: (c: InfraComponents) => boolean; // conditional
duration?: number; // ms, default 600
explain?: string; // shown during this beat
}
Tokens expand to parallel signals via cartesian product: $clients → cloud with 3 clients produces 3 parallel signals.
StepDef — declarative step config
export interface StepDef {
key: StepKey;
label: string;
when?: (c: InfraComponents) => boolean; // only include when true
nextButton?: string | ((c: InfraComponents) => string);
nextButtonColor?: string;
processingText?: string;
phase?: string | ((s: MyState) => string); // dispatched at start
flow?: FlowBeat[]; // animation beats (unique to this step!)
delay?: number; // pause after flow (ms)
recalcMetrics?: boolean; // recompute derived metrics
finalHotZones?: string[]; // hot zones after flow completes
explain?: string | ((s: MyState) => string); // shown after flow
action?: "reset"; // special actions
}
STEPS config — the single source of truth
Critical rule: each step owns a unique signal path. Never animate the same from→to in two different steps.
export const STEPS: StepDef[] = [
// 1. Overview — reset, no animation
{
key: "overview",
label: "Architecture Overview",
nextButton: "Send Traffic",
action: "reset",
},
// 2. Send Traffic — ONLY delivers to server layer
// Does NOT include DB, LB, or cache flows
{
key: "send-traffic",
label: "Send Traffic",
phase: "traffic",
flow: [
{ from: "$clients", to: "cloud", duration: 700,
explain: "Clients send requests through the internet." },
{ from: "cloud", to: "server-0", duration: 600,
explain: "Requests arrive at the server." },
],
recalcMetrics: true,
explain: (s) => `Traffic: ${s.requestsPerSecond} rps demand, ${s.maxCapacity} capacity.`,
},
// 3. Observe Metrics — no animation, just read numbers
{
key: "observe-metrics",
label: "Observe Metrics",
recalcMetrics: true,
delay: 500,
phase: (s) => (s.droppedRequests > 0 ? "overloaded" : "stable"),
finalHotZones: ["server-0"],
explain: (s) => s.droppedRequests > 0
? `Overloaded! ${s.droppedRequests} dropped.`
: `Stable at ${s.throughput} rps.`,
},
// 4. LB Distributes — unique: cloud → LB → servers
{
key: "lb-distribute",
label: "Load Balancer Distributes",
when: (c) => c.loadBalancer,
phase: "lb-distribute",
flow: [
{ from: "cloud", to: "lb", duration: 500 },
{ from: "lb", to: "$servers", duration: 700,
explain: "LB fans out across all servers." },
],
},
// 5. DB Flow — unique: servers ↔ DB
{
key: "db-flow",
label: "Server ↔ Database",
when: (c) => c.database,
phase: "db-flow",
flow: [
{ from: "$servers", to: "database", duration: 700 },
{ from: "database", to: "$servers", duration: 700 },
],
},
// 6. Summary
{ key: "summary", label: "Summary", phase: "summary",
explain: (s) => `Max capacity: ~${s.maxCapacity} rps.` },
];
buildSteps — auto-derives step list from config
export function buildSteps(state: MyState): TaggedStep[] {
const { components: c } = state;
const active = STEPS.filter((s) => !s.when || s.when(c));
return active.map((step, i) => {
const nextStep = active[i + 1];
// Auto-derive nextButtonText from next step's label if not explicit
let nextButtonText: string | undefined;
if (typeof step.nextButton === "function") nextButtonText = step.nextButton(c);
else if (typeof step.nextButton === "string") nextButtonText = step.nextButton;
else if (nextStep) nextButtonText = nextStep.label;
return { key: step.key, label: step.label, nextButtonText, ... };
});
}
executeFlow — generic flow executor
Walks beats, expands tokens, builds cartesian product signal pairs, auto-derives hot zones:
export async function executeFlow(
beats: FlowBeat[],
deps: FlowExecutorDeps,
): Promise<void> {
const components = deps.getState().components;
const activeBeats = beats.filter((b) => !b.when || b.when(components));
for (const beat of activeBeats) {
if (deps.cancelled()) return;
const state = deps.getState();
const froms = expandToken(beat.from, state);
const tos = expandToken(beat.to, state);
// Cartesian product → parallel signal pairs
const pairs: { from: string; to: string }[] = [];
for (const f of froms) {
for (const t of tos) {
pairs.push({ from: f, to: t });
}
}
// Auto-derive hot zones from participants
const hotZones = [...new Set([...froms, ...tos])];
const update: Partial<MyState> = { hotZones };
if (beat.explain) update.explanation = beat.explain;
deps.patch(update);
await deps.animateParallel(pairs, beat.duration ?? 600);
}
}
Generic Animation Hook (no switch statement)
The animation hook is fully generic — it reads config from STEPS and follows a fixed 9-step sequence. No per-step imperative code needed.
export const useMyAnimation = (onAnimationComplete?: () => void) => {
// ... standard setup (dispatch, selector, signals, refs) ...
const steps = buildSteps(runtime);
const currentKey: StepKey | undefined = steps[currentStep]?.key;
useEffect(() => {
let cancelled = false;
cleanup();
const stepDef = STEPS.find((s) => s.key === currentKey);
if (!stepDef) { finish(); return cleanup; }
const run = async () => {
// 1. Special actions (e.g. reset)
if (stepDef.action === "reset") { dispatch(reset()); finish(); return; }
// 2. Recalc metrics early (for non-flow steps that read derived state)
if (stepDef.recalcMetrics && !stepDef.flow) dispatch(recalcMetrics());
// 3. Set phase
if (stepDef.phase) {
const phase = typeof stepDef.phase === "function"
? stepDef.phase(rt()) : stepDef.phase;
doPatch({ phase });
}
// 4. Set initial hot zones for non-flow steps
if (stepDef.finalHotZones !== undefined && !stepDef.flow)
doPatch({ hotZones: stepDef.finalHotZones });
// 5. Execute flow beats
if (stepDef.flow) {
await executeFlow(stepDef.flow, {
animateParallel, patch: doPatch, getState: rt,
cancelled: () => cancelled,
});
if (cancelled) return;
}
// 6. Recalc after flow
if (stepDef.recalcMetrics && stepDef.flow) dispatch(recalcMetrics());
// 7. Delay
if (stepDef.delay) { await sleep(stepDef.delay); if (cancelled) return; }
// 8. Final hot zones
if (stepDef.finalHotZones !== undefined) doPatch({ hotZones: stepDef.finalHotZones });
else if (!stepDef.flow) doPatch({ hotZones: [] });
// 9. Final explanation
if (stepDef.explain) {
const explanation = typeof stepDef.explain === "function"
? stepDef.explain(rt()) : stepDef.explain;
doPatch({ explanation });
}
finish();
};
run();
return () => { cancelled = true; cleanup(); };
}, [currentStep, currentKey, cleanup, dispatch, sleep, animateParallel]);
return { runtime, signals };
};
Key rules:
- Switch on
currentKey, never oncurrentStepnumber. - Use
runtimeRef.current(not closed-overruntime) inside async sequences. - Include
currentKeyin theuseEffectdependency array.
Component state with prerequisites and cascades
State shape
export interface InfraComponents {
database: boolean;
loadBalancer: boolean;
cache: boolean;
extraServers: number; // numeric for multi-instance components
}
export type ComponentName = keyof InfraComponents;
Prerequisite system
const PREREQUISITES: Partial<Record<ComponentName, ComponentName[]>> = {
cache: ["database"],
extraServers: ["loadBalancer"],
};
In the addComponent reducer, check prerequisites before allowing the add.
Cascade removal system
const CASCADE_REMOVE: Partial<Record<ComponentName, ComponentName[]>> = {
database: ["cache"],
loadBalancer: ["extraServers"],
};
Removing a component auto-removes its dependents.
Capacity model
function getMaxCapacity(c: InfraComponents): number {
let cap = 60;
if (c.database) cap = 100;
if (c.loadBalancer) cap += 40;
cap += c.extraServers * 50;
if (c.cache) cap = Math.round(cap * 1.3);
return cap;
}
Important: Display requestsPerSecond / maxCapacity in the throughput badge (demand vs capacity), not throughput / maxCapacity (which caps at max, hiding overload).
Controls component and Shell slot
Shell wiring
The Shell renders plugin.Controls when defined:
{plugin.Controls && (
<div className="plugin-controls">
<plugin.Controls />
</div>
)}
Controls component pattern
const TOGGLES: Toggle[] = [
{ name: "database", addLabel: "+ Database", removeLabel: "− Database",
color: "#22c55e" },
{ name: "loadBalancer", addLabel: "+ Load Balancer", removeLabel: "− Load Balancer",
color: "#8b5cf6" },
{ name: "extraServers", addLabel: "+ Server", removeLabel: "− Server",
color: "#14b8a6", requires: ["loadBalancer"], multi: true },
{ name: "cache", addLabel: "+ Cache", removeLabel: "− Cache",
color: "#f97316", requires: ["database"] },
];
Critical: Always dispatch resetSimulation() after toggling a component. This resets currentStep to 0 because the step list has changed.
Dynamic scene builder
The scene must adapt to component state. Use a vertical stacking pattern with a nextY tracker:
const scene = (() => {
const b = viz().view(W, H);
let nextY = 40;
// Always-present: Clients row
const rowWidth = (clients.length - 1) * 72 + 56;
const clientStartX = W / 2 - rowWidth / 2;
clients.forEach((client, i) => {
b.node(client.id).at(clientStartX + i * 72, nextY).rect(56, 40, 8)
.image("/mobile.svg", 20, 20, { dy: -5, position: "center" });
});
nextY += 75;
// Conditional: Load Balancer
if (components.loadBalancer) {
b.node("lb").at(W/2 - 80, nextY).rect(160, 52, 12)
.label("Load Balancer", { ... });
nextY += 75;
}
// Edges adapt to topology
if (components.loadBalancer) {
b.edge("cloud", "lb").arrow(true);
for (let i = 0; i < totalServers; i++)
b.edge("lb", `server-${i}`).arrow(true);
} else {
b.edge("cloud", "server-0").arrow(true);
}
return b;
})();
Key rules:
- Use
nextYtracker — never hardcode absolute Y for conditional nodes. - All entity loops must iterate the full dynamic collection.
- Edge topology adapts to component presence.
- Client row centering: use
(clients.length - 1) * gap + nodeWidthfor actual row width.
Using icons with .image()
.image(href, width, height, { dx?, dy?, position? })
- Always use
"center"and offset withdx/dyto position inside the node "left"and"right"place the image outside the node boundary — avoid these- When using icon + label together, offset the label with a positive
dxto avoid overlap
Common sandbox-specific mistakes
- Repeating signal paths across steps — "send-traffic" animates the full path including DB, then "db-flow" repeats server→DB. Each step must own a unique flow.
- Switching on step index instead of StepKey — animations break when steps are conditionally included/removed.
- Writing imperative animation code per step — use the declarative STEPS config and the generic executor instead.
- Hardcoding
.slice(0, N)on dynamic collections — new entities won't animate. Iterate the full collection. Use$tokensfor this. - Forgetting
resetSimulation()after component toggle — step index points to a now-invalid position. - Forgetting cascade removals — removing DB should also remove cache; removing LB should remove extra servers.
- Using
position: "left"for.image()— places icon outside node. Use"center"withdxoffset. - Displaying
throughput/maxCapacityinstead ofrequestsPerSecond/maxCapacity— throughput is capped at max, hiding overload. - Not using
runtimeRef.currentin async animation code — the closure captures stale state. - Hardcoding edge topology — edges must adapt. Cloud→LB→servers when LB exists, cloud→server-0 otherwise.
Execution checklist
When creating a new sandbox plugin:
- Run
npm run generate my-plugin --sandbox --category "Category" - Define
InfraComponentsinterface with your togglable components - Set up
PREREQUISITESandCASCADE_REMOVEmaps - Write capacity model (
getMaxCapacity) andcomputeMetrics - Add
StepKeyentries for each component-specific step - Define
expandTokenmappings for your$tokennames - Configure
STEPSarray — each step with a unique flow (no overlap) - Add component TOGGLES in
controls.tsxwithresetSimulation()on every toggle - Build dynamic scene with
nextYtracker, conditional nodes, adaptive edges - Use
.image()withposition: "center"for node icons - Verify each step's flow is unique — no two steps animate the same from→to
- Test: add component → step list updates → animation matches
- Test: remove component → cascaded deps removed → step list shrinks → no crash