plotlink-ows 0.1.15 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +49 -57
  2. package/app/db.ts +1 -1
  3. package/app/lib/paths.ts +0 -2
  4. package/app/lib/publish.ts +150 -39
  5. package/app/prisma/schema.prisma +0 -36
  6. package/app/routes/dashboard.ts +53 -56
  7. package/app/routes/publish.ts +55 -24
  8. package/app/routes/stories.ts +156 -0
  9. package/app/routes/terminal.ts +154 -0
  10. package/app/routes/wallet.ts +40 -10
  11. package/app/server.ts +29 -81
  12. package/app/web/App.tsx +4 -6
  13. package/app/web/components/Dashboard.tsx +15 -47
  14. package/app/web/components/Layout.tsx +70 -103
  15. package/app/web/components/PreviewPanel.tsx +149 -0
  16. package/app/web/components/Settings.tsx +3 -84
  17. package/app/web/components/StoriesPage.tsx +157 -0
  18. package/app/web/components/StoryBrowser.tsx +137 -0
  19. package/app/web/components/TerminalPanel.tsx +122 -0
  20. package/app/web/components/WalletCard.tsx +14 -8
  21. package/app/web/dist/assets/index-D5gfwaEX.css +32 -0
  22. package/app/web/dist/assets/index-pBt5Q_bN.js +117 -0
  23. package/app/web/dist/index.html +3 -3
  24. package/app/web/dist/plotlink-logo.svg +5 -0
  25. package/app/web/public/plotlink-logo.svg +5 -0
  26. package/bin/plotlink-ows.js +2 -1
  27. package/package.json +9 -5
  28. package/app/lib/llm-client.ts +0 -265
  29. package/app/lib/writer-prompt.ts +0 -44
  30. package/app/routes/chat.ts +0 -135
  31. package/app/routes/config.ts +0 -210
  32. package/app/routes/oauth.ts +0 -150
  33. package/app/web/components/Chat.tsx +0 -272
  34. package/app/web/components/LLMSetup.tsx +0 -291
  35. package/app/web/components/Publish.tsx +0 -245
  36. package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
  37. package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
@@ -1,291 +0,0 @@
1
- import React, { useState, useEffect } from "react";
2
-
3
- const API_BASE = "http://localhost:7777";
4
-
5
- interface Provider {
6
- id: string;
7
- name: string;
8
- envKey: string | null;
9
- models: string[];
10
- tag: string | null;
11
- configured: boolean;
12
- }
13
-
14
- type Step = "provider" | "auth" | "model" | "test" | "done";
15
-
16
- export function LLMSetup({ token, onComplete }: { token: string; onComplete: () => void }) {
17
- const [step, setStep] = useState<Step>("provider");
18
- const [providers, setProviders] = useState<Provider[]>([]);
19
- const [selected, setSelected] = useState<string>("");
20
- const [model, setModel] = useState<string>("");
21
- const [apiKey, setApiKey] = useState<string>("");
22
- const [baseUrl, setBaseUrl] = useState<string>("http://localhost:11434");
23
- const [testing, setTesting] = useState(false);
24
- const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
25
- const [saving, setSaving] = useState(false);
26
- const [error, setError] = useState<string | null>(null);
27
-
28
- const authFetch = (url: string, opts?: RequestInit) =>
29
- fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
30
-
31
- useEffect(() => {
32
- authFetch(`${API_BASE}/api/config/llm/providers`)
33
- .then((r) => r.json())
34
- .then((data) => setProviders(data));
35
- }, []);
36
-
37
- const selectedProvider = providers.find((p) => p.id === selected);
38
-
39
- const handleTest = async () => {
40
- setTesting(true);
41
- setTestResult(null);
42
- try {
43
- const res = await authFetch(`${API_BASE}/api/config/llm/test`, {
44
- method: "POST",
45
- body: JSON.stringify({ provider: selected, model, apiKey: apiKey || undefined, baseUrl: selected === "local" ? baseUrl : undefined }),
46
- });
47
- const data = await res.json();
48
- setTestResult(data);
49
- } catch {
50
- setTestResult({ success: false, message: "Connection failed" });
51
- }
52
- setTesting(false);
53
- };
54
-
55
- const handleSave = async () => {
56
- setSaving(true);
57
- setError(null);
58
- try {
59
- const res = await authFetch(`${API_BASE}/api/config/llm`, {
60
- method: "POST",
61
- body: JSON.stringify({ provider: selected, model, apiKey: apiKey || undefined, baseUrl: selected === "local" ? baseUrl : undefined }),
62
- });
63
- const data = await res.json();
64
- if (!res.ok) throw new Error(data.error || "Save failed");
65
- setStep("done");
66
- } catch (err: unknown) {
67
- setError(err instanceof Error ? err.message : "Save failed");
68
- }
69
- setSaving(false);
70
- };
71
-
72
- return (
73
- <div className="mx-auto max-w-lg p-6">
74
- <h2 className="text-accent mb-1 text-lg font-bold">LLM Setup</h2>
75
- <p className="text-muted mb-6 text-xs">connect your AI provider to power the writer agent</p>
76
-
77
- {/* Step indicator */}
78
- <div className="text-muted mb-6 flex gap-2 text-[10px] uppercase tracking-wider">
79
- {(["provider", "auth", "model", "test", "done"] as Step[]).map((s) => (
80
- <span key={s} className={step === s ? "text-accent" : ""}>{s}</span>
81
- ))}
82
- </div>
83
-
84
- {/* Provider selection */}
85
- {step === "provider" && (
86
- <div className="space-y-3">
87
- {providers.map((p) => (
88
- <button
89
- key={p.id}
90
- onClick={() => { setSelected(p.id); setModel(p.models[0] || ""); setStep(p.id === "local" ? "auth" : "auth"); }}
91
- className={`border-border hover:border-accent w-full rounded border p-3 text-left transition-colors ${selected === p.id ? "border-accent" : ""}`}
92
- >
93
- <div className="flex items-center justify-between">
94
- <span className="text-foreground text-sm font-medium">{p.name}</span>
95
- <span className="flex gap-1.5">
96
- {p.tag && <span className="text-accent border-accent/30 rounded border px-1.5 py-0.5 text-[9px]">{p.tag}</span>}
97
- {p.configured && <span className="rounded border border-green-700/30 px-1.5 py-0.5 text-[9px] text-accent">configured</span>}
98
- </span>
99
- </div>
100
- </button>
101
- ))}
102
- </div>
103
- )}
104
-
105
- {/* Auth step */}
106
- {step === "auth" && selectedProvider && (
107
- <div className="space-y-4">
108
- <button onClick={() => setStep("provider")} className="text-muted hover:text-foreground text-xs">&larr; back</button>
109
- <h3 className="text-foreground text-sm font-medium">{selectedProvider.name}</h3>
110
-
111
- {selected === "local" ? (
112
- <div className="space-y-3">
113
- <div>
114
- <label className="text-muted mb-1.5 block text-xs uppercase tracking-wider">Base URL</label>
115
- <input
116
- type="text"
117
- value={baseUrl}
118
- onChange={(e) => setBaseUrl(e.target.value)}
119
- placeholder="http://localhost:11434"
120
- className="bg-surface border-border text-foreground w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
121
- />
122
- </div>
123
- <div>
124
- <label className="text-muted mb-1.5 block text-xs uppercase tracking-wider">Model Name</label>
125
- <input
126
- type="text"
127
- value={model}
128
- onChange={(e) => setModel(e.target.value)}
129
- placeholder="llama3.2"
130
- className="bg-surface border-border text-foreground w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
131
- />
132
- </div>
133
- </div>
134
- ) : (
135
- <div className="space-y-4">
136
- {/* OAuth option */}
137
- {(selected === "anthropic" || selected === "openai") && (
138
- <div>
139
- <button
140
- onClick={async () => {
141
- try {
142
- const res = await authFetch(`${API_BASE}/api/oauth/${selected}/start`);
143
- const data = await res.json();
144
- if (data.authUrl) {
145
- window.open(data.authUrl, "oauth", "width=600,height=700");
146
- // Poll for completion
147
- const poll = setInterval(async () => {
148
- const status = await authFetch(`${API_BASE}/api/oauth/${selected}/status`).then((r) => r.json());
149
- if (status.complete) {
150
- clearInterval(poll);
151
- setStep("model");
152
- }
153
- }, 1500);
154
- setTimeout(() => clearInterval(poll), 120000);
155
- }
156
- } catch { /* ignore */ }
157
- }}
158
- className="border-accent text-accent hover:bg-accent/10 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
159
- >
160
- connect with OAuth (recommended)
161
- </button>
162
- <div className="text-muted my-3 flex items-center gap-2 text-[10px]">
163
- <div className="border-border flex-1 border-t" />
164
- <span>or use API key</span>
165
- <div className="border-border flex-1 border-t" />
166
- </div>
167
- </div>
168
- )}
169
-
170
- {/* API key input */}
171
- <div>
172
- <label className="text-muted mb-1.5 block text-xs uppercase tracking-wider">API Key</label>
173
- <input
174
- type="password"
175
- value={apiKey}
176
- onChange={(e) => setApiKey(e.target.value)}
177
- placeholder={`paste your ${selectedProvider.name} API key`}
178
- className="bg-surface border-border text-foreground w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
179
- />
180
- {selectedProvider.configured && (
181
- <p className="text-muted mt-1.5 text-[10px]">key already saved — leave blank to keep current</p>
182
- )}
183
- </div>
184
- </div>
185
- )}
186
-
187
- <button
188
- onClick={() => setStep("model")}
189
- disabled={selected === "local" ? !model.trim() : (!apiKey.trim() && !selectedProvider.configured)}
190
- className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
191
- >
192
- next
193
- </button>
194
- </div>
195
- )}
196
-
197
- {/* Model selection */}
198
- {step === "model" && selectedProvider && (
199
- <div className="space-y-4">
200
- <button onClick={() => setStep("auth")} className="text-muted hover:text-foreground text-xs">&larr; back</button>
201
- <h3 className="text-foreground text-sm font-medium">Select Model</h3>
202
-
203
- {selected === "local" ? (
204
- <p className="text-muted text-xs">Using: <span className="text-foreground font-medium">{model}</span></p>
205
- ) : (
206
- <div className="space-y-2">
207
- {selectedProvider.models.map((m) => (
208
- <button
209
- key={m}
210
- onClick={() => setModel(m)}
211
- className={`border-border w-full rounded border px-3 py-2 text-left text-sm transition-colors ${model === m ? "border-accent text-accent" : "text-foreground hover:border-accent/50"}`}
212
- >
213
- {m}
214
- </button>
215
- ))}
216
- </div>
217
- )}
218
-
219
- <button
220
- onClick={() => setStep("test")}
221
- disabled={!model}
222
- className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
223
- >
224
- test connection
225
- </button>
226
- </div>
227
- )}
228
-
229
- {/* Test step */}
230
- {step === "test" && (
231
- <div className="space-y-4">
232
- <button onClick={() => setStep("model")} className="text-muted hover:text-foreground text-xs">&larr; back</button>
233
- <h3 className="text-foreground text-sm font-medium">Test Connection</h3>
234
- <p className="text-muted text-xs">{selectedProvider?.name} / {model}</p>
235
-
236
- {!testResult && (
237
- <button
238
- onClick={handleTest}
239
- disabled={testing}
240
- className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
241
- >
242
- {testing ? "testing..." : "run test"}
243
- </button>
244
- )}
245
-
246
- {testResult && (
247
- <div className={`rounded border p-3 text-xs ${testResult.success ? "border-accent/30 text-accent" : "border-error/30 text-error"}`}>
248
- {testResult.success ? "connected" : "failed"}: {testResult.message}
249
- </div>
250
- )}
251
-
252
- {testResult?.success && (
253
- <button
254
- onClick={handleSave}
255
- disabled={saving}
256
- className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
257
- >
258
- {saving ? "saving..." : "save & continue"}
259
- </button>
260
- )}
261
-
262
- {testResult && !testResult.success && (
263
- <button
264
- onClick={() => { setTestResult(null); setStep("auth"); }}
265
- className="text-muted hover:text-foreground w-full py-2 text-xs transition-colors"
266
- >
267
- go back and fix
268
- </button>
269
- )}
270
-
271
- {error && <p className="text-error text-xs">{error}</p>}
272
- </div>
273
- )}
274
-
275
- {/* Done */}
276
- {step === "done" && (
277
- <div className="space-y-4 text-center">
278
- <div className="text-accent text-2xl">&#x2713;</div>
279
- <p className="text-foreground text-sm font-medium">LLM configured</p>
280
- <p className="text-muted text-xs">{selectedProvider?.name} / {model}</p>
281
- <button
282
- onClick={onComplete}
283
- className="border-accent text-accent hover:bg-accent/10 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
284
- >
285
- continue to wallet setup
286
- </button>
287
- </div>
288
- )}
289
- </div>
290
- );
291
- }
@@ -1,245 +0,0 @@
1
- import React, { useState, useEffect } from "react";
2
- import Markdown from "react-markdown";
3
-
4
- const API_BASE = "http://localhost:7777";
5
-
6
- interface Draft {
7
- id: string;
8
- title: string;
9
- content: string;
10
- genre: string | null;
11
- status: string;
12
- createdAt: string;
13
- }
14
-
15
- interface Preflight {
16
- ready: boolean;
17
- address?: string;
18
- ethBalance?: string;
19
- creationFee?: string;
20
- requiredBalance?: string;
21
- hasEnoughEth?: boolean;
22
- hasFilebase?: boolean;
23
- error?: string | null;
24
- }
25
-
26
- interface PublishProgress {
27
- step: string;
28
- message: string;
29
- txHash?: string;
30
- contentCid?: string;
31
- error?: string;
32
- }
33
-
34
- export function Publish({ token }: { token: string }) {
35
- const [drafts, setDrafts] = useState<Draft[]>([]);
36
- const [selected, setSelected] = useState<Draft | null>(null);
37
- const [preflight, setPreflight] = useState<Preflight | null>(null);
38
- const [publishing, setPublishing] = useState(false);
39
- const [progress, setProgress] = useState<PublishProgress | null>(null);
40
-
41
- const authFetch = (url: string, opts?: RequestInit) =>
42
- fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
43
-
44
- useEffect(() => {
45
- authFetch(`${API_BASE}/api/chat/drafts`)
46
- .then((r) => r.json())
47
- .then((data) => setDrafts(data.filter((d: Draft) => d.status !== "published")));
48
- }, []);
49
-
50
- const checkPreflight = async () => {
51
- const res = await authFetch(`${API_BASE}/api/publish/preflight`);
52
- const data = await res.json();
53
- setPreflight(data);
54
- };
55
-
56
- const handlePublish = async (draft: Draft) => {
57
- setPublishing(true);
58
- setProgress(null);
59
-
60
- try {
61
- const res = await fetch(`${API_BASE}/api/publish/${draft.id}`, {
62
- method: "POST",
63
- headers: { Authorization: `Bearer ${token}` },
64
- });
65
-
66
- const reader = res.body?.getReader();
67
- if (!reader) throw new Error("No stream");
68
- const decoder = new TextDecoder();
69
- let buffer = "";
70
-
71
- while (true) {
72
- const { done, value } = await reader.read();
73
- if (done) break;
74
- buffer += decoder.decode(value, { stream: true });
75
- const lines = buffer.split("\n");
76
- buffer = lines.pop() || "";
77
-
78
- for (const line of lines) {
79
- if (line.startsWith("data: ")) {
80
- try {
81
- const parsed = JSON.parse(line.slice(6));
82
- setProgress(parsed);
83
- } catch { /* ignore */ }
84
- }
85
- }
86
- }
87
-
88
- // Refresh drafts list
89
- const draftsRes = await authFetch(`${API_BASE}/api/chat/drafts`);
90
- const draftsData = await draftsRes.json();
91
- setDrafts(draftsData.filter((d: Draft) => d.status !== "published"));
92
- } catch (err: unknown) {
93
- setProgress({ step: "error", message: err instanceof Error ? err.message : "Publish failed" });
94
- }
95
-
96
- setPublishing(false);
97
- };
98
-
99
- const formatEth = (wei: string) => {
100
- const eth = Number(BigInt(wei)) / 1e18;
101
- return eth.toFixed(6);
102
- };
103
-
104
- return (
105
- <div className="mx-auto max-w-2xl space-y-6 p-6">
106
- <h2 className="text-accent text-lg font-bold">Publish to PlotLink</h2>
107
- <p className="text-muted text-xs">sign and broadcast your story on-chain via OWS wallet</p>
108
-
109
- {/* Preflight check */}
110
- {!preflight && (
111
- <button
112
- onClick={checkPreflight}
113
- className="border-accent text-accent hover:bg-accent/10 rounded border px-4 py-2 text-sm font-medium transition-colors"
114
- >
115
- check publishing readiness
116
- </button>
117
- )}
118
-
119
- {preflight && (
120
- <div className="border-border rounded border p-4 space-y-2">
121
- <h3 className="text-accent text-xs font-bold uppercase tracking-wider">Preflight</h3>
122
- <div className="flex justify-between text-xs">
123
- <span className="text-muted">Wallet</span>
124
- <span className="text-foreground font-mono text-[10px]">{preflight.address?.slice(0, 10)}...</span>
125
- </div>
126
- <div className="flex justify-between text-xs">
127
- <span className="text-muted">ETH Balance</span>
128
- <span className={preflight.hasEnoughEth ? "text-accent" : "text-error"}>{preflight.ethBalance ? formatEth(preflight.ethBalance) : "0"} ETH</span>
129
- </div>
130
- {preflight.creationFee && BigInt(preflight.creationFee) > 0n && (
131
- <div className="flex justify-between text-xs">
132
- <span className="text-muted">Creation Fee</span>
133
- <span className="text-foreground">{formatEth(preflight.creationFee)} ETH</span>
134
- </div>
135
- )}
136
- {preflight.requiredBalance && (
137
- <div className="flex justify-between text-xs">
138
- <span className="text-muted">Required (fee + gas)</span>
139
- <span className="text-foreground">~{formatEth(preflight.requiredBalance)} ETH</span>
140
- </div>
141
- )}
142
- <div className="flex justify-between text-xs">
143
- <span className="text-muted">Filebase (IPFS)</span>
144
- <span className={preflight.hasFilebase ? "text-accent" : "text-error"}>{preflight.hasFilebase ? "configured" : "missing"}</span>
145
- </div>
146
- {preflight.error && <p className="text-error text-xs">{preflight.error}</p>}
147
- </div>
148
- )}
149
-
150
- {/* Draft list */}
151
- {drafts.length === 0 && (
152
- <p className="text-muted text-xs">no drafts ready for publishing — finalize a story from the chat first</p>
153
- )}
154
-
155
- {drafts.map((draft) => (
156
- <div key={draft.id} className="border-border rounded border p-4 space-y-3">
157
- <div className="flex items-center justify-between">
158
- <h3 className="text-foreground text-sm font-medium">{draft.title}</h3>
159
- <span className="text-accent text-[10px]">{draft.genre}</span>
160
- </div>
161
-
162
- {selected?.id === draft.id ? (
163
- <div className="space-y-3">
164
- {/* Full preview */}
165
- <div className="bg-surface max-h-80 overflow-y-auto rounded p-3">
166
- <div className="prose prose-xs max-w-none text-xs leading-relaxed">
167
- <Markdown>{draft.content}</Markdown>
168
- </div>
169
- </div>
170
-
171
- {/* Publish button */}
172
- {preflight?.ready && !publishing && (
173
- <div className="space-y-2">
174
- <p className="text-muted text-[10px]">
175
- This will upload to IPFS and publish on-chain to Base via your OWS wallet.
176
- </p>
177
- <button
178
- onClick={() => handlePublish(draft)}
179
- className="border-accent text-accent hover:bg-accent/10 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
180
- >
181
- publish to PlotLink
182
- </button>
183
- </div>
184
- )}
185
-
186
- {!preflight?.ready && (
187
- <p className="text-error text-xs">publishing not ready — check preflight above</p>
188
- )}
189
-
190
- <button onClick={() => setSelected(null)} className="text-muted hover:text-foreground text-xs">
191
- collapse
192
- </button>
193
- </div>
194
- ) : (
195
- <div className="flex items-center justify-between">
196
- <p className="text-muted text-xs truncate max-w-[60%]">{draft.content.slice(0, 100)}...</p>
197
- <button
198
- onClick={() => { setSelected(draft); if (!preflight) checkPreflight(); }}
199
- className="border-border text-muted hover:border-accent hover:text-accent rounded border px-3 py-1 text-[10px] font-medium transition-colors"
200
- >
201
- preview &amp; publish
202
- </button>
203
- </div>
204
- )}
205
- </div>
206
- ))}
207
-
208
- {/* Progress */}
209
- {progress && (
210
- <div className={`rounded border p-4 space-y-2 ${progress.step === "error" ? "border-red-700/30" : progress.step === "done" ? "border-green-700/30" : "border-accent/30"}`}>
211
- <div className="flex items-center gap-2">
212
- {progress.step !== "done" && progress.step !== "error" && (
213
- <span className="text-accent animate-pulse text-xs">&#x25CF;</span>
214
- )}
215
- <span className={`text-xs font-medium ${progress.step === "error" ? "text-red-700" : progress.step === "done" ? "text-accent" : "text-accent"}`}>
216
- {progress.message}
217
- </span>
218
- </div>
219
- {progress.txHash && (
220
- <div className="text-xs">
221
- <span className="text-muted">tx: </span>
222
- <a href={`https://basescan.org/tx/${progress.txHash}`} target="_blank" rel="noopener noreferrer" className="text-accent font-mono text-[10px] underline">
223
- {progress.txHash.slice(0, 14)}...
224
- </a>
225
- </div>
226
- )}
227
- {progress.contentCid && (
228
- <div className="text-xs">
229
- <span className="text-muted">IPFS: </span>
230
- <span className="text-foreground font-mono text-[10px]">{progress.contentCid}</span>
231
- </div>
232
- )}
233
- {progress.storylineId && (
234
- <div className="text-xs">
235
- <span className="text-muted">story: </span>
236
- <a href={`https://plotlink.xyz/story/${progress.storylineId}`} target="_blank" rel="noopener noreferrer" className="text-accent underline">
237
- plotlink.xyz/story/{progress.storylineId}
238
- </a>
239
- </div>
240
- )}
241
- </div>
242
- )}
243
- </div>
244
- );
245
- }
@@ -1,2 +0,0 @@
1
- /*! tailwindcss v4.2.2 | 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-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-mono-font-family:var(--font-mono)}}@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;font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent;font-family:Geist Mono,ui-monospace,monospace;line-height:1.5}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;-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}}@layer components;@layer utilities{.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.h-screen{height:100vh}.w-full{width:100%}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-3{gap:calc(var(--spacing) * 3)}: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)))}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-accent{border-color:var(--accent)}.border-border{border-color:var(--border)}.bg-surface{background-color:var(--bg-surface)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--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-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.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)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-accent{color:var(--accent)}.text-error{color:var(--error)}.text-foreground{color:var(--text)}.text-muted{color:var(--text-muted)}.uppercase{text-transform:uppercase}.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))}.outline-none{--tw-outline-style:none;outline-style:none}.placeholder\:text-muted\/50::placeholder{color:var(--text-muted)}@supports (color:color-mix(in lab, red, red)){.placeholder\:text-muted\/50::placeholder{color:color-mix(in oklab, var(--text-muted) 50%, transparent)}}@media (hover:hover){.hover\:bg-accent\/10:hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/10:hover{background-color:color-mix(in oklab, var(--accent) 10%, transparent)}}.hover\:text-foreground:hover{color:var(--text)}}.focus\:border-accent:focus{border-color:var(--accent)}.disabled\:opacity-40:disabled{opacity:.4}}:root{--bg:#0a0a0a;--bg-surface:#141414;--text:#e0e0e0;--text-muted:#666;--accent:#0f8;--accent-dim:#00cc6a;--border:#2a2a2a;--error:#f44}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Geist Mono,ui-monospace,monospace}::selection{background:var(--accent);color:var(--bg)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}