create-vizcraft-playground 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -160,6 +160,10 @@ export default playgroundConfig;
160
160
  console.log("");
161
161
  console.log(' npm run generate my-concept --category "My Category"');
162
162
  console.log("");
163
+ console.log(" Or scaffold a sandbox plugin (dynamic components, flow engine):");
164
+ console.log("");
165
+ console.log(' npm run generate my-concept --sandbox --category "My Category"');
166
+ console.log("");
163
167
 
164
168
  rl.close();
165
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vizcraft-playground",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Scaffold a new VizCraft interactive visualization playground",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,14 +6,17 @@ const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
8
  // ── Arg parsing ────────────────────────────────────────────
9
- // Usage: npm run generate <plugin-name> [--category "Category Name"]
9
+ // Usage: npm run generate <plugin-name> [--category "Category Name"] [--sandbox]
10
10
  const args = process.argv.slice(2);
11
11
  let pluginName = null;
12
12
  let categoryName = null;
13
+ let isSandbox = false;
13
14
 
14
15
  for (let i = 0; i < args.length; i++) {
15
16
  if (args[i] === "--category" || args[i] === "-c") {
16
17
  categoryName = args[++i];
18
+ } else if (args[i] === "--sandbox" || args[i] === "-s") {
19
+ isSandbox = true;
17
20
  } else if (!args[i].startsWith("-")) {
18
21
  pluginName = args[i];
19
22
  }
@@ -21,9 +24,11 @@ for (let i = 0; i < args.length; i++) {
21
24
 
22
25
  if (!pluginName) {
23
26
  console.error(
24
- "Usage: npm run generate <plugin-name> [--category \"Category Name\"]\n" +
27
+ "Usage: npm run generate <plugin-name> [--category \"Category Name\"] [--sandbox]\n" +
25
28
  " Name must be kebab-case, e.g. npm run generate api-gateway\n" +
26
- " --category / -c Existing or new category to place the plugin in",
29
+ " --category / -c Existing or new category to place the plugin in\n" +
30
+ " --sandbox / -s Generate a sandbox plugin with declarative flow engine,\n" +
31
+ " Controls panel, and dynamic component toggling",
27
32
  );
28
33
  process.exit(1);
29
34
  }
@@ -49,7 +54,175 @@ fs.mkdirSync(targetDir, { recursive: true });
49
54
  /* ================================================================
50
55
  1. Redux Slice — ${camelName}Slice.ts
51
56
  ================================================================ */
52
- const sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
57
+ let sliceContent;
58
+ if (isSandbox) {
59
+ sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
60
+
61
+ /* ── Addable infrastructure components ───────────────── */
62
+ export interface InfraComponents {
63
+ // TODO: add your togglable components here
64
+ // database: boolean;
65
+ // loadBalancer: boolean;
66
+ // cache: boolean;
67
+ // extraServers: number;
68
+ }
69
+
70
+ export type ComponentName = keyof InfraComponents;
71
+
72
+ /** Which components require which prerequisites. */
73
+ const PREREQUISITES: Partial<Record<ComponentName, ComponentName[]>> = {
74
+ // cache: ["database"],
75
+ };
76
+
77
+ /** Which components cascade-remove when toggled off. */
78
+ const CASCADE_REMOVE: Partial<Record<ComponentName, ComponentName[]>> = {
79
+ // database: ["cache"],
80
+ };
81
+
82
+ /* ── Client model ────────────────────────────────────── */
83
+ export interface ClientNode {
84
+ id: string;
85
+ type: "desktop" | "mobile";
86
+ }
87
+
88
+ /* ── State shape ─────────────────────────────────────── */
89
+ export interface ${pascalName}State {
90
+ components: InfraComponents;
91
+ clients: ClientNode[];
92
+
93
+ /* derived metrics (recomputed by computeMetrics) */
94
+ requestsPerSecond: number;
95
+ maxCapacity: number;
96
+ throughput: number;
97
+ droppedRequests: number;
98
+
99
+ /* ui */
100
+ hotZones: string[];
101
+ explanation: string;
102
+ phase: string;
103
+ }
104
+
105
+ const defaultClients: ClientNode[] = [
106
+ { id: "client-1", type: "desktop" },
107
+ { id: "client-2", type: "mobile" },
108
+ { id: "client-3", type: "desktop" },
109
+ ];
110
+
111
+ /* ── Capacity model ──────────────────────────────────── */
112
+ function getMaxCapacity(c: InfraComponents): number {
113
+ let cap = 60; // base solo capacity
114
+ // TODO: adjust capacity based on components
115
+ return cap;
116
+ }
117
+
118
+ export function computeMetrics(state: ${pascalName}State) {
119
+ const rps = state.clients.length * 10;
120
+ state.requestsPerSecond = rps;
121
+ state.maxCapacity = getMaxCapacity(state.components);
122
+ state.throughput = Math.min(rps, state.maxCapacity);
123
+ state.droppedRequests = Math.max(0, rps - state.maxCapacity);
124
+ }
125
+
126
+ function describeArch(c: InfraComponents): string {
127
+ const parts: string[] = ["Server"];
128
+ // TODO: push active component labels
129
+ return parts.join(" + ");
130
+ }
131
+
132
+ export const initialState: ${pascalName}State = {
133
+ components: {
134
+ // TODO: initialise your components (all off by default)
135
+ } as InfraComponents,
136
+ clients: defaultClients,
137
+
138
+ requestsPerSecond: 30,
139
+ maxCapacity: 60,
140
+ throughput: 30,
141
+ droppedRequests: 0,
142
+
143
+ hotZones: [],
144
+ explanation: "Welcome — build the architecture using the controls above.",
145
+ phase: "overview",
146
+ };
147
+
148
+ computeMetrics(initialState);
149
+
150
+ /* ── Slice ───────────────────────────────────────────── */
151
+ const ${camelName}Slice = createSlice({
152
+ name: "${camelName}",
153
+ initialState,
154
+ reducers: {
155
+ reset: () => {
156
+ const s = { ...initialState, clients: [...initialState.clients] };
157
+ computeMetrics(s);
158
+ return s;
159
+ },
160
+ patchState(state, action: PayloadAction<Partial<${pascalName}State>>) {
161
+ Object.assign(state, action.payload);
162
+ },
163
+ recalcMetrics(state) {
164
+ computeMetrics(state);
165
+ },
166
+
167
+ /* ── Component toggles ─────────────────────────── */
168
+ addComponent(state, action: PayloadAction<ComponentName>) {
169
+ const name = action.payload;
170
+ const prereqs = PREREQUISITES[name];
171
+ if (prereqs?.some((p) => !state.components[p])) return;
172
+
173
+ // TODO: handle numeric components (e.g. extraServers)
174
+ if (state.components[name]) return;
175
+ (state.components[name] as boolean) = true;
176
+
177
+ computeMetrics(state);
178
+ state.explanation = \`Added! Architecture: \${describeArch(state.components)}. Capacity: ~\${state.maxCapacity} rps.\`;
179
+ },
180
+
181
+ removeComponent(state, action: PayloadAction<ComponentName>) {
182
+ const name = action.payload;
183
+ if (!state.components[name]) return;
184
+ (state.components[name] as boolean) = false;
185
+
186
+ const cascades = CASCADE_REMOVE[name];
187
+ if (cascades) {
188
+ for (const dep of cascades) {
189
+ (state.components[dep] as boolean) = false;
190
+ }
191
+ }
192
+
193
+ computeMetrics(state);
194
+ state.explanation = \`Removed. Architecture: \${describeArch(state.components)}. Capacity: ~\${state.maxCapacity} rps.\`;
195
+ },
196
+
197
+ /* ── Client controls ───────────────────────────── */
198
+ addClient(state) {
199
+ if (state.clients.length >= 12) return;
200
+ const id = \`client-\${Date.now()}\`;
201
+ const type = state.clients.length % 2 === 0 ? "desktop" : "mobile";
202
+ state.clients.push({ id, type });
203
+ computeMetrics(state);
204
+ },
205
+ removeClient(state) {
206
+ if (state.clients.length <= 1) return;
207
+ state.clients.pop();
208
+ computeMetrics(state);
209
+ },
210
+ },
211
+ });
212
+
213
+ export const {
214
+ reset,
215
+ patchState,
216
+ recalcMetrics,
217
+ addComponent,
218
+ removeComponent,
219
+ addClient,
220
+ removeClient,
221
+ } = ${camelName}Slice.actions;
222
+ export default ${camelName}Slice.reducer;
223
+ `;
224
+ } else {
225
+ sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
53
226
 
54
227
  export type ${pascalName}Phase = "overview" | "processing" | "summary";
55
228
 
@@ -81,13 +254,196 @@ const ${camelName}Slice = createSlice({
81
254
  export const { patchState, reset } = ${camelName}Slice.actions;
82
255
  export default ${camelName}Slice.reducer;
83
256
  `;
257
+ } // end if/else sandbox slice
84
258
 
85
259
  fs.writeFileSync(path.join(targetDir, `${camelName}Slice.ts`), sliceContent);
86
260
 
87
261
  /* ================================================================
88
262
  2. Animation Hook — use${pascalName}Animation.ts
89
263
  ================================================================ */
90
- const hookContent = `import { useCallback, useEffect, useRef, useState } from "react";
264
+ let hookContent;
265
+ if (isSandbox) {
266
+ hookContent = `import { useCallback, useEffect, useRef, useState } from "react";
267
+ import { useDispatch, useSelector } from "react-redux";
268
+ import type { SignalOverlayParams } from "vizcraft";
269
+ import { type RootState } from "../../store/store";
270
+ import {
271
+ patchState,
272
+ reset,
273
+ recalcMetrics,
274
+ type ${pascalName}State,
275
+ } from "./${camelName}Slice";
276
+ import { STEPS, buildSteps, executeFlow, type StepKey } from "./flow-engine";
277
+
278
+ export type Signal = { id: string; color?: string } & SignalOverlayParams;
279
+
280
+ /* ──────────────────────────────────────────────────────────
281
+ Declarative animation hook.
282
+
283
+ Reads step config from STEPS, resolves the current step
284
+ key, then uses executeFlow to run the declared beats.
285
+ No per-step imperative code needed.
286
+ ────────────────────────────────────────────────────────── */
287
+
288
+ export const use${pascalName}Animation = (onAnimationComplete?: () => void) => {
289
+ const dispatch = useDispatch();
290
+ const { currentStep } = useSelector((state: RootState) => state.simulation);
291
+ const runtime = useSelector(
292
+ (state: RootState) => state.${camelName},
293
+ ) as ${pascalName}State;
294
+ const [signals, setSignals] = useState<Signal[]>([]);
295
+ const rafRef = useRef<number>(0);
296
+ const timeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
297
+ const onCompleteRef = useRef(onAnimationComplete);
298
+ const runtimeRef = useRef(runtime);
299
+
300
+ onCompleteRef.current = onAnimationComplete;
301
+ runtimeRef.current = runtime;
302
+
303
+ const cleanup = useCallback(() => {
304
+ cancelAnimationFrame(rafRef.current);
305
+ timeoutsRef.current.forEach((id) => clearTimeout(id));
306
+ timeoutsRef.current = [];
307
+ setSignals([]);
308
+ }, []);
309
+
310
+ const sleep = useCallback(
311
+ (ms: number) =>
312
+ new Promise<void>((resolve) => {
313
+ const id = setTimeout(resolve, ms);
314
+ timeoutsRef.current.push(id);
315
+ }),
316
+ [],
317
+ );
318
+
319
+ const animateParallel = useCallback(
320
+ (pairs: { from: string; to: string }[], duration: number) => {
321
+ return new Promise<void>((resolve) => {
322
+ const start = performance.now();
323
+ const sigs = pairs.map((p, i) => ({
324
+ id: \`par-\${i}-\${Date.now()}\`,
325
+ from: p.from,
326
+ to: p.to,
327
+ progress: 0,
328
+ magnitude: 0.8,
329
+ }));
330
+
331
+ const tick = (now: number) => {
332
+ const p = Math.min((now - start) / duration, 1);
333
+ setSignals(sigs.map((s) => ({ ...s, progress: p })));
334
+ if (p < 1) {
335
+ rafRef.current = requestAnimationFrame(tick);
336
+ } else {
337
+ resolve();
338
+ }
339
+ };
340
+ rafRef.current = requestAnimationFrame(tick);
341
+ });
342
+ },
343
+ [],
344
+ );
345
+
346
+ /* ── Resolve current step ─────────────────────────── */
347
+ const steps = buildSteps(runtime);
348
+ const currentKey: StepKey | undefined = steps[currentStep]?.key;
349
+
350
+ /* ── Generic step executor ────────────────────────── */
351
+ useEffect(() => {
352
+ let cancelled = false;
353
+ cleanup();
354
+
355
+ const finish = () => {
356
+ if (!cancelled) setTimeout(() => onCompleteRef.current?.(), 0);
357
+ };
358
+ const rt = () => runtimeRef.current;
359
+ const doPatch = (p: Partial<${pascalName}State>) => dispatch(patchState(p));
360
+
361
+ const stepDef = STEPS.find((s) => s.key === currentKey);
362
+ if (!stepDef) {
363
+ finish();
364
+ return cleanup;
365
+ }
366
+
367
+ const run = async () => {
368
+ // 1. Special actions
369
+ if (stepDef.action === "reset") {
370
+ dispatch(reset());
371
+ finish();
372
+ return;
373
+ }
374
+
375
+ // 2. Recalc metrics early (for non-flow steps)
376
+ if (stepDef.recalcMetrics && !stepDef.flow) {
377
+ dispatch(recalcMetrics());
378
+ }
379
+
380
+ // 3. Set phase
381
+ if (stepDef.phase) {
382
+ const phase =
383
+ typeof stepDef.phase === "function"
384
+ ? stepDef.phase(rt())
385
+ : stepDef.phase;
386
+ doPatch({ phase });
387
+ }
388
+
389
+ // 4. Set initial hot zones for non-flow steps
390
+ if (stepDef.finalHotZones !== undefined && !stepDef.flow) {
391
+ doPatch({ hotZones: stepDef.finalHotZones });
392
+ }
393
+
394
+ // 5. Execute flow beats
395
+ if (stepDef.flow) {
396
+ await executeFlow(stepDef.flow, {
397
+ animateParallel,
398
+ patch: doPatch,
399
+ getState: rt,
400
+ cancelled: () => cancelled,
401
+ });
402
+ if (cancelled) return;
403
+ }
404
+
405
+ // 6. Recalc after flow
406
+ if (stepDef.recalcMetrics && stepDef.flow) {
407
+ dispatch(recalcMetrics());
408
+ }
409
+
410
+ // 7. Delay
411
+ if (stepDef.delay) {
412
+ await sleep(stepDef.delay);
413
+ if (cancelled) return;
414
+ }
415
+
416
+ // 8. Final hot zones
417
+ if (stepDef.finalHotZones !== undefined) {
418
+ doPatch({ hotZones: stepDef.finalHotZones });
419
+ } else if (!stepDef.flow) {
420
+ doPatch({ hotZones: [] });
421
+ }
422
+
423
+ // 9. Final explanation
424
+ if (stepDef.explain) {
425
+ const explanation =
426
+ typeof stepDef.explain === "function"
427
+ ? stepDef.explain(rt())
428
+ : stepDef.explain;
429
+ doPatch({ explanation });
430
+ }
431
+
432
+ finish();
433
+ };
434
+
435
+ run();
436
+ return () => {
437
+ cancelled = true;
438
+ cleanup();
439
+ };
440
+ }, [currentStep, currentKey, cleanup, dispatch, sleep, animateParallel]);
441
+
442
+ return { runtime, signals };
443
+ };
444
+ `;
445
+ } else {
446
+ hookContent = `import { useCallback, useEffect, useRef, useState } from "react";
91
447
  import { useDispatch, useSelector } from "react-redux";
92
448
  import type { SignalOverlayParams } from "vizcraft";
93
449
  import { type RootState } from "../../store/store";
@@ -178,6 +534,7 @@ export const use${pascalName}Animation = (onAnimationComplete?: () => void) => {
178
534
  };
179
535
  };
180
536
  `;
537
+ } // end if/else sandbox hook
181
538
 
182
539
  fs.writeFileSync(
183
540
  path.join(targetDir, `use${pascalName}Animation.ts`),
@@ -414,7 +771,52 @@ fs.writeFileSync(path.join(targetDir, "main.scss"), scssContent);
414
771
  /* ================================================================
415
772
  6. Plugin Registration — index.ts
416
773
  ================================================================ */
417
- const indexContent = `import type { Action, Dispatch } from "@reduxjs/toolkit";
774
+ let indexContent;
775
+ if (isSandbox) {
776
+ indexContent = `import type { Action, Dispatch } from "@reduxjs/toolkit";
777
+ import type { DemoPlugin, DemoStep } from "../../types/ModelPlugin";
778
+ import ${pascalName}Visualization from "./main";
779
+ import ${pascalName}Controls from "./controls";
780
+ import ${camelName}Reducer, {
781
+ type ${pascalName}State,
782
+ initialState,
783
+ reset,
784
+ } from "./${camelName}Slice";
785
+ import {
786
+ buildSteps,
787
+ type StepKey,
788
+ type TaggedStep,
789
+ } from "./flow-engine";
790
+
791
+ type LocalRootState = { ${camelName}: ${pascalName}State };
792
+
793
+ const ${pascalName}Plugin: DemoPlugin<
794
+ ${pascalName}State,
795
+ Action,
796
+ LocalRootState,
797
+ Dispatch<Action>
798
+ > = {
799
+ id: "${pluginName}",
800
+ name: "${pascalName}",
801
+ description: "Describe what this demo teaches in one sentence.",
802
+ initialState,
803
+ reducer: ${camelName}Reducer,
804
+ Component: ${pascalName}Visualization,
805
+ Controls: ${pascalName}Controls,
806
+ restartConfig: { text: "Replay", color: "#3b82f6" },
807
+ getSteps: (state: ${pascalName}State): DemoStep[] => buildSteps(state),
808
+ init: (dispatch) => {
809
+ dispatch(reset());
810
+ },
811
+ selector: (state: LocalRootState) => state.${camelName},
812
+ };
813
+
814
+ export { buildSteps };
815
+ export type { StepKey, TaggedStep };
816
+ export default ${pascalName}Plugin;
817
+ `;
818
+ } else {
819
+ indexContent = `import type { Action, Dispatch } from "@reduxjs/toolkit";
418
820
  import type { DemoPlugin, DemoStep } from "../../types/ModelPlugin";
419
821
  import ${pascalName}Visualization from "./main";
420
822
  import ${camelName}Reducer, {
@@ -462,11 +864,345 @@ const ${pascalName}Plugin: DemoPlugin<
462
864
 
463
865
  export default ${pascalName}Plugin;
464
866
  `;
867
+ } // end if/else sandbox index
465
868
 
466
869
  fs.writeFileSync(path.join(targetDir, "index.ts"), indexContent);
467
870
 
468
871
  /* ================================================================
469
- 7. Update registry.tsadd import + wire into category
872
+ 7. (Sandbox only) Flow Engine flow-engine.ts
873
+ ================================================================ */
874
+ if (isSandbox) {
875
+ const flowEngineContent = `import type { InfraComponents, ${pascalName}State } from "./${camelName}Slice";
876
+
877
+ /* ══════════════════════════════════════════════════════════
878
+ Declarative Flow Engine
879
+
880
+ Define steps and their animation flows as DATA.
881
+ The engine handles token expansion, signal routing,
882
+ hot-zone derivation, and sequential execution.
883
+ ══════════════════════════════════════════════════════════ */
884
+
885
+ /* ── Token expansion ─────────────────────────────────────
886
+ Use $-prefixed tokens as shorthand for dynamic node sets.
887
+ The engine expands them to actual node IDs at runtime.
888
+ ──────────────────────────────────────────────────────── */
889
+
890
+ export function expandToken(
891
+ token: string,
892
+ state: ${pascalName}State,
893
+ ): string[] {
894
+ if (token === "$clients") return state.clients.map((c) => c.id);
895
+ // TODO: add more token expansions, e.g.:
896
+ // if (token === "$servers") {
897
+ // const count = 1 + state.components.extraServers;
898
+ // return Array.from({ length: count }, (_, i) => \`server-\${i}\`);
899
+ // }
900
+ return [token];
901
+ }
902
+
903
+ /* ── Flow Beat ───────────────────────────────────────────
904
+ One animation segment: signals travel from → to.
905
+ Tokens ($clients, $servers) expand to parallel signals.
906
+ ──────────────────────────────────────────────────────── */
907
+
908
+ export interface FlowBeat {
909
+ from: string;
910
+ to: string;
911
+ when?: (c: InfraComponents) => boolean;
912
+ duration?: number;
913
+ explain?: string;
914
+ }
915
+
916
+ /* ── Step Definition ─────────────────────────────────────
917
+ Declarative config for one step in the visualization.
918
+ ──────────────────────────────────────────────────────── */
919
+
920
+ export type StepKey = "overview" | "send-traffic" | "observe-metrics" | "summary";
921
+ // TODO: add more step keys as you add components
922
+
923
+ export interface StepDef {
924
+ key: StepKey;
925
+ label: string;
926
+ when?: (c: InfraComponents) => boolean;
927
+ nextButton?: string | ((c: InfraComponents) => string);
928
+ nextButtonColor?: string;
929
+ processingText?: string;
930
+ phase?: string | ((s: ${pascalName}State) => string);
931
+ flow?: FlowBeat[];
932
+ delay?: number;
933
+ recalcMetrics?: boolean;
934
+ finalHotZones?: string[];
935
+ explain?: string | ((s: ${pascalName}State) => string);
936
+ action?: "reset";
937
+ }
938
+
939
+ /* ── Step Configuration ──────────────────────────────────
940
+ Single source of truth. Each step gets its own unique
941
+ flow — never repeat the same signal path in two steps.
942
+ ──────────────────────────────────────────────────────── */
943
+
944
+ export const STEPS: StepDef[] = [
945
+ {
946
+ key: "overview",
947
+ label: "Architecture Overview",
948
+ nextButton: "Send Traffic",
949
+ action: "reset",
950
+ },
951
+ {
952
+ key: "send-traffic",
953
+ label: "Send Traffic",
954
+ processingText: "Sending...",
955
+ nextButtonColor: "#2563eb",
956
+ phase: "traffic",
957
+ flow: [
958
+ {
959
+ from: "$clients",
960
+ to: "cloud",
961
+ duration: 700,
962
+ explain: "Clients send requests through the internet.",
963
+ },
964
+ {
965
+ from: "cloud",
966
+ to: "server-0",
967
+ duration: 600,
968
+ explain: "Requests arrive at the server.",
969
+ },
970
+ ],
971
+ recalcMetrics: true,
972
+ explain: (s) =>
973
+ \`Traffic flowing. \${s.requestsPerSecond} rps demand, \${s.maxCapacity} capacity.\`,
974
+ },
975
+ {
976
+ key: "observe-metrics",
977
+ label: "Observe Metrics",
978
+ nextButtonColor: "#2563eb",
979
+ recalcMetrics: true,
980
+ delay: 500,
981
+ phase: (s) => (s.droppedRequests > 0 ? "overloaded" : "stable"),
982
+ finalHotZones: ["server-0"],
983
+ explain: (s) =>
984
+ s.droppedRequests > 0
985
+ ? \`Overloaded! \${s.droppedRequests} requests dropped.\`
986
+ : \`Stable at \${s.throughput} rps. Try adding components.\`,
987
+ },
988
+ // TODO: add component-specific steps here (each with unique flow)
989
+ {
990
+ key: "summary",
991
+ label: "Summary",
992
+ phase: "summary",
993
+ explain: (s) =>
994
+ \`Max capacity: ~\${s.maxCapacity} rps. Try adding or removing components and replaying.\`,
995
+ },
996
+ ];
997
+
998
+ /* ── Build active steps from config ──────────────────── */
999
+
1000
+ export interface TaggedStep {
1001
+ key: StepKey;
1002
+ label: string;
1003
+ autoAdvance?: boolean;
1004
+ nextButtonText?: string;
1005
+ nextButtonColor?: string;
1006
+ processingText?: string;
1007
+ }
1008
+
1009
+ export function buildSteps(state: ${pascalName}State): TaggedStep[] {
1010
+ const { components: c } = state;
1011
+ const active = STEPS.filter((s) => !s.when || s.when(c));
1012
+
1013
+ return active.map((step, i) => {
1014
+ const nextStep = active[i + 1];
1015
+ let nextButtonText: string | undefined;
1016
+ if (typeof step.nextButton === "function") {
1017
+ nextButtonText = step.nextButton(c);
1018
+ } else if (typeof step.nextButton === "string") {
1019
+ nextButtonText = step.nextButton;
1020
+ } else if (nextStep) {
1021
+ nextButtonText = nextStep.label;
1022
+ }
1023
+
1024
+ return {
1025
+ key: step.key,
1026
+ label: step.label,
1027
+ autoAdvance: false,
1028
+ nextButtonText,
1029
+ nextButtonColor: step.nextButtonColor,
1030
+ processingText: step.processingText,
1031
+ };
1032
+ });
1033
+ }
1034
+
1035
+ /* ── Flow Executor ───────────────────────────────────── */
1036
+
1037
+ export interface FlowExecutorDeps {
1038
+ animateParallel: (
1039
+ pairs: { from: string; to: string }[],
1040
+ duration: number,
1041
+ ) => Promise<void>;
1042
+ patch: (p: Partial<${pascalName}State>) => void;
1043
+ getState: () => ${pascalName}State;
1044
+ cancelled: () => boolean;
1045
+ }
1046
+
1047
+ export async function executeFlow(
1048
+ beats: FlowBeat[],
1049
+ deps: FlowExecutorDeps,
1050
+ ): Promise<void> {
1051
+ const components = deps.getState().components;
1052
+ const activeBeats = beats.filter((b) => !b.when || b.when(components));
1053
+
1054
+ for (const beat of activeBeats) {
1055
+ if (deps.cancelled()) return;
1056
+
1057
+ const state = deps.getState();
1058
+ const froms = expandToken(beat.from, state);
1059
+ const tos = expandToken(beat.to, state);
1060
+
1061
+ // Cartesian product → parallel signal pairs
1062
+ const pairs: { from: string; to: string }[] = [];
1063
+ for (const f of froms) {
1064
+ for (const t of tos) {
1065
+ pairs.push({ from: f, to: t });
1066
+ }
1067
+ }
1068
+
1069
+ const hotZones = [...new Set([...froms, ...tos])];
1070
+ const update: Partial<${pascalName}State> = { hotZones };
1071
+ if (beat.explain) update.explanation = beat.explain;
1072
+ deps.patch(update);
1073
+
1074
+ await deps.animateParallel(pairs, beat.duration ?? 600);
1075
+ }
1076
+ }
1077
+ `;
1078
+
1079
+ fs.writeFileSync(path.join(targetDir, "flow-engine.ts"), flowEngineContent);
1080
+
1081
+ /* ================================================================
1082
+ 8. (Sandbox only) Controls — controls.tsx
1083
+ ================================================================ */
1084
+ const controlsContent = `import React from "react";
1085
+ import { useDispatch, useSelector } from "react-redux";
1086
+ import { type RootState } from "../../store/store";
1087
+ import { resetSimulation } from "../../store/slices/simulationSlice";
1088
+ import {
1089
+ addClient,
1090
+ removeClient,
1091
+ addComponent,
1092
+ removeComponent,
1093
+ type ${pascalName}State,
1094
+ type ComponentName,
1095
+ } from "./${camelName}Slice";
1096
+
1097
+ /* ── Component toggle descriptor ─────────────────────── */
1098
+ interface Toggle {
1099
+ name: ComponentName;
1100
+ label: string;
1101
+ addLabel: string;
1102
+ removeLabel: string;
1103
+ color: string;
1104
+ requires?: ComponentName[];
1105
+ multi?: boolean;
1106
+ }
1107
+
1108
+ const TOGGLES: Toggle[] = [
1109
+ // TODO: define your component toggles here, e.g.:
1110
+ // {
1111
+ // name: "database",
1112
+ // label: "Database",
1113
+ // addLabel: "+ Database",
1114
+ // removeLabel: "− Database",
1115
+ // color: "#22c55e",
1116
+ // },
1117
+ ];
1118
+
1119
+ const ${pascalName}Controls: React.FC = () => {
1120
+ const dispatch = useDispatch();
1121
+ const { components, clients } = useSelector(
1122
+ (state: RootState) => state.${camelName},
1123
+ ) as ${pascalName}State;
1124
+
1125
+ const handleAdd = (name: ComponentName) => {
1126
+ dispatch(addComponent(name));
1127
+ dispatch(resetSimulation());
1128
+ };
1129
+
1130
+ const handleRemove = (name: ComponentName) => {
1131
+ dispatch(removeComponent(name));
1132
+ dispatch(resetSimulation());
1133
+ };
1134
+
1135
+ return (
1136
+ <div className="${pluginName}-controls">
1137
+ {/* Client count */}
1138
+ <div className="${pluginName}-controls__group">
1139
+ <button
1140
+ className="${pluginName}-controls__btn"
1141
+ onClick={() => dispatch(removeClient())}
1142
+ disabled={clients.length <= 1}
1143
+ >
1144
+
1145
+ </button>
1146
+ <span className="${pluginName}-controls__label">
1147
+ {clients.length} client{clients.length !== 1 ? "s" : ""}
1148
+ </span>
1149
+ <button
1150
+ className="${pluginName}-controls__btn"
1151
+ onClick={() => dispatch(addClient())}
1152
+ disabled={clients.length >= 12}
1153
+ >
1154
+ +
1155
+ </button>
1156
+ </div>
1157
+
1158
+ <span className="${pluginName}-controls__sep" />
1159
+
1160
+ {/* Infrastructure toggles */}
1161
+ {TOGGLES.map((t) => {
1162
+ const isActive = t.multi
1163
+ ? (components[t.name] as number) > 0
1164
+ : !!components[t.name];
1165
+ const prereqMet =
1166
+ !t.requires || t.requires.every((r) => !!components[r]);
1167
+ const canAdd = t.multi
1168
+ ? prereqMet
1169
+ : !isActive && prereqMet;
1170
+
1171
+ return (
1172
+ <div key={t.name} className="${pluginName}-controls__group">
1173
+ {isActive && (
1174
+ <button
1175
+ className="${pluginName}-controls__btn ${pluginName}-controls__btn--remove"
1176
+ style={{ borderColor: t.color }}
1177
+ onClick={() => handleRemove(t.name)}
1178
+ >
1179
+ {t.removeLabel}
1180
+ </button>
1181
+ )}
1182
+ {canAdd && (
1183
+ <button
1184
+ className="${pluginName}-controls__btn ${pluginName}-controls__btn--add"
1185
+ style={{ borderColor: t.color }}
1186
+ onClick={() => handleAdd(t.name)}
1187
+ >
1188
+ {t.addLabel}
1189
+ </button>
1190
+ )}
1191
+ </div>
1192
+ );
1193
+ })}
1194
+ </div>
1195
+ );
1196
+ };
1197
+
1198
+ export default ${pascalName}Controls;
1199
+ `;
1200
+
1201
+ fs.writeFileSync(path.join(targetDir, "controls.tsx"), controlsContent);
1202
+ } // end sandbox-only files
1203
+
1204
+ /* ================================================================
1205
+ 9. Update registry.ts — add import + wire into category
470
1206
  ================================================================ */
471
1207
  const registryPath = path.join(__dirname, "../src/registry.ts");
472
1208
  if (fs.existsSync(registryPath)) {
@@ -577,7 +1313,7 @@ if (fs.existsSync(registryPath)) {
577
1313
 
578
1314
  console.log("");
579
1315
  console.log(
580
- '✔ Created plugin "' + pluginName + '" in src/plugins/' + pluginName,
1316
+ '✔ Created ' + (isSandbox ? 'sandbox ' : '') + 'plugin "' + pluginName + '" in src/plugins/' + pluginName,
581
1317
  );
582
1318
  console.log("");
583
1319
  console.log(" Files generated:");
@@ -587,8 +1323,20 @@ console.log(" • concepts.tsx — InfoModal concept definitions");
587
1323
  console.log(" • main.tsx — Component (uses plugin-kit)");
588
1324
  console.log(" • main.scss — Styles");
589
1325
  console.log(" • index.ts — Plugin registration");
1326
+ if (isSandbox) {
1327
+ console.log(" • flow-engine.ts — Declarative step & flow config");
1328
+ console.log(" • controls.tsx — Controls panel (component toggles)");
1329
+ }
590
1330
  console.log("");
591
1331
  console.log(" Next steps:");
592
- console.log(" 1. Define your VizCraft nodes/edges in main.tsx");
593
- console.log(" 2. Add step animations in use" + pascalName + "Animation.ts");
594
- console.log(" 3. Add concept pills & definitions in concepts.tsx");
1332
+ if (isSandbox) {
1333
+ console.log(" 1. Define InfraComponents in " + camelName + "Slice.ts");
1334
+ console.log(" 2. Add togglable components to TOGGLES in controls.tsx");
1335
+ console.log(" 3. Define steps declaratively in STEPS array (flow-engine.ts)");
1336
+ console.log(" 4. Build dynamic scene in main.tsx (nodes/edges adapt to state)");
1337
+ console.log(" 5. Add concept pills & definitions in concepts.tsx");
1338
+ } else {
1339
+ console.log(" 1. Define your VizCraft nodes/edges in main.tsx");
1340
+ console.log(" 2. Add step animations in use" + pascalName + "Animation.ts");
1341
+ console.log(" 3. Add concept pills & definitions in concepts.tsx");
1342
+ }
@@ -12,11 +12,7 @@
12
12
  /* dark slate */
13
13
  color: #e5e7eb;
14
14
  /* light gray */
15
- font-family:
16
- system-ui,
17
- -apple-system,
18
- BlinkMacSystemFont,
19
- "Segoe UI",
15
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
20
16
  sans-serif;
21
17
  background-color: #111827; // Gray-900
22
18
  }
@@ -58,9 +54,7 @@
58
54
  align-items: center;
59
55
  justify-content: center;
60
56
  flex-shrink: 0;
61
- transition:
62
- background 0.15s,
63
- border-color 0.15s;
57
+ transition: background 0.15s, border-color 0.15s;
64
58
  padding: 0;
65
59
 
66
60
  &:hover {
@@ -130,8 +124,25 @@
130
124
  position: relative;
131
125
  overflow: hidden;
132
126
  display: flex;
127
+ flex-direction: column;
133
128
  justify-content: center;
134
129
  align-items: center;
135
130
  padding: 2rem;
136
131
  background-color: #111827; // Gray-900
137
132
  }
133
+
134
+ /* ── Plugin Controls slot ─────────────────────────── */
135
+ .plugin-controls {
136
+ width: 100%;
137
+ flex-shrink: 0;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ padding: 0.5rem 1rem;
142
+ background: rgba(15, 23, 42, 0.6);
143
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
144
+ border-radius: 12px 12px 0 0;
145
+ backdrop-filter: blur(8px);
146
+ gap: 0.75rem;
147
+ flex-wrap: wrap;
148
+ }
@@ -134,6 +134,11 @@ const Shell: React.FC<ShellProps> = ({ plugin, category }) => {
134
134
  />
135
135
 
136
136
  <div className="visualization-container">
137
+ {plugin.Controls && (
138
+ <div className="plugin-controls">
139
+ <plugin.Controls />
140
+ </div>
141
+ )}
137
142
  <plugin.Component onAnimationComplete={handleAnimationComplete} />
138
143
  </div>
139
144
  </div>
@@ -1,4 +1,4 @@
1
- @import "./components/plugin-kit/plugin-kit.scss";
1
+ @use "./components/plugin-kit/plugin-kit.scss";
2
2
 
3
3
  :root {
4
4
  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;