leanlab 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,335 @@
1
+ """The coding board — a dashboard for a coding lab's tasks, status, traces, and PLAYBOOK.
2
+
3
+ Overview (`/`) lists task cards from `.leanlab/worktrees/*`, `.leanlab/coding-results.jsonl`,
4
+ and `.leanlab/PLAYBOOK.md`. A task detail (`/?task=<slug>`) shows the build TIMELINE (from the
5
+ structured event log written by `build`) and the live agent CHAT (parsed from the worktree's
6
+ Claude transcripts). `coding_state` / `task_detail` / the renderers are pure and testable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import mimetypes
13
+ import sys
14
+ import time
15
+ import webbrowser
16
+ from datetime import datetime, timezone
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+ from urllib.parse import parse_qs, urlparse
20
+
21
+ from .playbook import read_playbook
22
+
23
+ _STATUS = {"merged": "#3fb950", "failed": "#f85149", "spec'd": "#d29922", "building": "#58a6ff"}
24
+
25
+
26
+ # --- structured event log (the timeline) ------------------------------------
27
+ def _events_path(repo, slug):
28
+ return Path(repo) / ".leanlab" / "events" / f"{slug}.jsonl"
29
+
30
+
31
+ def log_event(repo, slug, rec):
32
+ """Append one build event for a task (used by spec/build to feed the timeline)."""
33
+ p = _events_path(repo, slug)
34
+ p.parent.mkdir(parents=True, exist_ok=True)
35
+ with p.open("a") as f:
36
+ f.write(json.dumps({**rec, "ts": datetime.now(timezone.utc).isoformat()}) + "\n")
37
+
38
+
39
+ def read_events(repo, slug):
40
+ p = _events_path(repo, slug)
41
+ if not p.exists():
42
+ return []
43
+ out = []
44
+ for line in p.read_text().splitlines():
45
+ line = line.strip()
46
+ if line:
47
+ try:
48
+ out.append(json.loads(line))
49
+ except ValueError:
50
+ pass
51
+ return out
52
+
53
+
54
+ # --- agent chat (transcripts) ----------------------------------------------
55
+ def _transcript_dir(repo, slug):
56
+ """The Claude transcript dir for a task's worktree, or None."""
57
+ wt = Path(repo) / ".leanlab" / "worktrees" / slug
58
+ base = Path.home() / ".claude" / "projects"
59
+ if not base.is_dir():
60
+ return None
61
+ d = base / str(wt.resolve()).replace("/", "-")
62
+ if d.is_dir():
63
+ return d
64
+ matches = sorted(base.glob(f"*worktrees-{slug}")) # specific tail — avoids other projects
65
+ return matches[-1] if matches else None
66
+
67
+
68
+ _SESSIONS_CACHE = {}
69
+
70
+
71
+ def _parsed_sessions(d):
72
+ """[(path, events)] for every session in `d`, oldest first — parsed ONCE and cached by
73
+ the dir's (name, mtime) signature. The SSE loop polls task detail every second; without
74
+ this each poll would re-parse every transcript file."""
75
+ sessions = sorted(d.glob("*.jsonl"))
76
+ sig = tuple((p.name, p.stat().st_mtime) for p in sessions)
77
+ cached = _SESSIONS_CACHE.get(str(d))
78
+ if cached and cached[0] == sig:
79
+ return cached[1]
80
+ from ..monitor import parse_session # reuse the metric dashboard's parser
81
+ parsed = [(p, parse_session(p)[1])
82
+ for p in sorted(sessions, key=lambda p: p.stat().st_mtime)]
83
+ _SESSIONS_CACHE[str(d)] = (sig, parsed)
84
+ return parsed
85
+
86
+
87
+ def _task_transcript_events(repo, slug):
88
+ """Every agent session's events for a task, oldest first — one session per claude call,
89
+ so all build attempts and reviews show, not just the latest. A `divider` event marks
90
+ each session boundary."""
91
+ d = _transcript_dir(repo, slug)
92
+ if not d:
93
+ return []
94
+ runs = [events for _p, events in _parsed_sessions(d) if events]
95
+ out = []
96
+ for i, events in enumerate(runs, 1):
97
+ tok = sum((e.get("in_tok") or 0) + (e.get("out_tok") or 0) for e in events)
98
+ out.append({"kind": "divider", "text": f"session {i}/{len(runs)}", "tokens": tok})
99
+ out.extend(events)
100
+ return out
101
+
102
+
103
+ def _task_usage(repo, slug):
104
+ """Total tokens + cost across ALL agent sessions for a task."""
105
+ d = _transcript_dir(repo, slug)
106
+ if not d:
107
+ return {"tokens": 0, "cost": 0.0}
108
+ tokens = cost = 0
109
+ for _p, events in _parsed_sessions(d):
110
+ for e in events:
111
+ tokens += (e.get("in_tok") or 0) + (e.get("out_tok") or 0)
112
+ cost += e.get("cost") or 0
113
+ return {"tokens": tokens, "cost": round(cost, 4)}
114
+
115
+
116
+ # --- state builders ---------------------------------------------------------
117
+ def _results(repo):
118
+ p = Path(repo) / ".leanlab" / "coding-results.jsonl"
119
+ latest = {}
120
+ if p.exists():
121
+ for line in p.read_text().splitlines():
122
+ line = line.strip()
123
+ if line:
124
+ try:
125
+ r = json.loads(line)
126
+ latest[r["slug"]] = r
127
+ except (ValueError, KeyError):
128
+ pass
129
+ return latest
130
+
131
+
132
+ def _spec_summary(d):
133
+ spec = (d / "SPEC.md").read_text() if (d / "SPEC.md").exists() else ""
134
+ lines = [ln.strip() for ln in spec.splitlines() if ln.strip()]
135
+ return next((ln for ln in lines if not ln.startswith("#")), lines[0] if lines else "(no spec)")
136
+
137
+
138
+ def _task_slugs(repo):
139
+ """Every task we know about — live worktrees PLUS finished ones recorded in results/events.
140
+
141
+ A task's worktree is removed once it merges and is cleaned, so the worktree dir alone
142
+ forgets completed work. Union the durable records so the board keeps the full history.
143
+ """
144
+ repo = Path(repo)
145
+ wtroot = repo / ".leanlab" / "worktrees"
146
+ live = [d.name for d in sorted(wtroot.iterdir()) if d.is_dir()] if wtroot.is_dir() else []
147
+ durable = [*_results(repo)] + [f.stem for f in sorted((repo / ".leanlab" / "events").glob("*.jsonl"))]
148
+ seen = set(live)
149
+ archived = []
150
+ for slug in durable:
151
+ if slug not in seen:
152
+ seen.add(slug)
153
+ archived.append(slug)
154
+ return live, archived
155
+
156
+
157
+ def _task_status(repo, slug, by_slug=None):
158
+ """A task's status: the result row wins; otherwise inferred from the event log."""
159
+ by_slug = _results(repo) if by_slug is None else by_slug
160
+ r = by_slug.get(slug)
161
+ if r:
162
+ return "merged" if r.get("merged") else "failed"
163
+ evs = read_events(repo, slug)
164
+ if any(e.get("event") == "merged" and e.get("merged") for e in evs):
165
+ return "merged"
166
+ if any(e.get("event") == "gaveup" for e in evs):
167
+ return "failed"
168
+ return "spec'd"
169
+
170
+
171
+ def coding_state(repo) -> dict:
172
+ repo = Path(repo)
173
+ by_slug = _results(repo)
174
+ wtroot = repo / ".leanlab" / "worktrees"
175
+ live, archived = _task_slugs(repo)
176
+ tasks = []
177
+ for slug in live + archived:
178
+ d = wtroot / slug
179
+ is_live = d.is_dir()
180
+ r = by_slug.get(slug)
181
+ status = _task_status(repo, slug, by_slug)
182
+ attempts = (r or {}).get("attempts")
183
+ if attempts is None: # recover the count from the event log
184
+ n = sum(1 for e in read_events(repo, slug) if e.get("event") == "attempt")
185
+ attempts = n or None
186
+ if is_live:
187
+ spec = _spec_summary(d)
188
+ else:
189
+ spec = "merged — worktree cleaned" if status == "merged" else "worktree cleaned"
190
+ u = _task_usage(repo, slug)
191
+ tasks.append({"slug": slug, "status": status, "branch": f"leanlab/{slug}",
192
+ "attempts": attempts, "spec": spec, "archived": not is_live,
193
+ "tokens": u["tokens"], "cost": u["cost"]})
194
+ merged = sum(t["status"] == "merged" for t in tasks)
195
+ failed = sum(t["status"] == "failed" for t in tasks)
196
+ decided = merged + failed
197
+ totals = {"tasks": len(tasks), "merged": merged, "failed": failed,
198
+ "open": sum(t["status"] == "spec'd" for t in tasks),
199
+ "tokens": sum(t["tokens"] for t in tasks),
200
+ "cost": round(sum(t["cost"] for t in tasks), 4),
201
+ "success": round(100 * merged / decided) if decided else None}
202
+ return {"tasks": tasks, "playbook": read_playbook(repo), "totals": totals}
203
+
204
+
205
+ def task_detail(repo, slug) -> dict:
206
+ repo = Path(repo)
207
+ wt = repo / ".leanlab" / "worktrees" / slug
208
+ usage = _task_usage(repo, slug) # tokens + cost across all the task's sessions
209
+ return {"slug": slug, "exists": wt.is_dir(), "status": _task_status(repo, slug),
210
+ "spec": _spec_summary(wt) if wt.is_dir() else "",
211
+ "timeline": read_events(repo, slug), "stream": _task_transcript_events(repo, slug),
212
+ "cost": usage["cost"], "tokens": usage["tokens"]}
213
+
214
+
215
+
216
+ # --- live SPA (SSE) ---------------------------------------------------------
217
+ def overview_state(repo) -> dict:
218
+ return {"lab": Path(repo).resolve().name, **coding_state(repo)}
219
+
220
+
221
+ _DIST = Path(__file__).resolve().parent / "board_dist" # built React app (see frontend/)
222
+
223
+ _NOT_BUILT_HTML = (
224
+ "<!doctype html><meta charset='utf-8'>"
225
+ "<body style='font:14px system-ui;background:#0a0a0a;color:#e6edf3;padding:40px'>"
226
+ "<h2>Board UI not built</h2>"
227
+ "<p>Run <code>cd frontend &amp;&amp; npm install &amp;&amp; npm run build</code> to compile it.</p>"
228
+ "</body>"
229
+ )
230
+
231
+
232
+ def _asset(rel):
233
+ """Resolve a request path to a file inside the built board, rejecting path traversal."""
234
+ rel = (rel or "").lstrip("/") or "index.html"
235
+ f = (_DIST / rel).resolve()
236
+ if _DIST.resolve() not in f.parents:
237
+ return None
238
+ return f if f.is_file() else None
239
+
240
+
241
+ class _QuietServer(ThreadingHTTPServer):
242
+ daemon_threads = True
243
+
244
+ def handle_error(self, request, client_address):
245
+ exc = sys.exc_info()[1]
246
+ if not isinstance(exc, (ConnectionResetError, BrokenPipeError, TimeoutError)):
247
+ super().handle_error(request, client_address)
248
+
249
+
250
+ def serve_board(repo, port=8766, open_browser=True):
251
+ repo = Path(repo).resolve()
252
+
253
+ class Handler(BaseHTTPRequestHandler):
254
+ protocol_version = "HTTP/1.1"
255
+
256
+ def handle(self):
257
+ try:
258
+ super().handle()
259
+ except (ConnectionResetError, BrokenPipeError, TimeoutError):
260
+ pass
261
+
262
+ def _send(self, body, ctype="application/json"):
263
+ data = body.encode() if isinstance(body, str) else body
264
+ self.send_response(200)
265
+ self.send_header("Content-Type", ctype)
266
+ self.send_header("Content-Length", str(len(data)))
267
+ self.end_headers()
268
+ self.wfile.write(data)
269
+
270
+ def _sse(self, event, payload):
271
+ self.wfile.write(f"event: {event}\ndata: {payload}\n\n".encode())
272
+ self.wfile.flush()
273
+
274
+ def stream(self, slug):
275
+ self.send_response(200)
276
+ self.send_header("Content-Type", "text/event-stream")
277
+ self.send_header("Cache-Control", "no-cache")
278
+ self.end_headers()
279
+ last_state = last_task = None
280
+ last_ping = 0.0
281
+ try:
282
+ while True:
283
+ st = json.dumps(overview_state(repo))
284
+ if st != last_state:
285
+ self._sse("state", st)
286
+ last_state = st
287
+ if slug:
288
+ td = json.dumps(task_detail(repo, slug))
289
+ if td != last_task:
290
+ self._sse("task", td)
291
+ last_task = td
292
+ if time.time() - last_ping > 15:
293
+ self.wfile.write(b": ping\n\n")
294
+ self.wfile.flush()
295
+ last_ping = time.time()
296
+ time.sleep(1)
297
+ except (BrokenPipeError, ConnectionResetError, OSError):
298
+ return
299
+
300
+ def do_GET(self):
301
+ route = urlparse(self.path)
302
+ q = parse_qs(route.query)
303
+ try:
304
+ if route.path == "/api/stream":
305
+ self.stream(q.get("task", [""])[0])
306
+ return
307
+ if route.path == "/api/state":
308
+ self._send(json.dumps(overview_state(repo)))
309
+ return
310
+ if route.path == "/api/task":
311
+ self._send(json.dumps(task_detail(repo, q.get("task", [""])[0])))
312
+ return
313
+ # static: the built React app. Unknown paths fall back to index.html (SPA).
314
+ f = _asset(route.path) or _asset("index.html")
315
+ if f is None:
316
+ self._send(_NOT_BUILT_HTML, "text/html; charset=utf-8")
317
+ return
318
+ ctype = mimetypes.guess_type(str(f))[0] or "application/octet-stream"
319
+ if ctype.startswith("text/") or ctype == "image/svg+xml":
320
+ ctype += "; charset=utf-8"
321
+ self._send(f.read_bytes(), ctype)
322
+ except (BrokenPipeError, ConnectionResetError):
323
+ pass
324
+
325
+ def log_message(self, *a):
326
+ pass
327
+
328
+ url = f"http://127.0.0.1:{port}"
329
+ print(f"leanlab coding board: {url}")
330
+ if open_browser:
331
+ webbrowser.open(url)
332
+ try:
333
+ _QuietServer(("127.0.0.1", port), Handler).serve_forever()
334
+ except KeyboardInterrupt:
335
+ print("\nstopped.")
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html,body,#root{height:100%}body{--tw-bg-opacity: 1;background-color:rgb(10 10 10 / var(--tw-bg-opacity, 1));font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;--tw-text-opacity: 1;color:rgb(230 237 243 / var(--tw-text-opacity, 1));margin:0;font-size:14px;line-height:1.5}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:6px}.sticky{position:sticky}.top-0{top:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3\.5{margin-bottom:.875rem}.ml-auto{margin-left:auto}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.block{display:block}.flex{display:flex}.table{display:table}.grid{display:grid}.h-4{height:1rem}.h-\[440px\]{height:440px}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[260px\]{max-height:260px}.max-h-\[300px\]{max-height:300px}.max-h-\[340px\]{max-height:340px}.min-h-0{min-height:0px}.min-h-\[32px\]{min-height:32px}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[1360px\]{max-width:1360px}.max-w-\[380px\]{max-width:380px}.flex-1{flex:1 1 0%}.flex-\[2\]{flex:2}.border-collapse{border-collapse:collapse}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-\[\#1f3a5f\]{--tw-border-opacity: 1;border-color:rgb(31 58 95 / var(--tw-border-opacity, 1))}.border-\[\#332a4a\]{--tw-border-opacity: 1;border-color:rgb(51 42 74 / var(--tw-border-opacity, 1))}.border-accent{--tw-border-opacity: 1;border-color:rgb(88 166 255 / var(--tw-border-opacity, 1))}.border-amber{--tw-border-opacity: 1;border-color:rgb(210 153 34 / var(--tw-border-opacity, 1))}.border-amber\/40{border-color:#d2992266}.border-good{--tw-border-opacity: 1;border-color:rgb(63 185 80 / var(--tw-border-opacity, 1))}.border-line{--tw-border-opacity: 1;border-color:rgb(38 38 38 / var(--tw-border-opacity, 1))}.border-purple{--tw-border-opacity: 1;border-color:rgb(163 113 247 / var(--tw-border-opacity, 1))}.bg-\[\#0e1216\]{--tw-bg-opacity: 1;background-color:rgb(14 18 22 / var(--tw-bg-opacity, 1))}.bg-\[\#0e1a2b\]{--tw-bg-opacity: 1;background-color:rgb(14 26 43 / var(--tw-bg-opacity, 1))}.bg-\[\#10243b\]{--tw-bg-opacity: 1;background-color:rgb(16 36 59 / var(--tw-bg-opacity, 1))}.bg-\[\#16121f\]{--tw-bg-opacity: 1;background-color:rgb(22 18 31 / var(--tw-bg-opacity, 1))}.bg-\[\#22262e\]{--tw-bg-opacity: 1;background-color:rgb(34 38 46 / var(--tw-bg-opacity, 1))}.bg-accent{--tw-bg-opacity: 1;background-color:rgb(88 166 255 / var(--tw-bg-opacity, 1))}.bg-amber{--tw-bg-opacity: 1;background-color:rgb(210 153 34 / var(--tw-bg-opacity, 1))}.bg-amber\/10{background-color:#d299221a}.bg-bad{--tw-bg-opacity: 1;background-color:rgb(248 81 73 / var(--tw-bg-opacity, 1))}.bg-bad\/10{background-color:#f851491a}.bg-good{--tw-bg-opacity: 1;background-color:rgb(63 185 80 / var(--tw-bg-opacity, 1))}.bg-good\/10{background-color:#3fb9501a}.bg-panel{--tw-bg-opacity: 1;background-color:rgb(20 20 20 / var(--tw-bg-opacity, 1))}.bg-panel2{--tw-bg-opacity: 1;background-color:rgb(27 27 27 / var(--tw-bg-opacity, 1))}.bg-panel2\/40{background-color:#1b1b1b66}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:ui-monospace,Menlo,monospace}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[26px\]{font-size:26px}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.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)}.leading-snug{line-height:1.375}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-accent{--tw-text-opacity: 1;color:rgb(88 166 255 / var(--tw-text-opacity, 1))}.text-amber{--tw-text-opacity: 1;color:rgb(210 153 34 / var(--tw-text-opacity, 1))}.text-bad{--tw-text-opacity: 1;color:rgb(248 81 73 / var(--tw-text-opacity, 1))}.text-good{--tw-text-opacity: 1;color:rgb(63 185 80 / var(--tw-text-opacity, 1))}.text-ink{--tw-text-opacity: 1;color:rgb(230 237 243 / var(--tw-text-opacity, 1))}.text-muted{--tw-text-opacity: 1;color:rgb(139 148 158 / var(--tw-text-opacity, 1))}.text-purple{--tw-text-opacity: 1;color:rgb(163 113 247 / var(--tw-text-opacity, 1))}.opacity-100{opacity:1}.opacity-40{opacity:.4}.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)}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.before\:h-px:before{content:var(--tw-content);height:1px}.before\:flex-1:before{content:var(--tw-content);flex:1 1 0%}.before\:bg-line:before{content:var(--tw-content);--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.before\:content-\[\'\'\]:before{--tw-content: "";content:var(--tw-content)}.after\:h-px:after{content:var(--tw-content);height:1px}.after\:flex-1:after{content:var(--tw-content);flex:1 1 0%}.after\:bg-line:after{content:var(--tw-content);--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.after\:content-\[\'\'\]:after{--tw-content: "";content:var(--tw-content)}.hover\:border-accent:hover{--tw-border-opacity: 1;border-color:rgb(88 166 255 / var(--tw-border-opacity, 1))}.hover\:bg-panel2:hover{--tw-bg-opacity: 1;background-color:rgb(27 27 27 / var(--tw-bg-opacity, 1))}.hover\:text-ink:hover{--tw-text-opacity: 1;color:rgb(230 237 243 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:768px){.md\:flex-row{flex-direction:row}.md\:gap-0{gap:0px}}@media(min-width:1024px){.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}}