newpr 0.1.0 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
package/src/cli/args.ts CHANGED
@@ -10,6 +10,7 @@ export interface CliArgs {
10
10
  noClone: boolean;
11
11
  agent?: AgentToolName;
12
12
  port?: number;
13
+ cartoon?: boolean;
13
14
  subArgs: string[];
14
15
  }
15
16
 
@@ -101,8 +102,12 @@ export function parseArgs(argv: string[]): CliArgs {
101
102
  const portIdx = args.indexOf("--port");
102
103
  if (portIdx !== -1 && args[portIdx + 1]) {
103
104
  port = Number.parseInt(args[portIdx + 1]!, 10) || 3000;
105
+ } else {
106
+ const eqArg = args.find((a) => a.startsWith("--port="));
107
+ if (eqArg) port = Number.parseInt(eqArg.split("=")[1]!, 10) || 3000;
104
108
  }
105
- return { command: "web", port, ...DEFAULTS };
109
+ const cartoon = args.includes("--cartoon");
110
+ return { command: "web", port, cartoon, ...DEFAULTS };
106
111
  }
107
112
 
108
113
  if (args.length === 0) {
package/src/cli/index.ts CHANGED
@@ -9,12 +9,17 @@ import { parsePrInput } from "../github/parse-pr.ts";
9
9
  import { analyzePr } from "../analyzer/pipeline.ts";
10
10
  import { createStderrProgress, createSilentProgress, createStreamJsonProgress } from "../analyzer/progress.ts";
11
11
  import { renderLoading, renderShell } from "../tui/render.tsx";
12
+ import { checkForUpdate, printUpdateNotice } from "./update-check.ts";
12
13
 
13
- const VERSION = "0.1.0";
14
+ const VERSION = "0.1.3";
14
15
 
15
16
  async function main(): Promise<void> {
16
17
  const args = parseArgs(process.argv);
17
18
 
19
+ const updatePromise = (args.command === "shell" || args.command === "web")
20
+ ? checkForUpdate(VERSION).catch(() => null)
21
+ : null;
22
+
18
23
  if (args.command === "help") return;
19
24
 
20
25
  if (args.command === "version") {
@@ -48,8 +53,10 @@ async function main(): Promise<void> {
48
53
  try {
49
54
  const config = await loadConfig({ model: args.model });
50
55
  const token = await getGithubToken();
56
+ const updateInfo = await updatePromise;
57
+ if (updateInfo) printUpdateNotice(updateInfo);
51
58
  const { startWebServer } = await import("../web/server.ts");
52
- await startWebServer({ port: args.port ?? 3000, token, config });
59
+ await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon });
53
60
  } catch (error) {
54
61
  const message = error instanceof Error ? error.message : String(error);
55
62
  process.stderr.write(`Error: ${message}\n`);
@@ -62,6 +69,8 @@ async function main(): Promise<void> {
62
69
  try {
63
70
  const config = await loadConfig({ model: args.model });
64
71
  const token = await getGithubToken();
72
+ const updateInfo = await updatePromise;
73
+ if (updateInfo) printUpdateNotice(updateInfo);
65
74
  renderShell(token, config, args.prInput);
66
75
  } catch (error) {
67
76
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,76 @@
1
+ const PACKAGE_NAME = "newpr";
2
+ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
3
+
4
+ interface UpdateInfo {
5
+ current: string;
6
+ latest: string;
7
+ needsUpdate: boolean;
8
+ }
9
+
10
+ async function getLastCheckTime(): Promise<number> {
11
+ try {
12
+ const file = Bun.file(`${process.env.HOME}/.newpr/last-update-check`);
13
+ const text = await file.text();
14
+ return Number.parseInt(text.trim(), 10) || 0;
15
+ } catch {
16
+ return 0;
17
+ }
18
+ }
19
+
20
+ async function setLastCheckTime(): Promise<void> {
21
+ const dir = `${process.env.HOME}/.newpr`;
22
+ const { mkdirSync } = await import("node:fs");
23
+ try { mkdirSync(dir, { recursive: true }); } catch {}
24
+ await Bun.write(`${dir}/last-update-check`, String(Date.now()));
25
+ }
26
+
27
+ async function fetchLatestVersion(): Promise<string | null> {
28
+ try {
29
+ const controller = new AbortController();
30
+ const timeout = setTimeout(() => controller.abort(), 3000);
31
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
32
+ signal: controller.signal,
33
+ headers: { Accept: "application/json" },
34
+ });
35
+ clearTimeout(timeout);
36
+ if (!res.ok) return null;
37
+ const data = await res.json() as { version?: string };
38
+ return data.version ?? null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function compareVersions(current: string, latest: string): boolean {
45
+ const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
46
+ const c = parse(current);
47
+ const l = parse(latest);
48
+ for (let i = 0; i < 3; i++) {
49
+ if ((l[i] ?? 0) > (c[i] ?? 0)) return true;
50
+ if ((l[i] ?? 0) < (c[i] ?? 0)) return false;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export async function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
56
+ const lastCheck = await getLastCheckTime();
57
+ if (Date.now() - lastCheck < CHECK_INTERVAL_MS) return null;
58
+
59
+ const latest = await fetchLatestVersion();
60
+ await setLastCheckTime();
61
+
62
+ if (!latest) return null;
63
+ if (!compareVersions(currentVersion, latest)) return null;
64
+
65
+ return { current: currentVersion, latest, needsUpdate: true };
66
+ }
67
+
68
+ export function printUpdateNotice(info: UpdateInfo): void {
69
+ const msg = [
70
+ "",
71
+ ` Update available: ${info.current} → \x1b[32m${info.latest}\x1b[0m`,
72
+ ` Run \x1b[36mbun add -g ${PACKAGE_NAME}\x1b[0m to update`,
73
+ "",
74
+ ].join("\n");
75
+ process.stderr.write(msg);
76
+ }
@@ -0,0 +1,128 @@
1
+ import type { NewprOutput } from "../types/output.ts";
2
+
3
+ const CARTOON_MODEL = "google/gemini-3-pro-image-preview";
4
+
5
+ function buildCartoonPrompt(data: NewprOutput, language: string): string {
6
+ const { meta, summary, groups, narrative } = data;
7
+ const lang = language === "auto" ? "English" : language;
8
+ const groupList = groups.slice(0, 5).map((g) => `- ${g.name}: ${g.description.slice(0, 80)}`).join("\n");
9
+ const storyExcerpt = narrative
10
+ .replace(/\[\[(group|file):[^\]]+\]\]/g, "")
11
+ .split("\n")
12
+ .filter((l) => l.trim() && !l.startsWith("#"))
13
+ .slice(0, 6)
14
+ .join(" ")
15
+ .slice(0, 500);
16
+
17
+ return `Generate an image of a funny 4-panel comic strip about this Pull Request.
18
+
19
+ ## PR Context
20
+ Title: "${meta.pr_title}"
21
+ Author: ${meta.author}
22
+ Purpose: ${summary.purpose}
23
+ Scope: ${summary.scope}
24
+ Impact: ${summary.impact}
25
+ Risk: ${summary.risk_level}
26
+ Changes: +${meta.total_additions} -${meta.total_deletions} across ${meta.total_files_changed} files
27
+
28
+ ## What happened (key changes):
29
+ ${groupList}
30
+
31
+ ## Story:
32
+ ${storyExcerpt}
33
+
34
+ ## Comic Requirements:
35
+ - 2x2 grid, 4 panels
36
+ - Cute stick-figure developer characters with expressive faces and gestures
37
+ - Speech bubbles with SHORT, witty dialogue in ${lang}
38
+ - Panel 1: The developer discovers the problem or receives the task (based on the PR purpose above)
39
+ - Panel 2: The developer's ambitious plan or approach (based on the actual changes)
40
+ - Panel 3: A funny complication that reflects the real complexity (based on risk/impact)
41
+ - Panel 4: The resolution with a developer humor punchline
42
+ - The humor should be SPECIFIC to this PR's content, not generic programming jokes
43
+ - Make the characters expressive and the scenes detailed
44
+ - The image must be square (1:1 aspect ratio, 1080x1080px), suitable for Instagram
45
+ - Output only the image`;
46
+ }
47
+
48
+ interface CartoonResponse {
49
+ choices: Array<{
50
+ message: {
51
+ content: string;
52
+ images?: Array<{
53
+ image_url: { url: string };
54
+ }>;
55
+ };
56
+ }>;
57
+ }
58
+
59
+ const MAX_RETRIES = 3;
60
+
61
+ export async function generateCartoon(
62
+ apiKey: string,
63
+ data: NewprOutput,
64
+ language: string,
65
+ ): Promise<{ imageBase64: string; mimeType: string }> {
66
+ const prompt = buildCartoonPrompt(data, language);
67
+
68
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
69
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
70
+ method: "POST",
71
+ headers: {
72
+ Authorization: `Bearer ${apiKey}`,
73
+ "Content-Type": "application/json",
74
+ "HTTP-Referer": "https://github.com/jiwonMe/newpr",
75
+ "X-Title": "newpr-cartoon",
76
+ },
77
+ body: JSON.stringify({
78
+ model: CARTOON_MODEL,
79
+ messages: [
80
+ { role: "user", content: prompt },
81
+ ],
82
+ modalities: ["image", "text"],
83
+ temperature: 1.0,
84
+ }),
85
+ });
86
+
87
+ if (response.status === 500 || response.status === 502 || response.status === 503) {
88
+ if (attempt < MAX_RETRIES) {
89
+ await new Promise((r) => setTimeout(r, 2000 * attempt));
90
+ continue;
91
+ }
92
+ }
93
+
94
+ if (!response.ok) {
95
+ const body = await response.text();
96
+ throw new Error(`Cartoon generation failed (${response.status}): ${body.slice(0, 300)}`);
97
+ }
98
+
99
+ const result = await response.json() as CartoonResponse;
100
+ const message = result.choices?.[0]?.message;
101
+
102
+ if (message?.images?.length) {
103
+ const imageUrl = message.images[0]!.image_url.url;
104
+ const match = imageUrl.match(/^data:image\/(png|jpeg|webp|gif);base64,(.+)$/);
105
+ if (match) {
106
+ return { imageBase64: match[2]!, mimeType: `image/${match[1]}` };
107
+ }
108
+ const imgRes = await fetch(imageUrl);
109
+ if (imgRes.ok) {
110
+ const buf = await imgRes.arrayBuffer();
111
+ return {
112
+ imageBase64: Buffer.from(buf).toString("base64"),
113
+ mimeType: imgRes.headers.get("content-type") ?? "image/png",
114
+ };
115
+ }
116
+ }
117
+
118
+ if (attempt < MAX_RETRIES) {
119
+ await new Promise((r) => setTimeout(r, 2000 * attempt));
120
+ continue;
121
+ }
122
+
123
+ const raw = JSON.stringify(result).slice(0, 500);
124
+ throw new Error(`No image in response. Raw: ${raw}`);
125
+ }
126
+
127
+ throw new Error("Cartoon generation failed after retries");
128
+ }
@@ -50,10 +50,17 @@ export interface FileChange {
50
50
  groups: string[];
51
51
  }
52
52
 
53
+ export interface CartoonImage {
54
+ imageBase64: string;
55
+ mimeType: string;
56
+ generatedAt: string;
57
+ }
58
+
53
59
  export interface NewprOutput {
54
60
  meta: PrMeta;
55
61
  summary: PrSummary;
56
62
  groups: FileGroup[];
57
63
  files: FileChange[];
58
64
  narrative: string;
65
+ cartoon?: CartoonImage;
59
66
  }
@@ -3,6 +3,7 @@ import { useAnalysis } from "./hooks/useAnalysis.ts";
3
3
  import { useTheme } from "./hooks/useTheme.ts";
4
4
  import { useSessions } from "./hooks/useSessions.ts";
5
5
  import { useGithubUser } from "./hooks/useGithubUser.ts";
6
+ import { useFeatures } from "./hooks/useFeatures.ts";
6
7
  import { AppShell } from "./components/AppShell.tsx";
7
8
  import { InputScreen } from "./components/InputScreen.tsx";
8
9
  import { LoadingTimeline } from "./components/LoadingTimeline.tsx";
@@ -31,6 +32,7 @@ export function App() {
31
32
  const themeCtx = useTheme();
32
33
  const { sessions, refresh: refreshSessions } = useSessions();
33
34
  const githubUser = useGithubUser();
35
+ const features = useFeatures();
34
36
  const initialLoadDone = useRef(false);
35
37
  const [activeId, setActiveId] = useState<string | null>(null);
36
38
 
@@ -107,6 +109,8 @@ export function App() {
107
109
  onBack={handleNewAnalysis}
108
110
  activeId={activeId}
109
111
  onAnchorClick={handleAnchorClick}
112
+ cartoonEnabled={features.cartoon}
113
+ sessionId={analysis.sessionId}
110
114
  />
111
115
  )}
112
116
  {analysis.phase === "error" && (
@@ -1,5 +1,5 @@
1
- import { useState, useRef, useEffect } from "react";
2
- import { ArrowLeft, FileText, Layers, FolderTree, BookOpen, LayoutList, GitBranch, User, Files, Bot } from "lucide-react";
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+ import { ArrowLeft, FileText, Layers, FolderTree, BookOpen, LayoutList, GitBranch, User, Files, Bot, Sparkles } from "lucide-react";
3
3
  import { Button } from "../../components/ui/button.tsx";
4
4
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../components/ui/tabs.tsx";
5
5
  import type { NewprOutput } from "../../../types/output.ts";
@@ -8,8 +8,9 @@ import { GroupsPanel } from "../panels/GroupsPanel.tsx";
8
8
  import { FilesPanel } from "../panels/FilesPanel.tsx";
9
9
  import { NarrativePanel } from "../panels/NarrativePanel.tsx";
10
10
  import { StoryPanel } from "../panels/StoryPanel.tsx";
11
+ import { CartoonPanel } from "../panels/CartoonPanel.tsx";
11
12
 
12
- const VALID_TABS = ["story", "summary", "groups", "files", "narrative"] as const;
13
+ const VALID_TABS = ["story", "summary", "groups", "files", "narrative", "cartoon"] as const;
13
14
  type TabValue = typeof VALID_TABS[number];
14
15
 
15
16
  function getInitialTab(): TabValue {
@@ -36,43 +37,63 @@ export function ResultsScreen({
36
37
  onBack,
37
38
  activeId,
38
39
  onAnchorClick,
40
+ cartoonEnabled,
41
+ sessionId,
39
42
  }: {
40
43
  data: NewprOutput;
41
44
  onBack: () => void;
42
45
  activeId: string | null;
43
46
  onAnchorClick: (kind: "group" | "file", id: string) => void;
47
+ cartoonEnabled?: boolean;
48
+ sessionId?: string | null;
44
49
  }) {
45
50
  const { meta, summary } = data;
46
51
  const [tab, setTab] = useState<TabValue>(getInitialTab);
47
- const [scrolled, setScrolled] = useState(false);
48
- const sentinelRef = useRef<HTMLDivElement>(null);
52
+
53
+ const stickyRef = useRef<HTMLDivElement>(null);
54
+ const collapsibleRef = useRef<HTMLDivElement>(null);
55
+ const compactRef = useRef<HTMLDivElement>(null);
49
56
 
50
57
  useEffect(() => {
51
- const sentinel = sentinelRef.current;
52
- if (!sentinel) return;
53
- const observer = new IntersectionObserver(
54
- ([entry]) => setScrolled(!entry!.isIntersecting),
55
- { threshold: 0 },
56
- );
57
- observer.observe(sentinel);
58
- return () => observer.disconnect();
58
+ const sticky = stickyRef.current;
59
+ const collapsible = collapsibleRef.current;
60
+ const compact = compactRef.current;
61
+ if (!sticky || !collapsible || !compact) return;
62
+
63
+ const scrollParent = sticky.closest("main") ?? sticky.closest("[class*=overflow-y-auto]");
64
+ if (!scrollParent) return;
65
+
66
+ let wasScrolled = false;
67
+
68
+ const onScroll = () => {
69
+ const scrolled = scrollParent.scrollTop > 0;
70
+ if (scrolled === wasScrolled) return;
71
+ wasScrolled = scrolled;
72
+
73
+ collapsible.style.maxHeight = scrolled ? "0px" : "none";
74
+ collapsible.style.opacity = scrolled ? "0" : "1";
75
+ compact.style.maxHeight = scrolled ? "40px" : "0px";
76
+ compact.style.opacity = scrolled ? "1" : "0";
77
+ sticky.classList.toggle("border-b", scrolled);
78
+ };
79
+
80
+ scrollParent.addEventListener("scroll", onScroll, { passive: true });
81
+ return () => scrollParent.removeEventListener("scroll", onScroll);
59
82
  }, []);
60
83
 
61
- function handleTabChange(value: string) {
84
+ const handleTabChange = useCallback((value: string) => {
62
85
  setTab(value as TabValue);
63
86
  setTabParam(value);
64
- }
87
+ }, []);
65
88
 
66
89
  const repoSlug = meta.pr_url.replace(/^https?:\/\/github\.com\//, "").replace(/\/pull\//, "#");
67
90
 
68
91
  return (
69
- <div className="flex flex-col">
70
- <div ref={sentinelRef} />
71
-
72
- <div className={`sticky top-0 z-10 bg-background transition-all ${scrolled ? "pb-3 pt-1 border-b" : "pb-6 pt-0"}`}>
73
- {!scrolled && (
74
- <>
75
- <div className="flex items-center gap-3 mb-4">
92
+ <Tabs value={tab} onValueChange={handleTabChange} className="flex flex-col">
93
+ <div ref={stickyRef} className="sticky top-0 z-10 bg-background pb-2 -mx-10 px-10">
94
+ <div ref={collapsibleRef} className="overflow-hidden transition-[max-height,opacity] duration-200">
95
+ <div className="pb-3 pt-1">
96
+ <div className="flex items-center gap-3 mb-3">
76
97
  <Button variant="ghost" size="icon" className="shrink-0 -ml-2" onClick={onBack}>
77
98
  <ArrowLeft className="h-4 w-4" />
78
99
  </Button>
@@ -89,45 +110,45 @@ export function ResultsScreen({
89
110
  </span>
90
111
  </div>
91
112
 
92
- <h1 className="text-2xl font-bold tracking-tight mb-5" style={{ textWrap: "balance" }}>{meta.pr_title}</h1>
113
+ <h1 className="text-lg font-bold tracking-tight mb-2 line-clamp-2">{meta.pr_title}</h1>
93
114
 
94
- <div className="flex flex-wrap gap-x-5 gap-y-2 mb-6">
115
+ <div className="flex flex-wrap gap-x-4 gap-y-1">
95
116
  <a
96
117
  href={meta.author_url ?? `https://github.com/${meta.author}`}
97
118
  target="_blank"
98
119
  rel="noopener noreferrer"
99
- className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
120
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
100
121
  >
101
122
  {meta.author_avatar ? (
102
- <img src={meta.author_avatar} alt={meta.author} className="h-4 w-4 rounded-full" />
123
+ <img src={meta.author_avatar} alt={meta.author} className="h-3.5 w-3.5 rounded-full" />
103
124
  ) : (
104
- <User className="h-3.5 w-3.5" />
125
+ <User className="h-3 w-3" />
105
126
  )}
106
127
  <span>{meta.author}</span>
107
128
  </a>
108
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
109
- <GitBranch className="h-3.5 w-3.5" />
110
- <span className="font-mono text-xs">{meta.base_branch}</span>
129
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
130
+ <GitBranch className="h-3 w-3" />
131
+ <span className="font-mono">{meta.base_branch}</span>
111
132
  <span className="text-muted-foreground/50">←</span>
112
- <span className="font-mono text-xs">{meta.head_branch}</span>
133
+ <span className="font-mono">{meta.head_branch}</span>
113
134
  </div>
114
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
115
- <Files className="h-3.5 w-3.5" />
135
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
136
+ <Files className="h-3 w-3" />
116
137
  <span className="text-green-500">+{meta.total_additions}</span>
117
138
  <span className="text-red-500">−{meta.total_deletions}</span>
118
139
  <span className="text-muted-foreground/50">·</span>
119
140
  <span>{meta.total_files_changed} files</span>
120
141
  </div>
121
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
122
- <Bot className="h-3.5 w-3.5" />
142
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
143
+ <Bot className="h-3 w-3" />
123
144
  <span>{meta.model_used.split("/").pop()}</span>
124
145
  </div>
125
146
  </div>
126
- </>
127
- )}
147
+ </div>
148
+ </div>
128
149
 
129
- {scrolled && (
130
- <div className="flex items-center gap-3 min-w-0">
150
+ <div ref={compactRef} className="overflow-hidden transition-[max-height,opacity] duration-200" style={{ maxHeight: 0, opacity: 0 }}>
151
+ <div className="flex items-center gap-3 min-w-0 pb-2">
131
152
  <Button variant="ghost" size="icon" className="shrink-0 -ml-2 h-7 w-7" onClick={onBack}>
132
153
  <ArrowLeft className="h-3.5 w-3.5" />
133
154
  </Button>
@@ -137,49 +158,58 @@ export function ResultsScreen({
137
158
  {summary.risk_level}
138
159
  </span>
139
160
  </div>
140
- )}
161
+ </div>
141
162
 
142
- <Tabs value={tab} onValueChange={handleTabChange} className="w-full">
143
- <TabsList className="w-full justify-start overflow-x-auto">
144
- <TabsTrigger value="story" className="gap-1.5">
145
- <BookOpen className="h-3.5 w-3.5 shrink-0" />
146
- Story
147
- </TabsTrigger>
148
- <TabsTrigger value="summary" className="gap-1.5">
149
- <LayoutList className="h-3.5 w-3.5 shrink-0" />
150
- Summary
151
- </TabsTrigger>
152
- <TabsTrigger value="groups" className="gap-1.5">
153
- <Layers className="h-3.5 w-3.5 shrink-0" />
154
- Groups
163
+ <TabsList className="w-full justify-start overflow-x-auto">
164
+ <TabsTrigger value="story" className="gap-1.5">
165
+ <BookOpen className="h-3.5 w-3.5 shrink-0" />
166
+ Story
167
+ </TabsTrigger>
168
+ <TabsTrigger value="summary" className="gap-1.5">
169
+ <LayoutList className="h-3.5 w-3.5 shrink-0" />
170
+ Summary
171
+ </TabsTrigger>
172
+ <TabsTrigger value="groups" className="gap-1.5">
173
+ <Layers className="h-3.5 w-3.5 shrink-0" />
174
+ Groups
175
+ </TabsTrigger>
176
+ <TabsTrigger value="files" className="gap-1.5">
177
+ <FolderTree className="h-3.5 w-3.5 shrink-0" />
178
+ Files
179
+ </TabsTrigger>
180
+ <TabsTrigger value="narrative" className="gap-1.5">
181
+ <FileText className="h-3.5 w-3.5 shrink-0" />
182
+ Narrative
183
+ </TabsTrigger>
184
+ {cartoonEnabled && (
185
+ <TabsTrigger value="cartoon" className="gap-1.5">
186
+ <Sparkles className="h-3.5 w-3.5 shrink-0" />
187
+ Comic
155
188
  </TabsTrigger>
156
- <TabsTrigger value="files" className="gap-1.5">
157
- <FolderTree className="h-3.5 w-3.5 shrink-0" />
158
- Files
159
- </TabsTrigger>
160
- <TabsTrigger value="narrative" className="gap-1.5">
161
- <FileText className="h-3.5 w-3.5 shrink-0" />
162
- Narrative
163
- </TabsTrigger>
164
- </TabsList>
165
-
166
- <TabsContent value="story">
167
- <StoryPanel data={data} activeId={activeId} onAnchorClick={onAnchorClick} />
168
- </TabsContent>
169
- <TabsContent value="summary">
170
- <SummaryPanel summary={data.summary} />
171
- </TabsContent>
172
- <TabsContent value="groups">
173
- <GroupsPanel groups={data.groups} />
174
- </TabsContent>
175
- <TabsContent value="files">
176
- <FilesPanel files={data.files} />
177
- </TabsContent>
178
- <TabsContent value="narrative">
179
- <NarrativePanel narrative={data.narrative} />
180
- </TabsContent>
181
- </Tabs>
189
+ )}
190
+ </TabsList>
182
191
  </div>
183
- </div>
192
+
193
+ <TabsContent value="story">
194
+ <StoryPanel data={data} activeId={activeId} onAnchorClick={onAnchorClick} />
195
+ </TabsContent>
196
+ <TabsContent value="summary">
197
+ <SummaryPanel summary={data.summary} />
198
+ </TabsContent>
199
+ <TabsContent value="groups">
200
+ <GroupsPanel groups={data.groups} />
201
+ </TabsContent>
202
+ <TabsContent value="files">
203
+ <FilesPanel files={data.files} />
204
+ </TabsContent>
205
+ <TabsContent value="narrative">
206
+ <NarrativePanel narrative={data.narrative} />
207
+ </TabsContent>
208
+ {cartoonEnabled && (
209
+ <TabsContent value="cartoon">
210
+ <CartoonPanel data={data} sessionId={sessionId} />
211
+ </TabsContent>
212
+ )}
213
+ </Tabs>
184
214
  );
185
215
  }
@@ -0,0 +1,18 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ interface Features {
4
+ cartoon: boolean;
5
+ }
6
+
7
+ export function useFeatures(): Features {
8
+ const [features, setFeatures] = useState<Features>({ cartoon: false });
9
+
10
+ useEffect(() => {
11
+ fetch("/api/features")
12
+ .then((r) => r.json())
13
+ .then((data) => setFeatures(data as Features))
14
+ .catch(() => {});
15
+ }, []);
16
+
17
+ return features;
18
+ }
@@ -0,0 +1,96 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Loader2, Sparkles, RefreshCw } from "lucide-react";
3
+ import { Button } from "../../components/ui/button.tsx";
4
+ import type { NewprOutput } from "../../../types/output.ts";
5
+
6
+ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId?: string | null }) {
7
+ const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
8
+ const [imageUrl, setImageUrl] = useState<string | null>(null);
9
+ const [error, setError] = useState<string | null>(null);
10
+
11
+ useEffect(() => {
12
+ if (data.cartoon) {
13
+ setImageUrl(`data:${data.cartoon.mimeType};base64,${data.cartoon.imageBase64}`);
14
+ setState("done");
15
+ }
16
+ }, [data.cartoon]);
17
+
18
+ async function generate() {
19
+ setState("loading");
20
+ setError(null);
21
+ try {
22
+ const body: Record<string, unknown> = { data };
23
+ if (sessionId) body.sessionId = sessionId;
24
+
25
+ const res = await fetch("/api/cartoon", {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify(body),
29
+ });
30
+ const result = await res.json() as { imageBase64?: string; mimeType?: string; error?: string };
31
+ if (result.error) throw new Error(result.error);
32
+ if (!result.imageBase64) throw new Error("No image returned");
33
+ setImageUrl(`data:${result.mimeType};base64,${result.imageBase64}`);
34
+ setState("done");
35
+ } catch (err) {
36
+ setError(err instanceof Error ? err.message : String(err));
37
+ setState("error");
38
+ }
39
+ }
40
+
41
+ if (state === "idle") {
42
+ return (
43
+ <div className="pt-6 flex flex-col items-center gap-6 py-20">
44
+ <Sparkles className="h-12 w-12 text-yellow-500/60" />
45
+ <div className="text-center space-y-2">
46
+ <h3 className="text-lg font-semibold">PR 4-Panel Comic</h3>
47
+ <p className="text-sm text-muted-foreground max-w-sm">
48
+ Turn this PR into a fun 4-panel comic strip. Powered by Gemini.
49
+ </p>
50
+ </div>
51
+ <Button onClick={generate} size="lg">
52
+ <Sparkles className="mr-2 h-4 w-4" />
53
+ Generate Comic
54
+ </Button>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ if (state === "loading") {
60
+ return (
61
+ <div className="pt-6 flex flex-col items-center gap-4 py-20">
62
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
63
+ <p className="text-sm text-muted-foreground">Drawing your PR comic...</p>
64
+ <p className="text-xs text-muted-foreground/60">This may take 10-30 seconds</p>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ if (state === "error") {
70
+ return (
71
+ <div className="pt-6 flex flex-col items-center gap-4 py-20">
72
+ <p className="text-sm text-destructive">{error}</p>
73
+ <Button variant="ghost" onClick={generate}>
74
+ <RefreshCw className="mr-2 h-3.5 w-3.5" />
75
+ Try again
76
+ </Button>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <div className="pt-6 flex flex-col items-center gap-4">
83
+ {imageUrl && (
84
+ <img
85
+ src={imageUrl}
86
+ alt="PR 4-panel comic"
87
+ className="max-w-full rounded-lg border shadow-sm"
88
+ />
89
+ )}
90
+ <Button variant="ghost" size="sm" onClick={generate}>
91
+ <RefreshCw className="mr-2 h-3.5 w-3.5" />
92
+ Regenerate
93
+ </Button>
94
+ </div>
95
+ );
96
+ }
@@ -1,8 +1,10 @@
1
1
  import type { NewprConfig } from "../../types/config.ts";
2
+ import type { NewprOutput } from "../../types/output.ts";
2
3
  import { DEFAULT_CONFIG } from "../../types/config.ts";
3
4
  import { listSessions, loadSession } from "../../history/store.ts";
4
5
  import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
5
6
  import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
7
+ import { generateCartoon } from "../../llm/cartoon.ts";
6
8
 
7
9
  function json(data: unknown, status = 200): Response {
8
10
  return new Response(JSON.stringify(data), {
@@ -11,7 +13,11 @@ function json(data: unknown, status = 200): Response {
11
13
  });
12
14
  }
13
15
 
14
- export function createRoutes(token: string, config: NewprConfig) {
16
+ interface RouteOptions {
17
+ cartoon?: boolean;
18
+ }
19
+
20
+ export function createRoutes(token: string, config: NewprConfig, options: RouteOptions = {}) {
15
21
  return {
16
22
  "POST /api/analysis": async (req: Request) => {
17
23
  const body = await req.json() as { pr: string };
@@ -198,5 +204,47 @@ export function createRoutes(token: string, config: NewprConfig) {
198
204
  await writeStoredConfig(update);
199
205
  return json({ ok: true });
200
206
  },
207
+
208
+ "GET /api/features": () => {
209
+ return json({ cartoon: !!options.cartoon });
210
+ },
211
+
212
+ "POST /api/cartoon": async (req: Request) => {
213
+ if (!options.cartoon) return json({ error: "Cartoon mode not enabled. Start with --cartoon flag." }, 403);
214
+ if (!config.openrouter_api_key) return json({ error: "OpenRouter API key required for cartoon generation" }, 400);
215
+
216
+ try {
217
+ const body = await req.json() as { data?: NewprOutput; sessionId?: string };
218
+ let data = body.data;
219
+ const sessionId = body.sessionId;
220
+
221
+ if (!data && sessionId) {
222
+ data = await loadSession(sessionId) as NewprOutput | null ?? undefined;
223
+ }
224
+ if (!data) return json({ error: "Missing analysis data" }, 400);
225
+
226
+ const result = await generateCartoon(config.openrouter_api_key, data, config.language);
227
+
228
+ if (sessionId) {
229
+ const sessionData = await loadSession(sessionId);
230
+ if (sessionData) {
231
+ sessionData.cartoon = {
232
+ imageBase64: result.imageBase64,
233
+ mimeType: result.mimeType,
234
+ generatedAt: new Date().toISOString(),
235
+ };
236
+ const { join } = await import("node:path");
237
+ const { homedir } = await import("node:os");
238
+ const sessionsDir = join(homedir(), ".newpr", "history", "sessions");
239
+ await Bun.write(join(sessionsDir, `${sessionId}.json`), JSON.stringify(sessionData, null, 2));
240
+ }
241
+ }
242
+
243
+ return json(result);
244
+ } catch (err) {
245
+ const msg = err instanceof Error ? err.message : String(err);
246
+ return json({ error: msg }, 500);
247
+ }
248
+ },
201
249
  };
202
250
  }
package/src/web/server.ts CHANGED
@@ -7,6 +7,7 @@ interface WebServerOptions {
7
7
  port: number;
8
8
  token: string;
9
9
  config: NewprConfig;
10
+ cartoon?: boolean;
10
11
  }
11
12
 
12
13
  function getCssPaths() {
@@ -31,8 +32,8 @@ async function buildCss(bin: string, input: string, output: string): Promise<voi
31
32
  }
32
33
 
33
34
  export async function startWebServer(options: WebServerOptions): Promise<void> {
34
- const { port, token, config } = options;
35
- const routes = createRoutes(token, config);
35
+ const { port, token, config, cartoon } = options;
36
+ const routes = createRoutes(token, config, { cartoon });
36
37
  const css = getCssPaths();
37
38
 
38
39
  await buildCss(css.bin, css.input, css.output);
@@ -82,6 +83,12 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
82
83
  if (path.match(/^\/api\/sessions\/[^/]+$/) && req.method === "GET") {
83
84
  return routes["GET /api/sessions/:id"](req);
84
85
  }
86
+ if (path === "/api/features" && req.method === "GET") {
87
+ return routes["GET /api/features"]();
88
+ }
89
+ if (path === "/api/cartoon" && req.method === "POST") {
90
+ return routes["POST /api/cartoon"](req);
91
+ }
85
92
 
86
93
  return new Response("Not Found", { status: 404 });
87
94
  },
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:"Pretendard","Inter",ui-sans-serif,system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-black:#000;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--animate-spin:spin 1s linear infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius:.5rem}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border)}body{background-color:var(--bg);color:var(--fg);font-feature-settings:"rlig" 1,"calt" 1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.z-10{z-index:10}.z-50{z-index:50}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.-mx-10{margin-inline:calc(var(--spacing)*-10)}.mx-auto{margin-inline:auto}.my-4{margin-block:calc(var(--spacing)*4)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1\.5{margin-top:calc(var(--spacing)*1.5)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.-ml-2{margin-left:calc(var(--spacing)*-2)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-4{margin-left:calc(var(--spacing)*4)}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-11{height:calc(var(--spacing)*11)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-full{height:100%}.h-screen{height:100vh}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-\[85vh\]{max-height:85vh}.w-1{width:calc(var(--spacing)*1)}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-7{width:calc(var(--spacing)*7)}.w-8{width:calc(var(--spacing)*8)}.w-9{width:calc(var(--spacing)*9)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-20{width:calc(var(--spacing)*20)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing)*0)}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.list-decimal{list-style-type:decimal}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:calc(var(--spacing)*0)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-2\.5{gap:calc(var(--spacing)*2.5)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-12{gap:calc(var(--spacing)*12)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing)*4)}.gap-y-1{row-gap:calc(var(--spacing)*1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:var(--radius)}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-border{border-color:var(--border)}.border-input{border-color:var(--input)}.border-muted-foreground\/30{border-color:var(--muted-fg)}@supports (color:color-mix(in lab, red, red)){.border-muted-foreground\/30{border-color:color-mix(in oklab,var(--muted-fg)30%,transparent)}}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-background{background-color:var(--bg)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/20{background-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-destructive{background-color:var(--destructive)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500)10%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.bg-primary{background-color:var(--primary)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/10{background-color:color-mix(in oklab,var(--color-purple-500)10%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/20{background-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-secondary{background-color:var(--secondary)}.bg-teal-500\/10{background-color:#00baa71a}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/10{background-color:color-mix(in oklab,var(--color-teal-500)10%,transparent)}}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\/10{background-color:#edb2001a}@supports (color:color-mix(in lab, red, red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--color-yellow-500)10%,transparent)}}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-8{padding-inline:calc(var(--spacing)*8)}.px-10{padding-inline:calc(var(--spacing)*10)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-10{padding-block:calc(var(--spacing)*10)}.py-16{padding-block:calc(var(--spacing)*16)}.py-20{padding-block:calc(var(--spacing)*20)}.py-24{padding-block:calc(var(--spacing)*24)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-5{padding-top:calc(var(--spacing)*5)}.pt-6{padding-top:calc(var(--spacing)*6)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-6{padding-left:calc(var(--spacing)*6)}.pl-8{padding-left:calc(var(--spacing)*8)}.pl-12{padding-left:calc(var(--spacing)*12)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-card-foreground{color:var(--card-fg)}.text-destructive{color:var(--destructive)}.text-destructive-foreground{color:var(--destructive-fg)}.text-foreground,.text-foreground\/90{color:var(--fg)}@supports (color:color-mix(in lab, red, red)){.text-foreground\/90{color:color-mix(in oklab,var(--fg)90%,transparent)}}.text-gray-600{color:var(--color-gray-600)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-muted-foreground,.text-muted-foreground\/30{color:var(--muted-fg)}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/30{color:color-mix(in oklab,var(--muted-fg)30%,transparent)}}.text-muted-foreground\/40{color:var(--muted-fg)}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/40{color:color-mix(in oklab,var(--muted-fg)40%,transparent)}}.text-muted-foreground\/50{color:var(--muted-fg)}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/50{color:color-mix(in oklab,var(--muted-fg)50%,transparent)}}.text-muted-foreground\/60{color:var(--muted-fg)}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/60{color:color-mix(in oklab,var(--muted-fg)60%,transparent)}}.text-orange-600{color:var(--color-orange-600)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-fg)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-fg)}.text-teal-600{color:var(--color-teal-600)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-500\/60{color:#edb20099}@supports (color:color-mix(in lab, red, red)){.text-yellow-500\/60{color:color-mix(in oklab,var(--color-yellow-500)60%,transparent)}}.text-yellow-600{color:var(--color-yellow-600)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-blue-500\/40{--tw-ring-color:#3080ff66}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/40{--tw-ring-color:color-mix(in oklab,var(--color-blue-500)40%,transparent)}}.ring-offset-background{--tw-ring-offset-color:var(--bg)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition-\[max-height\,opacity\]{transition-property:max-height,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:text-foreground:is(:where(.group):hover *){color:var(--fg)}}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-fg)}@media (hover:hover){.hover\:bg-accent:hover,.hover\:bg-accent\/30:hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/30:hover{background-color:color-mix(in oklab,var(--accent)30%,transparent)}}.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.hover\:bg-blue-500\/10:hover{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/10:hover{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.hover\:bg-blue-500\/20:hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/20:hover{background-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.hover\:bg-blue-500\/30:hover{background-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/30:hover{background-color:color-mix(in oklab,var(--color-blue-500)30%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-muted\/80:hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,var(--muted)80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-fg)}.hover\:text-foreground:hover{color:var(--fg)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-80:hover{opacity:.8}}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--ring)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-blue-500\/50:active{background-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.active\:bg-blue-500\/50:active{background-color:color-mix(in oklab,var(--color-blue-500)50%,transparent)}}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--bg)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--fg)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-300:is(.dark *){color:var(--color-red-300)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-teal-400:is(.dark *){color:var(--color-teal-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}}:root{--bg:#fff;--fg:#0a0a0a;--card:#fff;--card-fg:#0a0a0a;--popover:#fff;--popover-fg:#0a0a0a;--primary:#171717;--primary-fg:#fafafa;--secondary:#f5f5f5;--secondary-fg:#171717;--muted:#f5f5f5;--muted-fg:#737373;--accent:#f5f5f5;--accent-fg:#171717;--destructive:#ef4444;--destructive-fg:#fafafa;--border:#e5e5e5;--input:#e5e5e5;--ring:#0a0a0a}.dark{--bg:#0a0a0a;--fg:#fafafa;--card:#0a0a0a;--card-fg:#fafafa;--popover:#0a0a0a;--popover-fg:#fafafa;--primary:#fafafa;--primary-fg:#171717;--secondary:#262626;--secondary-fg:#fafafa;--muted:#262626;--muted-fg:#a3a3a3;--accent:#262626;--accent-fg:#fafafa;--destructive:#7f1d1d;--destructive-fg:#fafafa;--border:#262626;--input:#262626;--ring:#d4d4d4}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}