From 366643d6efa17991528d970e65201137609c9915 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 23 Feb 2026 13:47:03 -0500 Subject: [PATCH] Add attractor-web React frontend for pipeline dashboard Bun-based React app with pipeline dashboard UI including event log, graph view, context/checkpoint panels, question panel, and status bar. Co-Authored-By: Claude Opus 4.6 --- attractor-web/.gitignore | 34 +++++++ attractor-web/CLAUDE.md | 111 +++++++++++++++++++++ attractor-web/README.md | 21 ++++ attractor-web/bun-env.d.ts | 17 ++++ attractor-web/bun.lock | 39 ++++++++ attractor-web/bunfig.toml | 2 + attractor-web/package.json | 20 ++++ attractor-web/src/App.tsx | 27 +++++ attractor-web/src/CheckpointView.tsx | 39 ++++++++ attractor-web/src/ContextView.tsx | 46 +++++++++ attractor-web/src/EventLog.tsx | 85 ++++++++++++++++ attractor-web/src/GraphView.tsx | 38 +++++++ attractor-web/src/PipelineDashboard.tsx | 38 +++++++ attractor-web/src/QuestionPanel.tsx | 125 ++++++++++++++++++++++++ attractor-web/src/StatusBar.tsx | 30 ++++++ attractor-web/src/frontend.tsx | 17 ++++ attractor-web/src/hooks.ts | 74 ++++++++++++++ attractor-web/src/index.html | 12 +++ attractor-web/src/index.ts | 34 +++++++ attractor-web/tsconfig.json | 36 +++++++ 20 files changed, 845 insertions(+) create mode 100644 attractor-web/.gitignore create mode 100644 attractor-web/CLAUDE.md create mode 100644 attractor-web/README.md create mode 100644 attractor-web/bun-env.d.ts create mode 100644 attractor-web/bun.lock create mode 100644 attractor-web/bunfig.toml create mode 100644 attractor-web/package.json create mode 100644 attractor-web/src/App.tsx create mode 100644 attractor-web/src/CheckpointView.tsx create mode 100644 attractor-web/src/ContextView.tsx create mode 100644 attractor-web/src/EventLog.tsx create mode 100644 attractor-web/src/GraphView.tsx create mode 100644 attractor-web/src/PipelineDashboard.tsx create mode 100644 attractor-web/src/QuestionPanel.tsx create mode 100644 attractor-web/src/StatusBar.tsx create mode 100644 attractor-web/src/frontend.tsx create mode 100644 attractor-web/src/hooks.ts create mode 100644 attractor-web/src/index.html create mode 100644 attractor-web/src/index.ts create mode 100644 attractor-web/tsconfig.json diff --git a/attractor-web/.gitignore b/attractor-web/.gitignore new file mode 100644 index 000000000..a14702c40 --- /dev/null +++ b/attractor-web/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/attractor-web/CLAUDE.md b/attractor-web/CLAUDE.md new file mode 100644 index 000000000..ebda99514 --- /dev/null +++ b/attractor-web/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/attractor-web/README.md b/attractor-web/README.md new file mode 100644 index 000000000..209c03861 --- /dev/null +++ b/attractor-web/README.md @@ -0,0 +1,21 @@ +# bun-react-template + +To install dependencies: + +```bash +bun install +``` + +To start a development server: + +```bash +bun dev +``` + +To run for production: + +```bash +bun start +``` + +This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/attractor-web/bun-env.d.ts b/attractor-web/bun-env.d.ts new file mode 100644 index 000000000..72f1c26e6 --- /dev/null +++ b/attractor-web/bun-env.d.ts @@ -0,0 +1,17 @@ +// Generated by `bun init` + +declare module "*.svg" { + /** + * A path to the SVG file + */ + const path: `${string}.svg`; + export = path; +} + +declare module "*.module.css" { + /** + * A record of class names to their corresponding CSS module classes + */ + const classes: { readonly [key: string]: string }; + export = classes; +} diff --git a/attractor-web/bun.lock b/attractor-web/bun.lock new file mode 100644 index 000000000..94fd2cfc5 --- /dev/null +++ b/attractor-web/bun.lock @@ -0,0 +1,39 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-react-template", + "dependencies": { + "react": "^19", + "react-dom": "^19", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/attractor-web/bunfig.toml b/attractor-web/bunfig.toml new file mode 100644 index 000000000..9819bf6de --- /dev/null +++ b/attractor-web/bunfig.toml @@ -0,0 +1,2 @@ +[serve.static] +env = "BUN_PUBLIC_*" \ No newline at end of file diff --git a/attractor-web/package.json b/attractor-web/package.json new file mode 100644 index 000000000..4e3b6d0de --- /dev/null +++ b/attractor-web/package.json @@ -0,0 +1,20 @@ +{ + "name": "bun-react-template", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/index.ts", + "build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", + "start": "NODE_ENV=production bun src/index.ts" + }, + "dependencies": { + "react": "^19", + "react-dom": "^19" + }, + "devDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/bun": "latest" + } +} diff --git a/attractor-web/src/App.tsx b/attractor-web/src/App.tsx new file mode 100644 index 000000000..87b8f3850 --- /dev/null +++ b/attractor-web/src/App.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; +import "./index.css"; +import { StartForm } from "./StartForm"; +import { PipelineDashboard } from "./PipelineDashboard"; + +export function App() { + const [pipelineId, setPipelineId] = useState(null); + + return ( +
+
+

Attractor

+ Pipeline Dashboard +
+ {pipelineId ? ( + setPipelineId(null)} + /> + ) : ( + + )} +
+ ); +} + +export default App; diff --git a/attractor-web/src/CheckpointView.tsx b/attractor-web/src/CheckpointView.tsx new file mode 100644 index 000000000..3c11ae77c --- /dev/null +++ b/attractor-web/src/CheckpointView.tsx @@ -0,0 +1,39 @@ +import { useCallback } from "react"; +import { getCheckpoint, type Checkpoint } from "./api"; +import { usePolling } from "./hooks"; + +interface CheckpointViewProps { + pipelineId: string; + active: boolean; +} + +export function CheckpointView({ pipelineId, active }: CheckpointViewProps) { + const fetcher = useCallback(() => getCheckpoint(pipelineId), [pipelineId]); + const { data } = usePolling(fetcher, 2000, active); + + if (!data) { + return ( +
+

Checkpoint

+

No checkpoint yet

+
+ ); + } + + return ( +
+

Checkpoint

+
+ {data.current_node} + {data.completed_nodes.map((node) => ( + + {node} + + ))} +
+ {data.logs.length > 0 && ( +
{data.logs.join("\n")}
+ )} +
+ ); +} diff --git a/attractor-web/src/ContextView.tsx b/attractor-web/src/ContextView.tsx new file mode 100644 index 000000000..6fab80c81 --- /dev/null +++ b/attractor-web/src/ContextView.tsx @@ -0,0 +1,46 @@ +import { useCallback } from "react"; +import { getContext, type ContextSnapshot } from "./api"; +import { usePolling } from "./hooks"; + +interface ContextViewProps { + pipelineId: string; + active: boolean; +} + +function formatValue(value: unknown): string { + if (typeof value === "string") return value; + return JSON.stringify(value, null, 2); +} + +export function ContextView({ pipelineId, active }: ContextViewProps) { + const fetcher = useCallback(() => getContext(pipelineId), [pipelineId]); + const { data } = usePolling(fetcher, 2000, active); + + const entries = data ? Object.entries(data) : []; + + return ( +
+

Context

+ {entries.length === 0 ? ( +

No context values yet

+ ) : ( + + + + + + + + + {entries.map(([key, value]) => ( + + + + + ))} + +
KeyValue
{key}{formatValue(value)}
+ )} +
+ ); +} diff --git a/attractor-web/src/EventLog.tsx b/attractor-web/src/EventLog.tsx new file mode 100644 index 000000000..2b77424e3 --- /dev/null +++ b/attractor-web/src/EventLog.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef } from "react"; +import { usePipelineEvents } from "./hooks"; +import type { PipelineEvent } from "./api"; + +interface EventLogProps { + pipelineId: string; + active: boolean; +} + +function eventKey(event: PipelineEvent): string { + return Object.keys(event)[0] ?? "Unknown"; +} + +function eventCssClass(key: string): string { + // Convert PascalCase to kebab-case + return key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +function eventDetail(event: PipelineEvent): string { + const key = eventKey(event); + const data = (event as Record>)[key]; + if (!data) return ""; + + switch (key) { + case "PipelineStarted": + return `${data.name}`; + case "PipelineCompleted": + return `${data.duration_ms}ms, ${data.artifact_count} artifacts`; + case "PipelineFailed": + return `${data.error} (${data.duration_ms}ms)`; + case "StageStarted": + return `${data.name} [${data.index}]`; + case "StageCompleted": + return `${data.name} [${data.index}] ${data.duration_ms}ms`; + case "StageFailed": + return `${data.name} [${data.index}]: ${data.error}${data.will_retry ? " (will retry)" : ""}`; + case "StageRetrying": + return `${data.name} [${data.index}] attempt ${data.attempt}, delay ${data.delay_ms}ms`; + case "ParallelStarted": + return `${data.branch_count} branches`; + case "ParallelBranchStarted": + return `${data.branch} [${data.index}]`; + case "ParallelBranchCompleted": + return `${data.branch} [${data.index}] ${data.duration_ms}ms ${data.success ? "ok" : "fail"}`; + case "ParallelCompleted": + return `${data.duration_ms}ms, ${data.success_count} ok, ${data.failure_count} fail`; + case "InterviewStarted": + return `"${data.question}" (${data.stage})`; + case "InterviewCompleted": + return `"${data.question}" -> "${data.answer}" (${data.duration_ms}ms)`; + case "InterviewTimeout": + return `"${data.question}" timed out (${data.duration_ms}ms)`; + case "CheckpointSaved": + return `node: ${data.node_id}`; + default: + return JSON.stringify(data); + } +} + +export function EventLog({ pipelineId, active }: EventLogProps) { + const { events } = usePipelineEvents(pipelineId, active); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [events.length]); + + return ( +
+

Events

+
+ {events.map((event, i) => { + const key = eventKey(event); + return ( +
+ {key} + {eventDetail(event)} +
+ ); + })} +
+
+
+ ); +} diff --git a/attractor-web/src/GraphView.tsx b/attractor-web/src/GraphView.tsx new file mode 100644 index 000000000..e796bff16 --- /dev/null +++ b/attractor-web/src/GraphView.tsx @@ -0,0 +1,38 @@ +import { useState, useEffect } from "react"; +import { getGraph } from "./api"; + +interface GraphViewProps { + pipelineId: string; +} + +export function GraphView({ pipelineId }: GraphViewProps) { + const [svg, setSvg] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + getGraph(pipelineId) + .then((data) => { + if (!cancelled) setSvg(data); + }) + .catch((err) => { + if (!cancelled) setError(String(err)); + }); + + return () => { + cancelled = true; + }; + }, [pipelineId]); + + return ( +
+

Graph

+
+ {error &&

{error}

} + {svg &&
} + {!svg && !error &&

Loading graph...

} +
+
+ ); +} diff --git a/attractor-web/src/PipelineDashboard.tsx b/attractor-web/src/PipelineDashboard.tsx new file mode 100644 index 000000000..de70aa893 --- /dev/null +++ b/attractor-web/src/PipelineDashboard.tsx @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { getPipelineStatus, type PipelineStatusResponse } from "./api"; +import { usePolling } from "./hooks"; +import { StatusBar } from "./StatusBar"; +import { EventLog } from "./EventLog"; +import { GraphView } from "./GraphView"; +import { ContextView } from "./ContextView"; +import { CheckpointView } from "./CheckpointView"; +import { QuestionPanel } from "./QuestionPanel"; + +interface PipelineDashboardProps { + pipelineId: string; + onBack: () => void; +} + +export function PipelineDashboard({ pipelineId, onBack }: PipelineDashboardProps) { + const fetcher = useCallback(() => getPipelineStatus(pipelineId), [pipelineId]); + const { data: status } = usePolling(fetcher, 1000, true); + + const pipelineStatus = status?.status ?? "running"; + const active = pipelineStatus === "running"; + + return ( +
+ + + +
+ + + + +
+
+ ); +} diff --git a/attractor-web/src/QuestionPanel.tsx b/attractor-web/src/QuestionPanel.tsx new file mode 100644 index 000000000..d4c599f8d --- /dev/null +++ b/attractor-web/src/QuestionPanel.tsx @@ -0,0 +1,125 @@ +import { useState, useCallback } from "react"; +import { getQuestions, submitAnswer, type ApiQuestion } from "./api"; +import { usePolling } from "./hooks"; + +interface QuestionPanelProps { + pipelineId: string; + active: boolean; +} + +function QuestionCard({ + pipelineId, + question, +}: { + pipelineId: string; + question: ApiQuestion; +}) { + const [value, setValue] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function submit(answer: string) { + setSubmitting(true); + try { + await submitAnswer(pipelineId, question.id, answer); + setValue(""); + } catch { + // question will remain visible for retry + } finally { + setSubmitting(false); + } + } + + if (question.question_type === "YesNo") { + return ( +
+
{question.question_type}
+
{question.text}
+
+ + +
+
+ ); + } + + if (question.question_type === "Confirmation") { + return ( +
+
{question.question_type}
+
{question.text}
+
+ + +
+
+ ); + } + + // Freeform and MultipleChoice (fallback to text input) + return ( +
+
{question.question_type}
+
{question.text}
+
+ setValue(e.target.value)} + placeholder="Type your answer..." + onKeyDown={(e) => { + if (e.key === "Enter" && value.trim()) submit(value); + }} + /> + +
+
+ ); +} + +export function QuestionPanel({ pipelineId, active }: QuestionPanelProps) { + const fetcher = useCallback(() => getQuestions(pipelineId), [pipelineId]); + const { data: questions } = usePolling(fetcher, 1000, active); + + if (!questions || questions.length === 0) { + return null; + } + + return ( +
+

Questions

+ {questions.map((q) => ( + + ))} +
+ ); +} diff --git a/attractor-web/src/StatusBar.tsx b/attractor-web/src/StatusBar.tsx new file mode 100644 index 000000000..f99b7c816 --- /dev/null +++ b/attractor-web/src/StatusBar.tsx @@ -0,0 +1,30 @@ +import { cancelPipeline, type PipelineStatus } from "./api"; + +interface StatusBarProps { + id: string; + status: PipelineStatus; + error?: string; +} + +export function StatusBar({ id, status, error }: StatusBarProps) { + async function handleCancel() { + try { + await cancelPipeline(id); + } catch { + // status will update via polling + } + } + + return ( +
+ {status} + {id} + {error && {error}} + {status === "running" && ( + + )} +
+ ); +} diff --git a/attractor-web/src/frontend.tsx b/attractor-web/src/frontend.tsx new file mode 100644 index 000000000..a2b6c1333 --- /dev/null +++ b/attractor-web/src/frontend.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const elem = document.getElementById("root")!; +const app = ( + + + +); + +if (import.meta.hot) { + const root = (import.meta.hot.data.root ??= createRoot(elem)); + root.render(app); +} else { + createRoot(elem).render(app); +} diff --git a/attractor-web/src/hooks.ts b/attractor-web/src/hooks.ts new file mode 100644 index 000000000..d230fbd20 --- /dev/null +++ b/attractor-web/src/hooks.ts @@ -0,0 +1,74 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { eventsUrl, type PipelineEvent } from "./api"; + +export function usePipelineEvents(id: string, active: boolean) { + const [events, setEvents] = useState([]); + const sourceRef = useRef(null); + + useEffect(() => { + if (!active) { + sourceRef.current?.close(); + sourceRef.current = null; + return; + } + + const es = new EventSource(eventsUrl(id)); + sourceRef.current = es; + + es.onmessage = (msg) => { + const event = JSON.parse(msg.data) as PipelineEvent; + setEvents((prev) => [...prev, event]); + }; + + es.onerror = () => { + es.close(); + sourceRef.current = null; + }; + + return () => { + es.close(); + sourceRef.current = null; + }; + }, [id, active]); + + const clear = useCallback(() => setEvents([]), []); + + return { events, clear }; +} + +export function usePolling( + fetcher: () => Promise, + intervalMs: number, + active: boolean, +) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!active) return; + + let cancelled = false; + + const poll = async () => { + try { + const result = await fetcher(); + if (!cancelled) { + setData(result); + setError(null); + } + } catch (e) { + if (!cancelled) setError(String(e)); + } + }; + + poll(); + const timer = setInterval(poll, intervalMs); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [fetcher, intervalMs, active]); + + return { data, error }; +} diff --git a/attractor-web/src/index.html b/attractor-web/src/index.html new file mode 100644 index 000000000..f4a9f8e1e --- /dev/null +++ b/attractor-web/src/index.html @@ -0,0 +1,12 @@ + + + + + + Attractor + + +
+ + + diff --git a/attractor-web/src/index.ts b/attractor-web/src/index.ts new file mode 100644 index 000000000..b13f0c505 --- /dev/null +++ b/attractor-web/src/index.ts @@ -0,0 +1,34 @@ +import { serve } from "bun"; +import index from "./index.html"; + +const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3000"; + +function proxyToBackend(req: Request): Promise { + const url = new URL(req.url); + const backendPath = url.pathname.replace(/^\/api/, ""); + const target = `${BACKEND_URL}${backendPath}${url.search}`; + + return fetch(target, { + method: req.method, + headers: req.headers, + body: req.body, + // @ts-expect-error Bun supports duplex on fetch + duplex: "half", + }); +} + +const server = serve({ + port: 5173, + + routes: { + "/api/*": proxyToBackend, + "/*": index, + }, + + development: process.env.NODE_ENV !== "production" && { + hmr: true, + console: true, + }, +}); + +console.log(`Server running at ${server.url}`); diff --git a/attractor-web/tsconfig.json b/attractor-web/tsconfig.json new file mode 100644 index 000000000..632a36f8d --- /dev/null +++ b/attractor-web/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + + "exclude": ["dist", "node_modules"] +}