@netnodeag/kraftwerk 0.2.0 → 0.3.1
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/README.md +10 -2
- package/dist/cli/kraftwerk.js +10 -0
- package/dist/cli/ui.d.ts +4 -0
- package/dist/cli/ui.js +75 -0
- package/inspector/README.md +67 -0
- package/inspector/app/api/runs/[id]/file/route.ts +56 -0
- package/inspector/app/api/runs/[id]/route.ts +15 -0
- package/inspector/app/api/runs/[id]/stop/route.ts +15 -0
- package/inspector/app/api/runs/route.ts +9 -0
- package/inspector/app/api/workflows/[slug]/route.ts +11 -0
- package/inspector/app/api/workflows/[slug]/run/route.ts +37 -0
- package/inspector/app/api/workflows/route.ts +8 -0
- package/inspector/app/globals.css +460 -0
- package/inspector/app/layout.tsx +54 -0
- package/inspector/app/page.tsx +16 -0
- package/inspector/app/runs/[id]/page.tsx +6 -0
- package/inspector/app/runs/[id]/run-detail.tsx +343 -0
- package/inspector/app/shared.tsx +81 -0
- package/inspector/app/theme-toggle.tsx +19 -0
- package/inspector/app/workflows/[slug]/page.tsx +6 -0
- package/inspector/app/workflows/[slug]/workflow-view.tsx +271 -0
- package/inspector/app/workflows/page.tsx +43 -0
- package/inspector/lib/runner.ts +78 -0
- package/inspector/lib/runs.ts +313 -0
- package/inspector/lib/workflows.ts +240 -0
- package/inspector/next.config.ts +5 -0
- package/inspector/package-lock.json +982 -0
- package/inspector/package.json +24 -0
- package/inspector/tsconfig.json +21 -0
- package/package.json +29 -6
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import type { RunDetail, RunListItem, PhaseView, FileView } from "@/lib/runs";
|
|
6
|
+
import {
|
|
7
|
+
usePoll,
|
|
8
|
+
fmtDuration,
|
|
9
|
+
fmtCost,
|
|
10
|
+
fmtTokens,
|
|
11
|
+
fmtSize,
|
|
12
|
+
fmtWhen,
|
|
13
|
+
Lamp,
|
|
14
|
+
StatusWord,
|
|
15
|
+
Elapsed,
|
|
16
|
+
} from "../../shared";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Full-width runs screen: a sidebar with every run (latest on top) for
|
|
20
|
+
* quick switching, and a main area with two tabs — "run" (phase timeline)
|
|
21
|
+
* and "artifacts" (file browser with a large viewer). Finished runs open
|
|
22
|
+
* on artifacts, running ones on the timeline.
|
|
23
|
+
*/
|
|
24
|
+
export function RunsScreen({ id }: { id: string }) {
|
|
25
|
+
const data = usePoll<{ runs: RunListItem[] }>("/api/runs", true);
|
|
26
|
+
const runs = data?.runs ?? [];
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="runs-screen">
|
|
30
|
+
<aside className="runs-side">
|
|
31
|
+
<div className="side-head">
|
|
32
|
+
<span className="microlabel">runs</span>
|
|
33
|
+
<span className="spacer" />
|
|
34
|
+
<span className="microlabel num">{runs.length}</span>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="side-list">
|
|
37
|
+
{runs.map((r) => (
|
|
38
|
+
<SideRow key={r.id} r={r} active={r.id === id} />
|
|
39
|
+
))}
|
|
40
|
+
{data && runs.length === 0 && <div className="viewer-note">no runs yet</div>}
|
|
41
|
+
</div>
|
|
42
|
+
</aside>
|
|
43
|
+
<div className="runs-main">
|
|
44
|
+
<RunDetailView key={id} id={id} />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function SideRow({ r, active }: { r: RunListItem; active: boolean }) {
|
|
51
|
+
return (
|
|
52
|
+
<Link href={`/runs/${r.id}`} className={`side-row ${active ? "active" : ""}`}>
|
|
53
|
+
<Lamp status={r.status} />
|
|
54
|
+
<div className="side-row-body">
|
|
55
|
+
<div className="side-row-top">
|
|
56
|
+
<span className="side-wf">{r.workflow ?? "unknown"}</span>
|
|
57
|
+
<span className="side-when num">{fmtWhen(r.startedAt)}</span>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="side-row-sub">
|
|
60
|
+
<span className="side-req" title={r.request}>
|
|
61
|
+
{r.status === "running" && r.currentPhase ? `▸ ${r.currentPhase}` : (r.request ?? "")}
|
|
62
|
+
</span>
|
|
63
|
+
<span className="side-meta num">
|
|
64
|
+
{r.status === "running" ? <Elapsed since={r.startedAt} /> : fmtDuration(r.durationMs)}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</Link>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ---------- detail ---------- */
|
|
73
|
+
|
|
74
|
+
function RunDetailView({ id }: { id: string }) {
|
|
75
|
+
const run = usePoll<RunDetail>(`/api/runs/${id}`, true);
|
|
76
|
+
const live = run?.status === "running";
|
|
77
|
+
const [tab, setTab] = useState<"run" | "artifacts" | null>(null);
|
|
78
|
+
const [stopping, setStopping] = useState(false);
|
|
79
|
+
|
|
80
|
+
if (!run) return <div className="empty">loading…</div>;
|
|
81
|
+
const sandboxed = run.files.some((f) => f.name === "runner.json");
|
|
82
|
+
const active = tab ?? (live ? "run" : "artifacts");
|
|
83
|
+
|
|
84
|
+
async function stop() {
|
|
85
|
+
setStopping(true);
|
|
86
|
+
await fetch(`/api/runs/${id}/stop`, { method: "POST" }).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
<div className="detail-head">
|
|
92
|
+
<Lamp status={run.status} />
|
|
93
|
+
<h1>
|
|
94
|
+
{run.workflow ? (
|
|
95
|
+
<a href={`/workflows/${encodeURIComponent(run.workflow)}`} title="open workflow">
|
|
96
|
+
{run.workflow}
|
|
97
|
+
</a>
|
|
98
|
+
) : (
|
|
99
|
+
"unknown workflow"
|
|
100
|
+
)}
|
|
101
|
+
</h1>
|
|
102
|
+
<StatusWord status={run.status} />
|
|
103
|
+
{sandboxed && <span className="chip sandbox-chip">⬒ sandbox</span>}
|
|
104
|
+
<span className="rid">{id.replace(/^run-/, "")}</span>
|
|
105
|
+
{live && sandboxed && (
|
|
106
|
+
<button className="stop-btn" onClick={stop} disabled={stopping}>
|
|
107
|
+
{stopping ? "stopping…" : "■ stop"}
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
<span className="spacer" />
|
|
111
|
+
<nav className="tabs">
|
|
112
|
+
<button className={active === "run" ? "active" : ""} onClick={() => setTab("run")}>
|
|
113
|
+
run
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
className={active === "artifacts" ? "active" : ""}
|
|
117
|
+
onClick={() => setTab("artifacts")}
|
|
118
|
+
>
|
|
119
|
+
artifacts <span className="num">({run.files.length})</span>
|
|
120
|
+
</button>
|
|
121
|
+
</nav>
|
|
122
|
+
</div>
|
|
123
|
+
{run.request && <p className="detail-req">{run.request}</p>}
|
|
124
|
+
|
|
125
|
+
{active === "run" ? (
|
|
126
|
+
<RunTab run={run} live={live} />
|
|
127
|
+
) : (
|
|
128
|
+
<ArtifactsTab id={id} files={run.files} live={live} />
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function RunTab({ run, live }: { run: RunDetail; live: boolean }) {
|
|
135
|
+
return (
|
|
136
|
+
<div className="run-tab">
|
|
137
|
+
<div className="statgrid">
|
|
138
|
+
<div className="stat">
|
|
139
|
+
<div className="microlabel">phases</div>
|
|
140
|
+
<div className="v num">
|
|
141
|
+
{run.phasesDone}
|
|
142
|
+
{run.phasesTotal != null ? ` / ${run.phasesTotal}` : ""}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="stat">
|
|
146
|
+
<div className="microlabel">duration</div>
|
|
147
|
+
<div className="v num">
|
|
148
|
+
{live ? <Elapsed since={run.startedAt} /> : fmtDuration(run.durationMs)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="stat">
|
|
152
|
+
<div className="microlabel">cost</div>
|
|
153
|
+
<div className="v num">{fmtCost(run.costUsd ?? sumCost(run.phases))}</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="stat">
|
|
156
|
+
<div className="microlabel">tokens in / out</div>
|
|
157
|
+
<div className="v num">
|
|
158
|
+
{fmtTokens(sum(run.phases, "tokensIn"))} / {fmtTokens(sum(run.phases, "tokensOut"))}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<section className="panel">
|
|
164
|
+
<div className="panel-head">
|
|
165
|
+
<span className="microlabel">phase timeline</span>
|
|
166
|
+
<span className="spacer" />
|
|
167
|
+
{live && (
|
|
168
|
+
<span className="live-note">
|
|
169
|
+
<span className="lamp running" /> live · polling
|
|
170
|
+
</span>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
<div className="phases">
|
|
174
|
+
{run.phases.map((p) => (
|
|
175
|
+
<PhaseRow key={p.phase} p={p} />
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</section>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sum(phases: PhaseView[], k: "tokensIn" | "tokensOut"): number {
|
|
184
|
+
return phases.reduce((a, p) => a + (p[k] ?? 0), 0);
|
|
185
|
+
}
|
|
186
|
+
function sumCost(phases: PhaseView[]): number {
|
|
187
|
+
return phases.reduce((a, p) => a + (p.costUsd ?? 0), 0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function PhaseRow({ p }: { p: PhaseView }) {
|
|
191
|
+
const failedGates = p.gates.filter((g) => !g.passed);
|
|
192
|
+
return (
|
|
193
|
+
<div className="phase-row">
|
|
194
|
+
<Lamp status={p.status} />
|
|
195
|
+
<div className="phase-top">
|
|
196
|
+
<span className="phase-name">{p.phase}</span>
|
|
197
|
+
{p.kind === "agent" ? (
|
|
198
|
+
<span className="chip agent">
|
|
199
|
+
{p.agent}
|
|
200
|
+
{p.model ? ` · ${p.model}` : ""}
|
|
201
|
+
</span>
|
|
202
|
+
) : (
|
|
203
|
+
<span className="chip">script</span>
|
|
204
|
+
)}
|
|
205
|
+
<span className="right num">
|
|
206
|
+
{p.attempts > 1 && <span>att {p.attempts}</span>}
|
|
207
|
+
{p.status === "running" ? (
|
|
208
|
+
<Elapsed since={p.startedAt} />
|
|
209
|
+
) : (
|
|
210
|
+
p.durationMs != null && <span>{fmtDuration(p.durationMs)}</span>
|
|
211
|
+
)}
|
|
212
|
+
{p.kind === "agent" && p.tokensIn != null && (
|
|
213
|
+
<span>
|
|
214
|
+
{fmtTokens(p.tokensIn)} / {fmtTokens(p.tokensOut)}
|
|
215
|
+
</span>
|
|
216
|
+
)}
|
|
217
|
+
{p.costUsd != null && p.costUsd > 0 && <span>{fmtCost(p.costUsd)}</span>}
|
|
218
|
+
</span>
|
|
219
|
+
</div>
|
|
220
|
+
{p.status === "running" && p.lastActivity && (
|
|
221
|
+
<div className="phase-activity">{p.lastActivity}</div>
|
|
222
|
+
)}
|
|
223
|
+
{p.summary && p.status !== "running" && <div className="phase-sub">{p.summary}</div>}
|
|
224
|
+
{p.gates.length > 0 && (
|
|
225
|
+
<div className="gate-line">
|
|
226
|
+
{p.gates.map((g) => (
|
|
227
|
+
<span key={g.gate} className={`gate ${g.passed ? "" : "failed"}`} title={g.failure ?? ""}>
|
|
228
|
+
{g.passed ? "✓" : "✕"} {g.gate}
|
|
229
|
+
</span>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
{failedGates.map((g) => (
|
|
234
|
+
<div key={g.gate} className="gate-fail-msg">
|
|
235
|
+
{g.failure}
|
|
236
|
+
</div>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ---------- artifacts ---------- */
|
|
243
|
+
|
|
244
|
+
const IMG = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
|
|
245
|
+
|
|
246
|
+
function ArtifactsTab({ id, files, live }: { id: string; files: FileView[]; live: boolean }) {
|
|
247
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
248
|
+
|
|
249
|
+
// Default selection: the most interesting artifact.
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (selected || files.length === 0) return;
|
|
252
|
+
const prefer = ["report.html", "recommendation.md", "trace.jsonl"];
|
|
253
|
+
setSelected(prefer.find((n) => files.some((f) => f.name === n)) ?? files[0].name);
|
|
254
|
+
}, [files, selected]);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div className="art-layout">
|
|
258
|
+
<section className="panel art-files">
|
|
259
|
+
<div className="panel-head">
|
|
260
|
+
<span className="microlabel">
|
|
261
|
+
files <span className="num">({files.length})</span>
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
264
|
+
<div className="file-list">
|
|
265
|
+
{files.map((f) => (
|
|
266
|
+
<button
|
|
267
|
+
key={f.name}
|
|
268
|
+
className={`file-row ${selected === f.name ? "active" : ""}`}
|
|
269
|
+
onClick={() => setSelected(f.name)}
|
|
270
|
+
>
|
|
271
|
+
<span className="fname">{f.name}</span>
|
|
272
|
+
<span className="fsize num">{fmtSize(f.size)}</span>
|
|
273
|
+
</button>
|
|
274
|
+
))}
|
|
275
|
+
{files.length === 0 && <div className="viewer-note">no files yet</div>}
|
|
276
|
+
</div>
|
|
277
|
+
</section>
|
|
278
|
+
|
|
279
|
+
<section className="panel art-viewer">
|
|
280
|
+
<div className="panel-head">
|
|
281
|
+
<span className="microlabel">{selected ?? "viewer"}</span>
|
|
282
|
+
<span className="spacer" />
|
|
283
|
+
{selected && (
|
|
284
|
+
<a
|
|
285
|
+
className="open-raw"
|
|
286
|
+
href={`/api/runs/${id}/file?name=${encodeURIComponent(selected)}&raw=1`}
|
|
287
|
+
target="_blank"
|
|
288
|
+
>
|
|
289
|
+
open raw ↗
|
|
290
|
+
</a>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
{selected ? (
|
|
294
|
+
<Viewer id={id} name={selected} live={live} />
|
|
295
|
+
) : (
|
|
296
|
+
<div className="empty">select a file</div>
|
|
297
|
+
)}
|
|
298
|
+
</section>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function Viewer({ id, name, live }: { id: string; name: string; live: boolean }) {
|
|
304
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
305
|
+
const rawUrl = `/api/runs/${id}/file?name=${encodeURIComponent(name)}&raw=1`;
|
|
306
|
+
|
|
307
|
+
if (ext === "html") {
|
|
308
|
+
return (
|
|
309
|
+
<div className="viewer">
|
|
310
|
+
<div className="viewer-body">
|
|
311
|
+
<iframe src={rawUrl} sandbox="allow-scripts" allow="clipboard-write" title={name} />
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (IMG.has(ext)) {
|
|
317
|
+
return (
|
|
318
|
+
<div className="viewer">
|
|
319
|
+
<div className="viewer-body">
|
|
320
|
+
<img src={rawUrl} alt={name} />
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return <TextViewer id={id} name={name} live={live} />;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function TextViewer({ id, name, live }: { id: string; name: string; live: boolean }) {
|
|
329
|
+
const data = usePoll<{ content: string; truncated: boolean; size: number }>(
|
|
330
|
+
`/api/runs/${id}/file?name=${encodeURIComponent(name)}`,
|
|
331
|
+
live
|
|
332
|
+
);
|
|
333
|
+
return (
|
|
334
|
+
<div className="viewer">
|
|
335
|
+
{data?.truncated && (
|
|
336
|
+
<div className="viewer-note">large file — showing the last {fmtSize(400_000)}</div>
|
|
337
|
+
)}
|
|
338
|
+
<div className="viewer-body">
|
|
339
|
+
<pre>{data ? data.content || "(empty)" : "loading…"}</pre>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** Poll a JSON endpoint; tightens the interval while `fast` (live run). */
|
|
6
|
+
export function usePoll<T>(url: string, fast: boolean): T | null {
|
|
7
|
+
const [data, setData] = useState<T | null>(null);
|
|
8
|
+
const urlRef = useRef(url);
|
|
9
|
+
urlRef.current = url;
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let alive = true;
|
|
13
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
14
|
+
const tick = async () => {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(urlRef.current, { cache: "no-store" });
|
|
17
|
+
if (res.ok && alive) setData(await res.json());
|
|
18
|
+
} catch {}
|
|
19
|
+
if (alive) timer = setTimeout(tick, fast ? 1500 : 6000);
|
|
20
|
+
};
|
|
21
|
+
tick();
|
|
22
|
+
return () => {
|
|
23
|
+
alive = false;
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
};
|
|
26
|
+
}, [url, fast]);
|
|
27
|
+
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fmtDuration(ms?: number): string {
|
|
32
|
+
if (ms == null) return "—";
|
|
33
|
+
const s = Math.round(ms / 1000);
|
|
34
|
+
if (s < 60) return `${s}s`;
|
|
35
|
+
const m = Math.floor(s / 60);
|
|
36
|
+
return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function fmtCost(usd?: number): string {
|
|
40
|
+
if (usd == null) return "—";
|
|
41
|
+
return `$${usd.toFixed(2)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function fmtTokens(n?: number): string {
|
|
45
|
+
if (!n) return "0";
|
|
46
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function fmtSize(n: number): string {
|
|
50
|
+
if (n < 1024) return `${n} B`;
|
|
51
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} K`;
|
|
52
|
+
return `${(n / 1024 / 1024).toFixed(1)} M`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function fmtWhen(iso?: string): string {
|
|
56
|
+
if (!iso) return "—";
|
|
57
|
+
const d = new Date(iso);
|
|
58
|
+
const today = new Date();
|
|
59
|
+
const sameDay = d.toDateString() === today.toDateString();
|
|
60
|
+
const hm = d.toTimeString().slice(0, 5);
|
|
61
|
+
return sameDay ? hm : `${d.toISOString().slice(5, 10)} ${hm}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Live elapsed time for a running phase/run. */
|
|
65
|
+
export function Elapsed({ since }: { since?: string }) {
|
|
66
|
+
const [, force] = useState(0);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const t = setInterval(() => force((n) => n + 1), 1000);
|
|
69
|
+
return () => clearInterval(t);
|
|
70
|
+
}, []);
|
|
71
|
+
if (!since) return null;
|
|
72
|
+
return <span className="num">{fmtDuration(Date.now() - Date.parse(since))}</span>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Lamp({ status }: { status: string }) {
|
|
76
|
+
return <span className={`lamp ${status}`} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function StatusWord({ status }: { status: string }) {
|
|
80
|
+
return <span className={`status-word ${status}`}>{status}</span>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export function ThemeToggle() {
|
|
4
|
+
return (
|
|
5
|
+
<button
|
|
6
|
+
className="theme-btn"
|
|
7
|
+
onClick={() => {
|
|
8
|
+
const el = document.documentElement;
|
|
9
|
+
const next = el.dataset.theme === "light" ? "dark" : "light";
|
|
10
|
+
el.dataset.theme = next;
|
|
11
|
+
try {
|
|
12
|
+
localStorage.setItem("kw-theme", next);
|
|
13
|
+
} catch {}
|
|
14
|
+
}}
|
|
15
|
+
>
|
|
16
|
+
day / night
|
|
17
|
+
</button>
|
|
18
|
+
);
|
|
19
|
+
}
|