create-vizcraft-playground 0.1.0

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.
Files changed (49) hide show
  1. package/index.js +171 -0
  2. package/package.json +44 -0
  3. package/template/.github/skills/vizcraft-playground/SKILL.md +573 -0
  4. package/template/README.md +59 -0
  5. package/template/eslint.config.js +23 -0
  6. package/template/index.html +13 -0
  7. package/template/package.json +37 -0
  8. package/template/public/vite.svg +1 -0
  9. package/template/scripts/generate-plugin.js +594 -0
  10. package/template/scripts/init-playground.js +119 -0
  11. package/template/src/App.scss +137 -0
  12. package/template/src/App.tsx +72 -0
  13. package/template/src/assets/react.svg +1 -0
  14. package/template/src/components/InfoModal/InfoModal.scss +211 -0
  15. package/template/src/components/InfoModal/InfoModal.tsx +85 -0
  16. package/template/src/components/Landing/Landing.scss +85 -0
  17. package/template/src/components/Landing/Landing.tsx +55 -0
  18. package/template/src/components/Shell.tsx +144 -0
  19. package/template/src/components/StepIndicator/StepIndicator.scss +151 -0
  20. package/template/src/components/StepIndicator/StepIndicator.tsx +73 -0
  21. package/template/src/components/VizInfoBeacon/VizInfoBeacon.scss +41 -0
  22. package/template/src/components/VizInfoBeacon/VizInfoBeacon.tsx +157 -0
  23. package/template/src/components/plugin-kit/CanvasStage.tsx +30 -0
  24. package/template/src/components/plugin-kit/ConceptPills.tsx +55 -0
  25. package/template/src/components/plugin-kit/PluginLayout.tsx +41 -0
  26. package/template/src/components/plugin-kit/SidePanel.tsx +69 -0
  27. package/template/src/components/plugin-kit/StageHeader.tsx +35 -0
  28. package/template/src/components/plugin-kit/StatBadge.tsx +35 -0
  29. package/template/src/components/plugin-kit/index.ts +42 -0
  30. package/template/src/components/plugin-kit/plugin-kit.scss +241 -0
  31. package/template/src/components/plugin-kit/useConceptModal.tsx +51 -0
  32. package/template/src/index.scss +81 -0
  33. package/template/src/main.tsx +14 -0
  34. package/template/src/playground.config.ts +27 -0
  35. package/template/src/plugins/hello-world/concepts.tsx +70 -0
  36. package/template/src/plugins/hello-world/helloWorldSlice.ts +29 -0
  37. package/template/src/plugins/hello-world/index.ts +48 -0
  38. package/template/src/plugins/hello-world/main.scss +32 -0
  39. package/template/src/plugins/hello-world/main.tsx +144 -0
  40. package/template/src/plugins/hello-world/useHelloWorldAnimation.ts +99 -0
  41. package/template/src/registry.ts +73 -0
  42. package/template/src/store/slices/simulationSlice.ts +47 -0
  43. package/template/src/store/store.ts +13 -0
  44. package/template/src/types/ModelPlugin.ts +55 -0
  45. package/template/src/utils/random.ts +11 -0
  46. package/template/tsconfig.app.json +35 -0
  47. package/template/tsconfig.json +7 -0
  48. package/template/tsconfig.node.json +26 -0
  49. package/template/vite.config.ts +7 -0
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,594 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ // ── Arg parsing ────────────────────────────────────────────
9
+ // Usage: npm run generate <plugin-name> [--category "Category Name"]
10
+ const args = process.argv.slice(2);
11
+ let pluginName = null;
12
+ let categoryName = null;
13
+
14
+ for (let i = 0; i < args.length; i++) {
15
+ if (args[i] === "--category" || args[i] === "-c") {
16
+ categoryName = args[++i];
17
+ } else if (!args[i].startsWith("-")) {
18
+ pluginName = args[i];
19
+ }
20
+ }
21
+
22
+ if (!pluginName) {
23
+ console.error(
24
+ "Usage: npm run generate <plugin-name> [--category \"Category Name\"]\n" +
25
+ " 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",
27
+ );
28
+ process.exit(1);
29
+ }
30
+
31
+ // ── Helpers ────────────────────────────────────────────────
32
+ const toPascalCase = (str) =>
33
+ str.replace(/(^\w|-\w)/g, (m) => m.replace(/-/, "").toUpperCase());
34
+
35
+ const toCamelCase = (str) =>
36
+ str.replace(/-\w/g, (m) => m[1].toUpperCase());
37
+
38
+ const pascalName = toPascalCase(pluginName);
39
+ const camelName = toCamelCase(pluginName);
40
+ const targetDir = path.join(__dirname, "../src/plugins", pluginName);
41
+
42
+ if (fs.existsSync(targetDir)) {
43
+ console.error(`Plugin "${pluginName}" already exists at ${targetDir}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ fs.mkdirSync(targetDir, { recursive: true });
48
+
49
+ /* ================================================================
50
+ 1. Redux Slice — ${camelName}Slice.ts
51
+ ================================================================ */
52
+ const sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
53
+
54
+ export type ${pascalName}Phase = "overview" | "processing" | "summary";
55
+
56
+ export interface ${pascalName}State {
57
+ phase: ${pascalName}Phase;
58
+ explanation: string;
59
+ hotZones: string[];
60
+ }
61
+
62
+ export const initialState: ${pascalName}State = {
63
+ phase: "overview",
64
+ explanation: "Welcome — explore the architecture before stepping through.",
65
+ hotZones: [],
66
+ };
67
+
68
+ const ${camelName}Slice = createSlice({
69
+ name: "${camelName}",
70
+ initialState,
71
+ reducers: {
72
+ patchState(state, action: PayloadAction<Partial<${pascalName}State>>) {
73
+ Object.assign(state, action.payload);
74
+ },
75
+ reset() {
76
+ return initialState;
77
+ },
78
+ },
79
+ });
80
+
81
+ export const { patchState, reset } = ${camelName}Slice.actions;
82
+ export default ${camelName}Slice.reducer;
83
+ `;
84
+
85
+ fs.writeFileSync(path.join(targetDir, `${camelName}Slice.ts`), sliceContent);
86
+
87
+ /* ================================================================
88
+ 2. Animation Hook — use${pascalName}Animation.ts
89
+ ================================================================ */
90
+ const hookContent = `import { useCallback, useEffect, useRef, useState } from "react";
91
+ import { useDispatch, useSelector } from "react-redux";
92
+ import type { SignalOverlayParams } from "vizcraft";
93
+ import { type RootState } from "../../store/store";
94
+ import { patchState, reset, type ${pascalName}Phase } from "./${camelName}Slice";
95
+
96
+ export type Signal = { id: string } & SignalOverlayParams;
97
+
98
+ export const use${pascalName}Animation = (onAnimationComplete?: () => void) => {
99
+ const dispatch = useDispatch();
100
+ const { currentStep } = useSelector((state: RootState) => state.simulation);
101
+ const runtime = useSelector((state: RootState) => state.${camelName});
102
+ const [signals, setSignals] = useState<Signal[]>([]);
103
+ const [animPhase, setAnimPhase] = useState<string>("idle");
104
+ const timeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
105
+ const onCompleteRef = useRef(onAnimationComplete);
106
+
107
+ onCompleteRef.current = onAnimationComplete;
108
+
109
+ const cleanup = useCallback(() => {
110
+ timeoutsRef.current.forEach((id) => clearTimeout(id));
111
+ timeoutsRef.current = [];
112
+ setSignals([]);
113
+ }, []);
114
+
115
+ const sleep = useCallback((ms: number) => {
116
+ return new Promise<void>((resolve) => {
117
+ const id = setTimeout(() => resolve(), ms);
118
+ timeoutsRef.current.push(id);
119
+ });
120
+ }, []);
121
+
122
+ const finish = useCallback(() => {
123
+ onCompleteRef.current?.();
124
+ }, []);
125
+
126
+ /* ── Step orchestration ─────────────────────────────────── */
127
+ useEffect(() => {
128
+ cleanup();
129
+
130
+ const run = async () => {
131
+ switch (currentStep) {
132
+ case 0:
133
+ dispatch(reset());
134
+ setAnimPhase("idle");
135
+ finish();
136
+ break;
137
+
138
+ case 1:
139
+ dispatch(
140
+ patchState({
141
+ phase: "processing",
142
+ explanation: "Step 1 — describe what is happening here.",
143
+ hotZones: ["node-a"],
144
+ }),
145
+ );
146
+ setAnimPhase("processing");
147
+ await sleep(1200);
148
+ finish();
149
+ break;
150
+
151
+ case 2:
152
+ dispatch(
153
+ patchState({
154
+ phase: "summary",
155
+ explanation: "All done — here is the takeaway.",
156
+ hotZones: [],
157
+ }),
158
+ );
159
+ setAnimPhase("idle");
160
+ finish();
161
+ break;
162
+
163
+ default:
164
+ finish();
165
+ }
166
+ };
167
+
168
+ run();
169
+ return cleanup;
170
+ }, [currentStep]);
171
+
172
+ return {
173
+ runtime,
174
+ currentStep,
175
+ signals,
176
+ animPhase,
177
+ phase: runtime.phase,
178
+ };
179
+ };
180
+ `;
181
+
182
+ fs.writeFileSync(
183
+ path.join(targetDir, `use${pascalName}Animation.ts`),
184
+ hookContent,
185
+ );
186
+
187
+ /* ================================================================
188
+ 3. Concepts — concepts.tsx
189
+ ================================================================ */
190
+ const conceptsContent = `import React from "react";
191
+ import type { InfoModalSection } from "../../components/InfoModal/InfoModal";
192
+
193
+ export type ConceptKey = "overview";
194
+
195
+ interface ConceptDefinition {
196
+ title: string;
197
+ subtitle: string;
198
+ accentColor: string;
199
+ sections: InfoModalSection[];
200
+ aside?: React.ReactNode;
201
+ }
202
+
203
+ export const concepts: Record<ConceptKey, ConceptDefinition> = {
204
+ overview: {
205
+ title: "${pascalName}",
206
+ subtitle: "A brief explanation of this concept",
207
+ accentColor: "#60a5fa",
208
+ sections: [
209
+ {
210
+ title: "What it does",
211
+ accent: "#60a5fa",
212
+ content: (
213
+ <p>
214
+ Explain the core concept here. Keep it concise — one or two
215
+ paragraphs is usually enough.
216
+ </p>
217
+ ),
218
+ },
219
+ ],
220
+ },
221
+ };
222
+ `;
223
+
224
+ fs.writeFileSync(path.join(targetDir, "concepts.tsx"), conceptsContent);
225
+
226
+ /* ================================================================
227
+ 4. Main Component — main.tsx
228
+ ================================================================ */
229
+ const mainContent = `import React, { useLayoutEffect, useRef, useEffect } from "react";
230
+ import {
231
+ viz,
232
+ type PanZoomController,
233
+ type SignalOverlayParams,
234
+ } from "vizcraft";
235
+ import {
236
+ useConceptModal,
237
+ ConceptPills,
238
+ PluginLayout,
239
+ StageHeader,
240
+ StatBadge,
241
+ SidePanel,
242
+ SideCard,
243
+ CanvasStage,
244
+ } from "../../components/plugin-kit";
245
+ import { concepts, type ConceptKey } from "./concepts";
246
+ import { use${pascalName}Animation, type Signal } from "./use${pascalName}Animation";
247
+ import "./main.scss";
248
+
249
+ interface Props {
250
+ onAnimationComplete?: () => void;
251
+ }
252
+
253
+ const W = 900;
254
+ const H = 600;
255
+
256
+ const ${pascalName}Visualization: React.FC<Props> = ({ onAnimationComplete }) => {
257
+ const { runtime, currentStep, signals, animPhase, phase } =
258
+ use${pascalName}Animation(onAnimationComplete);
259
+ const { openConcept, ConceptModal } = useConceptModal<ConceptKey>(concepts);
260
+ const containerRef = useRef<HTMLDivElement>(null!);
261
+ const builderRef = useRef<ReturnType<typeof viz> | null>(null);
262
+ const pzRef = useRef<PanZoomController | null>(null);
263
+
264
+ const { explanation, hotZones } = runtime;
265
+ const hot = (zone: string) => hotZones.includes(zone);
266
+
267
+ /* ── Build VizCraft scene ─────────────────────────────── */
268
+ const scene = (() => {
269
+ const b = viz().view(W, H);
270
+
271
+ // ── Nodes ────────────────────────────────────────────
272
+ b.node("node-a")
273
+ .at(200, 300)
274
+ .rect(140, 60, 12)
275
+ .fill(hot("node-a") ? "#1e40af" : "#0f172a")
276
+ .stroke(hot("node-a") ? "#60a5fa" : "#334155", 2)
277
+ .label("Node A", { fill: "#fff", fontSize: 13, fontWeight: "bold" });
278
+
279
+ b.node("node-b")
280
+ .at(650, 300)
281
+ .rect(140, 60, 12)
282
+ .fill(hot("node-b") ? "#065f46" : "#0f172a")
283
+ .stroke(hot("node-b") ? "#34d399" : "#334155", 2)
284
+ .label("Node B", { fill: "#fff", fontSize: 13, fontWeight: "bold" });
285
+
286
+ // ── Edges ────────────────────────────────────────────
287
+ b.edge("node-a", "node-b", "edge-ab")
288
+ .stroke("#475569", 2)
289
+ .animate("flow", { duration: "3s" });
290
+
291
+ // ── Signals ──────────────────────────────────────────
292
+ if (signals.length > 0) {
293
+ b.overlay((o) => {
294
+ signals.forEach((sig) => {
295
+ const { id, ...params } = sig;
296
+ o.add("signal", params as SignalOverlayParams, { key: id });
297
+ });
298
+ });
299
+ }
300
+
301
+ return b;
302
+ })();
303
+
304
+ /* ── Mount / destroy VizCraft scene ─────────────────── */
305
+ useLayoutEffect(() => {
306
+ if (!containerRef.current) return;
307
+ const saved = pzRef.current?.getState() ?? null;
308
+ builderRef.current?.destroy();
309
+ builderRef.current = scene;
310
+ pzRef.current =
311
+ scene.mount(containerRef.current, {
312
+ autoplay: true,
313
+ panZoom: true,
314
+ initialZoom: saved?.zoom ?? 1,
315
+ initialPan: saved?.pan ?? { x: 0, y: 0 },
316
+ }) ?? null;
317
+ }, [scene]);
318
+
319
+ useEffect(() => {
320
+ return () => {
321
+ builderRef.current?.destroy();
322
+ builderRef.current = null;
323
+ pzRef.current = null;
324
+ };
325
+ }, []);
326
+
327
+ /* ── Pill definitions ───────────────────────────────── */
328
+ const pills = [
329
+ { key: "overview", label: "${pascalName}", color: "#93c5fd", borderColor: "#3b82f6" },
330
+ ];
331
+
332
+ /* ── Render ─────────────────────────────────────────── */
333
+ return (
334
+ <div className="${pluginName}-root">
335
+ <PluginLayout
336
+ toolbar={
337
+ <ConceptPills pills={pills} onOpen={openConcept} />
338
+ }
339
+ canvas={
340
+ <div className="${pluginName}-stage">
341
+ <StageHeader
342
+ title="${pascalName}"
343
+ subtitle="Describe the visualisation in one line."
344
+ >
345
+ <StatBadge
346
+ label="Phase"
347
+ value={phase}
348
+ className={\`${pluginName}-phase ${pluginName}-phase--\${phase}\`}
349
+ />
350
+ </StageHeader>
351
+ <CanvasStage canvasRef={containerRef} />
352
+ </div>
353
+ }
354
+ sidebar={
355
+ <SidePanel>
356
+ <SideCard label="What's happening" variant="explanation">
357
+ <p>{explanation}</p>
358
+ </SideCard>
359
+ </SidePanel>
360
+ }
361
+ />
362
+ <ConceptModal />
363
+ </div>
364
+ );
365
+ };
366
+
367
+ export default ${pascalName}Visualization;
368
+ `;
369
+
370
+ fs.writeFileSync(path.join(targetDir, "main.tsx"), mainContent);
371
+
372
+ /* ================================================================
373
+ 5. Styles — main.scss
374
+ ================================================================ */
375
+ const scssContent = `.${pluginName}-root {
376
+ --${pluginName}-bg: #020617;
377
+ --${pluginName}-panel: rgba(7, 17, 34, 0.88);
378
+ --${pluginName}-border: rgba(148, 163, 184, 0.18);
379
+ --${pluginName}-text: #e2e8f0;
380
+ --${pluginName}-muted: #94a3b8;
381
+
382
+ display: flex;
383
+ flex-direction: column;
384
+ width: 100%;
385
+ height: 100%;
386
+ overflow: hidden;
387
+ color: var(--${pluginName}-text);
388
+ background:
389
+ radial-gradient(circle at top left, rgba(59, 130, 246, 0.14), transparent 28%),
390
+ radial-gradient(circle at bottom right, rgba(20, 184, 166, 0.12), transparent 30%),
391
+ linear-gradient(180deg, #020617 0%, #071325 100%);
392
+ }
393
+
394
+ /* ── Stage ──────────────────────────────────────────── */
395
+ .${pluginName}-stage {
396
+ background: var(--${pluginName}-panel);
397
+ border: 1px solid var(--${pluginName}-border);
398
+ box-shadow: 0 20px 42px -28px rgba(0, 0, 0, 0.7);
399
+ border-radius: 24px;
400
+ padding: 1rem;
401
+ display: flex;
402
+ flex-direction: column;
403
+ min-height: 0;
404
+ }
405
+
406
+ /* ── Phase colours ──────────────────────────────────── */
407
+ .${pluginName}-phase--overview .vc-stat-badge__value { color: #fbbf24; }
408
+ .${pluginName}-phase--processing .vc-stat-badge__value { color: #60a5fa; }
409
+ .${pluginName}-phase--summary .vc-stat-badge__value { color: #86efac; }
410
+ `;
411
+
412
+ fs.writeFileSync(path.join(targetDir, "main.scss"), scssContent);
413
+
414
+ /* ================================================================
415
+ 6. Plugin Registration — index.ts
416
+ ================================================================ */
417
+ const indexContent = `import type { Action, Dispatch } from "@reduxjs/toolkit";
418
+ import type { DemoPlugin, DemoStep } from "../../types/ModelPlugin";
419
+ import ${pascalName}Visualization from "./main";
420
+ import ${camelName}Reducer, {
421
+ type ${pascalName}State,
422
+ initialState,
423
+ reset,
424
+ } from "./${camelName}Slice";
425
+
426
+ type LocalRootState = { ${camelName}: ${pascalName}State };
427
+
428
+ const ${pascalName}Plugin: DemoPlugin<
429
+ ${pascalName}State,
430
+ Action,
431
+ LocalRootState,
432
+ Dispatch<Action>
433
+ > = {
434
+ id: "${pluginName}",
435
+ name: "${pascalName}",
436
+ description: "Describe what this demo teaches in one sentence.",
437
+ initialState,
438
+ reducer: ${camelName}Reducer,
439
+ Component: ${pascalName}Visualization,
440
+ restartConfig: { text: "Replay", color: "#1e40af" },
441
+ getSteps: (_: ${pascalName}State): DemoStep[] => [
442
+ {
443
+ label: "Overview",
444
+ autoAdvance: false,
445
+ nextButtonText: "Begin",
446
+ },
447
+ {
448
+ label: "Step One",
449
+ autoAdvance: true,
450
+ processingText: "Running…",
451
+ },
452
+ {
453
+ label: "Summary",
454
+ autoAdvance: true,
455
+ },
456
+ ],
457
+ init: (dispatch) => {
458
+ dispatch(reset());
459
+ },
460
+ selector: (state: LocalRootState) => state.${camelName},
461
+ };
462
+
463
+ export default ${pascalName}Plugin;
464
+ `;
465
+
466
+ fs.writeFileSync(path.join(targetDir, "index.ts"), indexContent);
467
+
468
+ /* ================================================================
469
+ 7. Update registry.ts — add import + wire into category
470
+ ================================================================ */
471
+ const registryPath = path.join(__dirname, "../src/registry.ts");
472
+ if (fs.existsSync(registryPath)) {
473
+ let regContent = fs.readFileSync(registryPath, "utf-8");
474
+
475
+ // ── 7a. Add import after the last plugin import ──────────
476
+ const regImport = `import ${pascalName}Plugin from "./plugins/${pluginName}";`;
477
+ const lastPluginImportRegex = /import .*Plugin from ".\/plugins\/.*";/g;
478
+ let match;
479
+ let lastIdx = -1;
480
+ while ((match = lastPluginImportRegex.exec(regContent)) !== null) {
481
+ lastIdx = match.index + match[0].length;
482
+ }
483
+
484
+ if (lastIdx !== -1) {
485
+ regContent =
486
+ regContent.slice(0, lastIdx) +
487
+ "\n" +
488
+ regImport +
489
+ regContent.slice(lastIdx);
490
+ }
491
+
492
+ console.log("✔ Updated src/registry.ts with import for " + pascalName + "Plugin");
493
+
494
+ // ── 7b. Place plugin into a category ─────────────────────
495
+ if (categoryName) {
496
+ // Check if category already exists by matching name: "Category Name"
497
+ // We look for name: "...", inside the categories array.
498
+ const nameLiteralEscaped = categoryName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
499
+ const categoryNameRegex = new RegExp(
500
+ `name:\\s*["']${nameLiteralEscaped}["']`,
501
+ );
502
+ const categoryMatch = categoryNameRegex.exec(regContent);
503
+
504
+ if (categoryMatch) {
505
+ // ── Category exists: append plugin to its plugins array ──
506
+ // Find the `plugins: [...]` line that follows this category name.
507
+ // Search from the category name match position forward.
508
+ const afterName = regContent.slice(categoryMatch.index);
509
+ const pluginsArrayRegex = /plugins:\s*\[([^\]]*)\]/;
510
+ const pluginsMatch = pluginsArrayRegex.exec(afterName);
511
+
512
+ if (pluginsMatch) {
513
+ const arrayContent = pluginsMatch[1].trimEnd();
514
+ // Build the new array content — append the new plugin
515
+ const newArrayContent = arrayContent.endsWith(",")
516
+ ? arrayContent + " " + pascalName + "Plugin"
517
+ : arrayContent + ", " + pascalName + "Plugin";
518
+
519
+ const absStart = categoryMatch.index + pluginsMatch.index;
520
+ const absEnd = absStart + pluginsMatch[0].length;
521
+ regContent =
522
+ regContent.slice(0, absStart) +
523
+ "plugins: [" + newArrayContent + "]" +
524
+ regContent.slice(absEnd);
525
+
526
+ console.log(
527
+ '✔ Added ' + pascalName + 'Plugin to existing category "' + categoryName + '"',
528
+ );
529
+ }
530
+ } else {
531
+ // ── Category does not exist: create a new one ────────────
532
+ const categorySlug = categoryName
533
+ .toLowerCase()
534
+ .replace(/[^a-z0-9]+/g, "-")
535
+ .replace(/(^-|-$)/g, "");
536
+
537
+ const newCategory =
538
+ " {\n" +
539
+ ' id: "' + categorySlug + '",\n' +
540
+ ' name: "' + categoryName + '",\n' +
541
+ ' description: "Add a description for this category.",\n' +
542
+ ' accent: "#6366f1",\n' +
543
+ " plugins: [" + pascalName + "Plugin],\n" +
544
+ " },";
545
+
546
+ // Insert before the closing `];` of the categories array.
547
+ const closingBracket = /^];\s*$/m;
548
+ const closingMatch = closingBracket.exec(regContent);
549
+
550
+ if (closingMatch) {
551
+ regContent =
552
+ regContent.slice(0, closingMatch.index) +
553
+ newCategory + "\n" +
554
+ regContent.slice(closingMatch.index);
555
+
556
+ console.log(
557
+ '✔ Created new category "' + categoryName + '" with ' + pascalName + "Plugin",
558
+ );
559
+ } else {
560
+ console.log(
561
+ '⚠ Could not locate categories array — add "' + categoryName + '" manually.',
562
+ );
563
+ }
564
+ }
565
+ } else {
566
+ console.log("");
567
+ console.log(" ⚠ No --category flag provided. Add the plugin to a category manually:");
568
+ console.log(" plugins: [..., " + pascalName + "Plugin],");
569
+ }
570
+
571
+ fs.writeFileSync(registryPath, regContent);
572
+ } else {
573
+ console.log(
574
+ "Could not find src/registry.ts — add the plugin to the registry manually.",
575
+ );
576
+ }
577
+
578
+ console.log("");
579
+ console.log(
580
+ '✔ Created plugin "' + pluginName + '" in src/plugins/' + pluginName,
581
+ );
582
+ console.log("");
583
+ console.log(" Files generated:");
584
+ console.log(" • " + camelName + "Slice.ts — Redux state & actions");
585
+ console.log(" • use" + pascalName + "Animation.ts — Step orchestration & signals");
586
+ console.log(" • concepts.tsx — InfoModal concept definitions");
587
+ console.log(" • main.tsx — Component (uses plugin-kit)");
588
+ console.log(" • main.scss — Styles");
589
+ console.log(" • index.ts — Plugin registration");
590
+ console.log("");
591
+ 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");