@illuma-ai/code-sandbox 1.0.0 → 1.2.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.
- package/dist/components/CodeEditor.d.ts +18 -5
- package/dist/components/FileTree.d.ts +2 -1
- package/dist/components/Preview.d.ts +15 -3
- package/dist/components/Workbench.d.ts +3 -2
- package/dist/hooks/useRuntime.d.ts +35 -2
- package/dist/index.cjs +348 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8566 -7799
- package/dist/index.js.map +1 -1
- package/dist/services/runtime.d.ts +58 -1
- package/dist/types.d.ts +197 -9
- package/package.json +1 -1
- package/src/components/CodeEditor.tsx +141 -11
- package/src/components/FileTree.tsx +66 -4
- package/src/components/Preview.tsx +188 -5
- package/src/components/Workbench.tsx +140 -84
- package/src/hooks/useRuntime.ts +426 -89
- package/src/index.ts +4 -0
- package/src/services/runtime.ts +240 -1
- package/src/styles.css +96 -0
- package/src/templates/fullstack-starter.ts +211 -1
- package/src/types.ts +227 -10
package/src/services/runtime.ts
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import { Nodepod } from "@illuma-ai/nodepod";
|
|
11
11
|
import type { NodepodProcess } from "@illuma-ai/nodepod";
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
BootProgress,
|
|
14
|
+
BootStage,
|
|
15
|
+
FileMap,
|
|
16
|
+
RuntimeConfig,
|
|
17
|
+
SandboxError,
|
|
18
|
+
SandboxErrorCategory,
|
|
19
|
+
} from "../types";
|
|
13
20
|
|
|
14
21
|
/** Callback type for progress updates */
|
|
15
22
|
type ProgressCallback = (progress: BootProgress) => void;
|
|
@@ -17,6 +24,8 @@ type ProgressCallback = (progress: BootProgress) => void;
|
|
|
17
24
|
type OutputCallback = (line: string) => void;
|
|
18
25
|
/** Callback type for server ready */
|
|
19
26
|
type ServerReadyCallback = (port: number, url: string) => void;
|
|
27
|
+
/** Callback type for structured errors */
|
|
28
|
+
type ErrorCallback = (error: SandboxError) => void;
|
|
20
29
|
|
|
21
30
|
/** Debug log prefix for easy filtering in DevTools console */
|
|
22
31
|
const DBG = "[CodeSandbox:Runtime]";
|
|
@@ -42,11 +51,14 @@ export class NodepodRuntime {
|
|
|
42
51
|
private status: BootStage = "initializing";
|
|
43
52
|
private error: string | null = null;
|
|
44
53
|
private previewUrl: string | null = null;
|
|
54
|
+
private errors: SandboxError[] = [];
|
|
55
|
+
private errorIdCounter = 0;
|
|
45
56
|
|
|
46
57
|
// Callbacks
|
|
47
58
|
private onProgress: ProgressCallback | null = null;
|
|
48
59
|
private onOutput: OutputCallback | null = null;
|
|
49
60
|
private onServerReady: ServerReadyCallback | null = null;
|
|
61
|
+
private onSandboxError: ErrorCallback | null = null;
|
|
50
62
|
|
|
51
63
|
constructor(config: RuntimeConfig) {
|
|
52
64
|
this.config = {
|
|
@@ -78,6 +90,11 @@ export class NodepodRuntime {
|
|
|
78
90
|
this.onServerReady = cb;
|
|
79
91
|
}
|
|
80
92
|
|
|
93
|
+
/** Register a structured error callback */
|
|
94
|
+
setErrorCallback(cb: ErrorCallback): void {
|
|
95
|
+
this.onSandboxError = cb;
|
|
96
|
+
}
|
|
97
|
+
|
|
81
98
|
/** Get the current preview URL (null if server not ready) */
|
|
82
99
|
getPreviewUrl(): string | null {
|
|
83
100
|
return this.previewUrl;
|
|
@@ -119,6 +136,31 @@ export class NodepodRuntime {
|
|
|
119
136
|
return this.error;
|
|
120
137
|
}
|
|
121
138
|
|
|
139
|
+
/** Get all structured errors collected during this session */
|
|
140
|
+
getErrors(): SandboxError[] {
|
|
141
|
+
return [...this.errors];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Report an external error (e.g., from the preview iframe).
|
|
146
|
+
*
|
|
147
|
+
* This is the public entry point for errors that originate outside
|
|
148
|
+
* the runtime — browser console errors, unhandled rejections, etc.
|
|
149
|
+
* The runtime enriches them with source context from the virtual FS
|
|
150
|
+
* and emits them via the onSandboxError callback.
|
|
151
|
+
*/
|
|
152
|
+
async reportError(error: SandboxError): Promise<void> {
|
|
153
|
+
// Enrich with source context if we have a file path and line number
|
|
154
|
+
if (error.filePath && error.line && !error.sourceContext) {
|
|
155
|
+
error.sourceContext = await this.getSourceContext(
|
|
156
|
+
error.filePath,
|
|
157
|
+
error.line,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
this.errors.push(error);
|
|
161
|
+
this.onSandboxError?.(error);
|
|
162
|
+
}
|
|
163
|
+
|
|
122
164
|
// -------------------------------------------------------------------------
|
|
123
165
|
// Lifecycle
|
|
124
166
|
// -------------------------------------------------------------------------
|
|
@@ -287,9 +329,11 @@ export class NodepodRuntime {
|
|
|
287
329
|
}
|
|
288
330
|
} catch (err) {
|
|
289
331
|
const message = err instanceof Error ? err.message : String(err);
|
|
332
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
290
333
|
console.error(DBG, "boot: FATAL ERROR:", err);
|
|
291
334
|
this.error = message;
|
|
292
335
|
this.emitProgress("error", `Error: ${message}`, 0);
|
|
336
|
+
this.emitError("boot", message, { stack });
|
|
293
337
|
throw err;
|
|
294
338
|
}
|
|
295
339
|
}
|
|
@@ -506,6 +550,12 @@ export class NodepodRuntime {
|
|
|
506
550
|
|
|
507
551
|
this.serverProcess.on("error", (chunk: string) => {
|
|
508
552
|
this.appendOutput(`[stderr] ${chunk}`);
|
|
553
|
+
// Emit structured error for stderr output.
|
|
554
|
+
// Filter out noise: skip empty lines and common non-error warnings.
|
|
555
|
+
const trimmed = chunk.trim();
|
|
556
|
+
if (trimmed && !this.isStderrNoise(trimmed)) {
|
|
557
|
+
this.emitError("process-stderr", trimmed);
|
|
558
|
+
}
|
|
509
559
|
});
|
|
510
560
|
|
|
511
561
|
this.serverProcess.on("exit", (code: number) => {
|
|
@@ -513,6 +563,11 @@ export class NodepodRuntime {
|
|
|
513
563
|
if (code !== 0 && this.status !== "ready") {
|
|
514
564
|
this.error = `Process exited with code ${code}`;
|
|
515
565
|
this.emitProgress("error", `Process exited with code ${code}`, 0);
|
|
566
|
+
// Emit structured error for non-zero exit
|
|
567
|
+
this.emitError("process-exit", `Process exited with code ${code}`, {
|
|
568
|
+
// Include last 20 lines of terminal output for context
|
|
569
|
+
stack: this.terminalOutput.slice(-20).join("\n"),
|
|
570
|
+
});
|
|
516
571
|
}
|
|
517
572
|
});
|
|
518
573
|
}
|
|
@@ -533,4 +588,188 @@ export class NodepodRuntime {
|
|
|
533
588
|
this.terminalOutput.push(line);
|
|
534
589
|
this.onOutput?.(line);
|
|
535
590
|
}
|
|
591
|
+
|
|
592
|
+
// -------------------------------------------------------------------------
|
|
593
|
+
// Error helpers
|
|
594
|
+
// -------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
/** Generate a unique error ID */
|
|
597
|
+
private nextErrorId(): string {
|
|
598
|
+
return `err_${++this.errorIdCounter}_${Date.now()}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Create and record a structured error from a process or boot event.
|
|
603
|
+
*
|
|
604
|
+
* Parses the error message to extract file path and line number when
|
|
605
|
+
* possible, then enriches with surrounding source context.
|
|
606
|
+
*/
|
|
607
|
+
private async emitError(
|
|
608
|
+
category: SandboxErrorCategory,
|
|
609
|
+
message: string,
|
|
610
|
+
extra?: Partial<SandboxError>,
|
|
611
|
+
): Promise<void> {
|
|
612
|
+
// Parse file/line from common Node.js error formats:
|
|
613
|
+
// - "/app/server.js:42"
|
|
614
|
+
// - "at Object.<anonymous> (/app/routes/api.js:15:8)"
|
|
615
|
+
// - "SyntaxError: /app/server.js: Unexpected token (12:5)"
|
|
616
|
+
let filePath = extra?.filePath;
|
|
617
|
+
let line = extra?.line;
|
|
618
|
+
let column = extra?.column;
|
|
619
|
+
|
|
620
|
+
if (!filePath) {
|
|
621
|
+
const parsed = this.parseErrorLocation(message + (extra?.stack ?? ""));
|
|
622
|
+
filePath = parsed.filePath;
|
|
623
|
+
line = parsed.line ?? line;
|
|
624
|
+
column = parsed.column ?? column;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let sourceContext = extra?.sourceContext;
|
|
628
|
+
if (filePath && line && !sourceContext) {
|
|
629
|
+
sourceContext = await this.getSourceContext(filePath, line);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const error: SandboxError = {
|
|
633
|
+
id: this.nextErrorId(),
|
|
634
|
+
category,
|
|
635
|
+
message,
|
|
636
|
+
timestamp: new Date().toISOString(),
|
|
637
|
+
...extra,
|
|
638
|
+
filePath,
|
|
639
|
+
line,
|
|
640
|
+
column,
|
|
641
|
+
sourceContext,
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
this.errors.push(error);
|
|
645
|
+
this.onSandboxError?.(error);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Filter out common non-error stderr output.
|
|
650
|
+
*
|
|
651
|
+
* Many Node.js tools write informational messages to stderr
|
|
652
|
+
* (deprecation warnings, experimental feature notices, etc.).
|
|
653
|
+
* We don't want to flood the agent with these.
|
|
654
|
+
*/
|
|
655
|
+
private isStderrNoise(text: string): boolean {
|
|
656
|
+
const noisePatterns = [
|
|
657
|
+
/^DeprecationWarning:/,
|
|
658
|
+
/^ExperimentalWarning:/,
|
|
659
|
+
/^\(node:\d+\) /,
|
|
660
|
+
/^npm WARN /,
|
|
661
|
+
/^warn /i,
|
|
662
|
+
/^Debugger listening/,
|
|
663
|
+
/^Debugger attached/,
|
|
664
|
+
/^Waiting for the debugger/,
|
|
665
|
+
];
|
|
666
|
+
return noisePatterns.some((p) => p.test(text));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Parse file path and line/column from common error message formats.
|
|
671
|
+
*
|
|
672
|
+
* Handles:
|
|
673
|
+
* - Node.js stack frames: "at func (/app/file.js:12:5)"
|
|
674
|
+
* - Direct references: "/app/file.js:12:5"
|
|
675
|
+
* - SyntaxError: "/app/file.js: Unexpected token (12:5)"
|
|
676
|
+
*/
|
|
677
|
+
private parseErrorLocation(text: string): {
|
|
678
|
+
filePath?: string;
|
|
679
|
+
line?: number;
|
|
680
|
+
column?: number;
|
|
681
|
+
} {
|
|
682
|
+
const workdir = this.config.workdir ?? "/app";
|
|
683
|
+
|
|
684
|
+
// Pattern 1: "at ... (/app/path/file.js:LINE:COL)"
|
|
685
|
+
const stackMatch = text.match(/\(?(\/[^:)]+):(\d+):(\d+)\)?/);
|
|
686
|
+
if (stackMatch) {
|
|
687
|
+
const absPath = stackMatch[1];
|
|
688
|
+
const relativePath = absPath.startsWith(workdir + "/")
|
|
689
|
+
? absPath.slice(workdir.length + 1)
|
|
690
|
+
: absPath;
|
|
691
|
+
return {
|
|
692
|
+
filePath: relativePath,
|
|
693
|
+
line: parseInt(stackMatch[2], 10),
|
|
694
|
+
column: parseInt(stackMatch[3], 10),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Pattern 2: "file.js:LINE" (no column)
|
|
699
|
+
const simpleMatch = text.match(/\(?(\/[^:)]+):(\d+)\)?/);
|
|
700
|
+
if (simpleMatch) {
|
|
701
|
+
const absPath = simpleMatch[1];
|
|
702
|
+
const relativePath = absPath.startsWith(workdir + "/")
|
|
703
|
+
? absPath.slice(workdir.length + 1)
|
|
704
|
+
: absPath;
|
|
705
|
+
return {
|
|
706
|
+
filePath: relativePath,
|
|
707
|
+
line: parseInt(simpleMatch[2], 10),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Pattern 3: "Unexpected token (LINE:COL)" with a file path earlier
|
|
712
|
+
const syntaxMatch = text.match(/(\/[^\s:]+)\s*:\s*[^(]*\((\d+):(\d+)\)/);
|
|
713
|
+
if (syntaxMatch) {
|
|
714
|
+
const absPath = syntaxMatch[1];
|
|
715
|
+
const relativePath = absPath.startsWith(workdir + "/")
|
|
716
|
+
? absPath.slice(workdir.length + 1)
|
|
717
|
+
: absPath;
|
|
718
|
+
return {
|
|
719
|
+
filePath: relativePath,
|
|
720
|
+
line: parseInt(syntaxMatch[2], 10),
|
|
721
|
+
column: parseInt(syntaxMatch[3], 10),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return {};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Get surrounding source code lines from the virtual filesystem.
|
|
730
|
+
*
|
|
731
|
+
* Returns ~10 lines centered on the target line, formatted as
|
|
732
|
+
* "lineNum: content" for each line. This gives the AI agent enough
|
|
733
|
+
* context to construct a surgical fix.
|
|
734
|
+
*/
|
|
735
|
+
private async getSourceContext(
|
|
736
|
+
filePath: string,
|
|
737
|
+
targetLine: number,
|
|
738
|
+
windowSize = 5,
|
|
739
|
+
): Promise<string | undefined> {
|
|
740
|
+
try {
|
|
741
|
+
const content = this.currentFiles[filePath];
|
|
742
|
+
if (!content) {
|
|
743
|
+
// Try reading from the FS directly (file might have been written
|
|
744
|
+
// outside of our tracking, e.g., by npm install)
|
|
745
|
+
if (!this.nodepod) return undefined;
|
|
746
|
+
const fullPath = this.resolvePath(filePath);
|
|
747
|
+
const exists = await this.nodepod.fs.exists(fullPath);
|
|
748
|
+
if (!exists) return undefined;
|
|
749
|
+
const fsContent = await this.nodepod.fs.readFile(fullPath, "utf-8");
|
|
750
|
+
return this.formatSourceContext(fsContent, targetLine, windowSize);
|
|
751
|
+
}
|
|
752
|
+
return this.formatSourceContext(content, targetLine, windowSize);
|
|
753
|
+
} catch {
|
|
754
|
+
return undefined;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Format source code lines centered on a target line.
|
|
760
|
+
* Output: "lineNum: content\n" for each line in the window.
|
|
761
|
+
*/
|
|
762
|
+
private formatSourceContext(
|
|
763
|
+
content: string,
|
|
764
|
+
targetLine: number,
|
|
765
|
+
windowSize: number,
|
|
766
|
+
): string {
|
|
767
|
+
const lines = content.split("\n");
|
|
768
|
+
const start = Math.max(0, targetLine - windowSize - 1);
|
|
769
|
+
const end = Math.min(lines.length, targetLine + windowSize);
|
|
770
|
+
return lines
|
|
771
|
+
.slice(start, end)
|
|
772
|
+
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
773
|
+
.join("\n");
|
|
774
|
+
}
|
|
536
775
|
}
|
package/src/styles.css
CHANGED
|
@@ -22,3 +22,99 @@
|
|
|
22
22
|
--sb-warning: #cca700;
|
|
23
23
|
--sb-error: #f44747;
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* Ranger Theme Variables — bridged from ranger/client/src/style.css.
|
|
28
|
+
*
|
|
29
|
+
* These are injected into the sandbox shell so consumers can apply Ranger's
|
|
30
|
+
* design tokens to both the sandbox chrome and the preview iframe's content.
|
|
31
|
+
* HSL values are stored WITHOUT the hsl() wrapper (e.g. "12 76% 61%")
|
|
32
|
+
* and consumed as: color: hsl(var(--chart-1));
|
|
33
|
+
*
|
|
34
|
+
* Light mode (default) and dark mode (.dark) variants are provided.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/* --- Light mode --------------------------------------------------------- */
|
|
38
|
+
:root {
|
|
39
|
+
/* Ranger text */
|
|
40
|
+
--text-primary: #1a1a1a;
|
|
41
|
+
--text-secondary: #565869;
|
|
42
|
+
--text-tertiary: #8e8ea0;
|
|
43
|
+
|
|
44
|
+
/* Ranger surfaces */
|
|
45
|
+
--surface-primary: #ffffff;
|
|
46
|
+
--surface-secondary: #f9fafb;
|
|
47
|
+
--surface-tertiary: #f3f4f6;
|
|
48
|
+
|
|
49
|
+
/* Ranger borders */
|
|
50
|
+
--border-light: #e5e7eb;
|
|
51
|
+
--border-medium: #d1d5db;
|
|
52
|
+
--border-heavy: #9ca3af;
|
|
53
|
+
|
|
54
|
+
/* shadcn / chart HSL tokens (light) */
|
|
55
|
+
--background: 0 0% 100%;
|
|
56
|
+
--foreground: 0 0% 3.9%;
|
|
57
|
+
--card: 0 0% 100%;
|
|
58
|
+
--card-foreground: 0 0% 3.9%;
|
|
59
|
+
--primary: 0 0% 9%;
|
|
60
|
+
--primary-foreground: 0 0% 98%;
|
|
61
|
+
--secondary: 0 0% 96.1%;
|
|
62
|
+
--secondary-foreground: 0 0% 9%;
|
|
63
|
+
--muted: 0 0% 96.1%;
|
|
64
|
+
--muted-foreground: 0 0% 45.1%;
|
|
65
|
+
--accent: 0 0% 96.1%;
|
|
66
|
+
--accent-foreground: 0 0% 9%;
|
|
67
|
+
--destructive: 0 84.2% 60.2%;
|
|
68
|
+
--destructive-foreground: 0 0% 98%;
|
|
69
|
+
--border: 0 0% 89.8%;
|
|
70
|
+
--input: 0 0% 89.8%;
|
|
71
|
+
--ring: 0 0% 3.9%;
|
|
72
|
+
--radius: 0.5rem;
|
|
73
|
+
--chart-1: 12 76% 61%;
|
|
74
|
+
--chart-2: 173 58% 39%;
|
|
75
|
+
--chart-3: 197 37% 24%;
|
|
76
|
+
--chart-4: 43 74% 66%;
|
|
77
|
+
--chart-5: 27 87% 67%;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* --- Dark mode ---------------------------------------------------------- */
|
|
81
|
+
.dark {
|
|
82
|
+
/* Ranger text */
|
|
83
|
+
--text-primary: #f3f4f6;
|
|
84
|
+
--text-secondary: #d1d5db;
|
|
85
|
+
--text-tertiary: #6b7280;
|
|
86
|
+
|
|
87
|
+
/* Ranger surfaces */
|
|
88
|
+
--surface-primary: #111827;
|
|
89
|
+
--surface-secondary: #1f2937;
|
|
90
|
+
--surface-tertiary: #374151;
|
|
91
|
+
|
|
92
|
+
/* Ranger borders */
|
|
93
|
+
--border-light: #3a3a3b;
|
|
94
|
+
--border-medium: #4b5563;
|
|
95
|
+
--border-heavy: #6b7280;
|
|
96
|
+
|
|
97
|
+
/* shadcn / chart HSL tokens (dark) */
|
|
98
|
+
--background: 0 0% 7%;
|
|
99
|
+
--foreground: 0 0% 98%;
|
|
100
|
+
--card: 0 0% 3.9%;
|
|
101
|
+
--card-foreground: 0 0% 98%;
|
|
102
|
+
--primary: 0 0% 98%;
|
|
103
|
+
--primary-foreground: 0 0% 9%;
|
|
104
|
+
--secondary: 0 0% 14.9%;
|
|
105
|
+
--secondary-foreground: 0 0% 98%;
|
|
106
|
+
--muted: 0 0% 14.9%;
|
|
107
|
+
--muted-foreground: 0 0% 63.9%;
|
|
108
|
+
--accent: 0 0% 14.9%;
|
|
109
|
+
--accent-foreground: 0 0% 98%;
|
|
110
|
+
--destructive: 0 62.8% 40.6%;
|
|
111
|
+
--destructive-foreground: 0 0% 98%;
|
|
112
|
+
--border: 0 0% 14.9%;
|
|
113
|
+
--input: 0 0% 14.9%;
|
|
114
|
+
--ring: 0 0% 83.1%;
|
|
115
|
+
--chart-1: 220 70% 50%;
|
|
116
|
+
--chart-2: 160 60% 45%;
|
|
117
|
+
--chart-3: 30 80% 55%;
|
|
118
|
+
--chart-4: 280 65% 60%;
|
|
119
|
+
--chart-5: 340 75% 55%;
|
|
120
|
+
}
|
|
@@ -1400,6 +1400,7 @@ const INDEX_HTML = `<!DOCTYPE html>
|
|
|
1400
1400
|
<script src="https://unpkg.com/@remix-run/router@1.21.0/dist/router.umd.min.js"><\/script>
|
|
1401
1401
|
<script src="https://unpkg.com/react-router@6.28.0/dist/umd/react-router.production.min.js"><\/script>
|
|
1402
1402
|
<script src="https://unpkg.com/react-router-dom@6.28.0/dist/umd/react-router-dom.production.min.js"><\/script>
|
|
1403
|
+
<script src="https://unpkg.com/chart.js@4.4.0/dist/chart.umd.js"><\/script>
|
|
1403
1404
|
<link rel="stylesheet" href="/styles.css">
|
|
1404
1405
|
</head>
|
|
1405
1406
|
<body class="bg-gray-50 min-h-screen antialiased">
|
|
@@ -1417,6 +1418,7 @@ const INDEX_HTML = `<!DOCTYPE html>
|
|
|
1417
1418
|
<script src="/components/Navbar.js"><\/script>
|
|
1418
1419
|
<script src="/components/ItemCard.js"><\/script>
|
|
1419
1420
|
<script src="/components/ItemForm.js"><\/script>
|
|
1421
|
+
<script src="/components/StatsChart.js"><\/script>
|
|
1420
1422
|
|
|
1421
1423
|
<!-- Pages -->
|
|
1422
1424
|
<script src="/pages/WelcomePage.js"><\/script>
|
|
@@ -1433,7 +1435,84 @@ const INDEX_HTML = `<!DOCTYPE html>
|
|
|
1433
1435
|
// public/styles.css
|
|
1434
1436
|
// ---------------------------------------------------------------------------
|
|
1435
1437
|
|
|
1436
|
-
const STYLES_CSS =
|
|
1438
|
+
const STYLES_CSS = `/*
|
|
1439
|
+
* Ranger Theme Variables — chart colors and design tokens.
|
|
1440
|
+
* HSL values stored WITHOUT hsl() wrapper; consume as: color: hsl(var(--chart-1));
|
|
1441
|
+
*/
|
|
1442
|
+
|
|
1443
|
+
/* --- Light mode (default) ----------------------------------------------- */
|
|
1444
|
+
:root {
|
|
1445
|
+
--text-primary: #1a1a1a;
|
|
1446
|
+
--text-secondary: #565869;
|
|
1447
|
+
--text-tertiary: #8e8ea0;
|
|
1448
|
+
--surface-primary: #ffffff;
|
|
1449
|
+
--surface-secondary: #f9fafb;
|
|
1450
|
+
--surface-tertiary: #f3f4f6;
|
|
1451
|
+
--border-light: #e5e7eb;
|
|
1452
|
+
--border-medium: #d1d5db;
|
|
1453
|
+
--border-heavy: #9ca3af;
|
|
1454
|
+
--background: 0 0% 100%;
|
|
1455
|
+
--foreground: 0 0% 3.9%;
|
|
1456
|
+
--card: 0 0% 100%;
|
|
1457
|
+
--card-foreground: 0 0% 3.9%;
|
|
1458
|
+
--primary: 0 0% 9%;
|
|
1459
|
+
--primary-foreground: 0 0% 98%;
|
|
1460
|
+
--secondary: 0 0% 96.1%;
|
|
1461
|
+
--secondary-foreground: 0 0% 9%;
|
|
1462
|
+
--muted: 0 0% 96.1%;
|
|
1463
|
+
--muted-foreground: 0 0% 45.1%;
|
|
1464
|
+
--accent: 0 0% 96.1%;
|
|
1465
|
+
--accent-foreground: 0 0% 9%;
|
|
1466
|
+
--destructive: 0 84.2% 60.2%;
|
|
1467
|
+
--destructive-foreground: 0 0% 98%;
|
|
1468
|
+
--border: 0 0% 89.8%;
|
|
1469
|
+
--input: 0 0% 89.8%;
|
|
1470
|
+
--ring: 0 0% 3.9%;
|
|
1471
|
+
--radius: 0.5rem;
|
|
1472
|
+
--chart-1: 12 76% 61%;
|
|
1473
|
+
--chart-2: 173 58% 39%;
|
|
1474
|
+
--chart-3: 197 37% 24%;
|
|
1475
|
+
--chart-4: 43 74% 66%;
|
|
1476
|
+
--chart-5: 27 87% 67%;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/* --- Dark mode ---------------------------------------------------------- */
|
|
1480
|
+
.dark {
|
|
1481
|
+
--text-primary: #f3f4f6;
|
|
1482
|
+
--text-secondary: #d1d5db;
|
|
1483
|
+
--text-tertiary: #6b7280;
|
|
1484
|
+
--surface-primary: #111827;
|
|
1485
|
+
--surface-secondary: #1f2937;
|
|
1486
|
+
--surface-tertiary: #374151;
|
|
1487
|
+
--border-light: #3a3a3b;
|
|
1488
|
+
--border-medium: #4b5563;
|
|
1489
|
+
--border-heavy: #6b7280;
|
|
1490
|
+
--background: 0 0% 7%;
|
|
1491
|
+
--foreground: 0 0% 98%;
|
|
1492
|
+
--card: 0 0% 3.9%;
|
|
1493
|
+
--card-foreground: 0 0% 98%;
|
|
1494
|
+
--primary: 0 0% 98%;
|
|
1495
|
+
--primary-foreground: 0 0% 9%;
|
|
1496
|
+
--secondary: 0 0% 14.9%;
|
|
1497
|
+
--secondary-foreground: 0 0% 98%;
|
|
1498
|
+
--muted: 0 0% 14.9%;
|
|
1499
|
+
--muted-foreground: 0 0% 63.9%;
|
|
1500
|
+
--accent: 0 0% 14.9%;
|
|
1501
|
+
--accent-foreground: 0 0% 98%;
|
|
1502
|
+
--destructive: 0 62.8% 40.6%;
|
|
1503
|
+
--destructive-foreground: 0 0% 98%;
|
|
1504
|
+
--border: 0 0% 14.9%;
|
|
1505
|
+
--input: 0 0% 14.9%;
|
|
1506
|
+
--ring: 0 0% 83.1%;
|
|
1507
|
+
--chart-1: 220 70% 50%;
|
|
1508
|
+
--chart-2: 160 60% 45%;
|
|
1509
|
+
--chart-3: 30 80% 55%;
|
|
1510
|
+
--chart-4: 280 65% 60%;
|
|
1511
|
+
--chart-5: 340 75% 55%;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/* --- Animations --------------------------------------------------------- */
|
|
1515
|
+
.fade-in {
|
|
1437
1516
|
animation: fadeIn 0.3s ease-out;
|
|
1438
1517
|
}
|
|
1439
1518
|
@keyframes fadeIn {
|
|
@@ -2431,6 +2510,131 @@ window.RegisterPage = function RegisterPage() {
|
|
|
2431
2510
|
};
|
|
2432
2511
|
`;
|
|
2433
2512
|
|
|
2513
|
+
// ---------------------------------------------------------------------------
|
|
2514
|
+
// public/components/StatsChart.js
|
|
2515
|
+
// ---------------------------------------------------------------------------
|
|
2516
|
+
|
|
2517
|
+
const STATS_CHART_JS = `var h = React.createElement;
|
|
2518
|
+
|
|
2519
|
+
/**
|
|
2520
|
+
* StatsChart — Renders a doughnut chart of item status distribution
|
|
2521
|
+
* using Chart.js with Ranger theme CSS variables (--chart-1 through --chart-5).
|
|
2522
|
+
*
|
|
2523
|
+
* Props:
|
|
2524
|
+
* stats — { total, byStatus: { todo, in_progress, done }, byPriority: { low, medium, high } }
|
|
2525
|
+
*
|
|
2526
|
+
* Uses the global Chart class from chart.js UMD CDN.
|
|
2527
|
+
*/
|
|
2528
|
+
window.StatsChart = function StatsChart(props) {
|
|
2529
|
+
var stats = props.stats || { total: 0, byStatus: {}, byPriority: {} };
|
|
2530
|
+
var canvasRef = React.useRef(null);
|
|
2531
|
+
var chartRef = React.useRef(null);
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* Read a CSS custom property value from :root and convert to usable color.
|
|
2535
|
+
* Ranger stores HSL values without the hsl() wrapper (e.g. "12 76% 61%"),
|
|
2536
|
+
* so we wrap them: hsl(12 76% 61%).
|
|
2537
|
+
*/
|
|
2538
|
+
function getChartColor(varName, fallback) {
|
|
2539
|
+
var raw = getComputedStyle(document.documentElement)
|
|
2540
|
+
.getPropertyValue(varName)
|
|
2541
|
+
.trim();
|
|
2542
|
+
if (!raw) return fallback;
|
|
2543
|
+
if (raw.startsWith('#') || raw.startsWith('rgb') || raw.startsWith('hsl(')) {
|
|
2544
|
+
return raw;
|
|
2545
|
+
}
|
|
2546
|
+
return 'hsl(' + raw + ')';
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
React.useEffect(function () {
|
|
2550
|
+
if (!canvasRef.current) return;
|
|
2551
|
+
|
|
2552
|
+
if (chartRef.current) {
|
|
2553
|
+
chartRef.current.destroy();
|
|
2554
|
+
chartRef.current = null;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
var todo = (stats.byStatus && stats.byStatus.todo) || 0;
|
|
2558
|
+
var inProgress = (stats.byStatus && stats.byStatus.in_progress) || 0;
|
|
2559
|
+
var done = (stats.byStatus && stats.byStatus.done) || 0;
|
|
2560
|
+
|
|
2561
|
+
if (todo + inProgress + done === 0) return;
|
|
2562
|
+
|
|
2563
|
+
var colors = [
|
|
2564
|
+
getChartColor('--chart-1', 'hsl(12 76% 61%)'),
|
|
2565
|
+
getChartColor('--chart-2', 'hsl(173 58% 39%)'),
|
|
2566
|
+
getChartColor('--chart-3', 'hsl(197 37% 24%)'),
|
|
2567
|
+
];
|
|
2568
|
+
|
|
2569
|
+
chartRef.current = new Chart(canvasRef.current, {
|
|
2570
|
+
type: 'doughnut',
|
|
2571
|
+
data: {
|
|
2572
|
+
labels: ['To Do', 'In Progress', 'Done'],
|
|
2573
|
+
datasets: [
|
|
2574
|
+
{
|
|
2575
|
+
data: [todo, inProgress, done],
|
|
2576
|
+
backgroundColor: colors,
|
|
2577
|
+
borderWidth: 0,
|
|
2578
|
+
hoverOffset: 6,
|
|
2579
|
+
},
|
|
2580
|
+
],
|
|
2581
|
+
},
|
|
2582
|
+
options: {
|
|
2583
|
+
responsive: true,
|
|
2584
|
+
maintainAspectRatio: false,
|
|
2585
|
+
cutout: '65%',
|
|
2586
|
+
plugins: {
|
|
2587
|
+
legend: {
|
|
2588
|
+
position: 'bottom',
|
|
2589
|
+
labels: {
|
|
2590
|
+
padding: 16,
|
|
2591
|
+
usePointStyle: true,
|
|
2592
|
+
pointStyleWidth: 8,
|
|
2593
|
+
font: { size: 12 },
|
|
2594
|
+
color: getChartColor('--text-secondary', '#565869'),
|
|
2595
|
+
},
|
|
2596
|
+
},
|
|
2597
|
+
tooltip: {
|
|
2598
|
+
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
2599
|
+
titleFont: { size: 13 },
|
|
2600
|
+
bodyFont: { size: 12 },
|
|
2601
|
+
padding: 10,
|
|
2602
|
+
cornerRadius: 8,
|
|
2603
|
+
},
|
|
2604
|
+
},
|
|
2605
|
+
},
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
return function () {
|
|
2609
|
+
if (chartRef.current) {
|
|
2610
|
+
chartRef.current.destroy();
|
|
2611
|
+
chartRef.current = null;
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
}, [stats.byStatus && stats.byStatus.todo,
|
|
2615
|
+
stats.byStatus && stats.byStatus.in_progress,
|
|
2616
|
+
stats.byStatus && stats.byStatus.done]);
|
|
2617
|
+
|
|
2618
|
+
var hasData =
|
|
2619
|
+
((stats.byStatus && stats.byStatus.todo) || 0) +
|
|
2620
|
+
((stats.byStatus && stats.byStatus.in_progress) || 0) +
|
|
2621
|
+
((stats.byStatus && stats.byStatus.done) || 0) > 0;
|
|
2622
|
+
|
|
2623
|
+
return h(
|
|
2624
|
+
'div',
|
|
2625
|
+
{ className: 'bg-white rounded-lg border border-gray-200 p-4 card-hover' },
|
|
2626
|
+
h('h3', { className: 'text-sm font-semibold text-gray-700 mb-3' }, 'Status Distribution'),
|
|
2627
|
+
hasData
|
|
2628
|
+
? h('div', { style: { height: '200px', position: 'relative' } },
|
|
2629
|
+
h('canvas', { ref: canvasRef })
|
|
2630
|
+
)
|
|
2631
|
+
: h('div', { className: 'flex items-center justify-center h-48 text-gray-400 text-sm' },
|
|
2632
|
+
'No data to display'
|
|
2633
|
+
)
|
|
2634
|
+
);
|
|
2635
|
+
};
|
|
2636
|
+
`;
|
|
2637
|
+
|
|
2434
2638
|
// ---------------------------------------------------------------------------
|
|
2435
2639
|
// public/pages/DashboardPage.js
|
|
2436
2640
|
// ---------------------------------------------------------------------------
|
|
@@ -2527,6 +2731,11 @@ window.DashboardPage = function DashboardPage() {
|
|
|
2527
2731
|
_statCard('Done', stats.byStatus.done || 0, 'bg-emerald-50')
|
|
2528
2732
|
),
|
|
2529
2733
|
|
|
2734
|
+
// Chart — doughnut showing status distribution
|
|
2735
|
+
h('div', { className: 'mb-6' },
|
|
2736
|
+
h(window.StatsChart, { stats: stats })
|
|
2737
|
+
),
|
|
2738
|
+
|
|
2530
2739
|
// Filters
|
|
2531
2740
|
h('div', { className: 'flex gap-2 mb-4' },
|
|
2532
2741
|
_filterSelect('Status', app.filters.status, ['todo', 'in_progress', 'done'],
|
|
@@ -3288,6 +3497,7 @@ export const fullstackStarterTemplate = {
|
|
|
3288
3497
|
"public/components/Navbar.js": NAVBAR_JS,
|
|
3289
3498
|
"public/components/ItemCard.js": ITEM_CARD_JS,
|
|
3290
3499
|
"public/components/ItemForm.js": ITEM_FORM_JS,
|
|
3500
|
+
"public/components/StatsChart.js": STATS_CHART_JS,
|
|
3291
3501
|
"public/pages/WelcomePage.js": WELCOME_PAGE_JS,
|
|
3292
3502
|
"public/pages/LoginPage.js": LOGIN_PAGE_JS,
|
|
3293
3503
|
"public/pages/RegisterPage.js": REGISTER_PAGE_JS,
|