Files
steep/public/index.html
T
qpismont d74db2096c
ci/woodpecker/push/tests Pipeline was successful
Project website + use Apalis crate for task queue (with redis backend)
instead channel and semaphore queue + upgrade to rust 1.99
2026-10-05 20:44:57 +00:00

645 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b1520">
<link rel="icon" type="image/png" sizes="512x512" href="favicon.png">
<link rel="apple-touch-icon" href="favicon.png">
<meta name="description" content="Steep is an open-source Gitea bot that reviews pull requests in isolated Dev Containers.">
<title>Steep — Let your code steep</title>
<style>
:root {
color-scheme: dark;
--paper: #0b1520;
--surface: #111f2d;
--ink: #edf4f7;
--ink-soft: #c2cfd9;
--muted: #94a8b8;
--line: #263b4d;
--green: #087b5b;
--green-dark: #5bd4a7;
--green-pale: #153a32;
--blue-pale: #1a3044;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
--button-hover: #066449;
--brand-mark-bg: #149b72;
--brand-mark-fg: #07111b;
--step-line: #23604c;
--setup-bg: #07111b;
--radius: 20px;
--content: 1160px;
}
:root[data-theme="light"] {
color-scheme: light;
--paper: #f7f8f5;
--surface: #ffffff;
--ink: #102f50;
--ink-soft: #38536c;
--muted: #718092;
--line: #e1e8e5;
--green: #149b72;
--green-dark: #087b5b;
--green-pale: #e8f5ef;
--blue-pale: #edf3f8;
--shadow: 0 18px 50px rgba(16, 47, 80, 0.08);
--button-hover: #087b5b;
--brand-mark-bg: #102f50;
--brand-mark-fg: #ffffff;
--step-line: #cce8da;
--setup-bg: #102f50;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 90px; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; }
img { display: block; max-width: 100%; }
.container { width: min(var(--content), calc(100% - 48px)); margin-inline: auto; }
.site-header {
position: sticky;
z-index: 5;
top: 0;
border-bottom: 1px solid var(--line);
background: var(--paper);
backdrop-filter: blur(16px);
}
.nav {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
}
.wordmark {
display: inline-flex;
align-items: center;
gap: 11px;
color: var(--ink);
text-decoration: none;
font-size: 18px;
font-weight: 850;
letter-spacing: 0.13em;
}
.wordmark-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 11px;
background: var(--brand-mark-bg);
color: var(--brand-mark-fg);
font-size: 17px;
letter-spacing: 0;
}
.nav-links { display: flex; align-items: center; gap: 30px; }
.nav-links a {
color: var(--ink-soft);
font-size: 14px;
font-weight: 650;
text-decoration: none;
transition: color 160ms ease;
}
.nav-links a:hover { color: var(--green-dark); }
.nav-links .nav-cta {
padding: 10px 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink);
}
.nav-links .nav-cta:hover { border-color: var(--green); color: var(--green-dark); }
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-soft);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
}
.theme-toggle:hover { border-color: var(--green-dark); color: var(--green-dark); }
.theme-icon { color: var(--green-dark); font-size: 16px; line-height: 1; }
.hero { padding: 46px 0 94px; }
.brand-banner {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 26px;
background: white;
box-shadow: var(--shadow);
}
.brand-banner img { width: 100%; height: auto; }
.hero-copy {
display: grid;
grid-template-columns: 1.05fr 0.75fr;
align-items: end;
gap: 54px;
padding: 50px 10px 0;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
margin: 0 0 16px;
color: var(--green-dark);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow::before {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
content: "";
}
h1, h2, h3, p { margin-top: 0; }
h1 {
max-width: 690px;
margin-bottom: 0;
font-size: clamp(38px, 6vw, 66px);
line-height: 1.04;
letter-spacing: -0.055em;
}
.hero-aside p {
margin-bottom: 22px;
color: var(--ink-soft);
font-size: 17px;
line-height: 1.7;
}
.button-row { display: flex; flex-wrap: wrap; gap: 11px; }
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
gap: 9px;
padding: 11px 18px;
border: 1px solid transparent;
border-radius: 999px;
background: var(--green);
color: white;
font-size: 14px;
font-weight: 750;
text-decoration: none;
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.button:hover { transform: translateY(-2px); background: var(--button-hover); box-shadow: 0 8px 20px rgba(20, 155, 114, 0.2); }
.button-secondary { border-color: var(--line); background: var(--surface); color: var(--ink); }
.button-secondary:hover { border-color: #b8d8ca; background: var(--green-pale); color: var(--green-dark); box-shadow: none; }
.arrow { font-size: 17px; line-height: 1; }
.section { padding: 82px 0; }
.section-white { border-block: 1px solid var(--line); background: var(--surface); }
.section-heading { max-width: 700px; margin-bottom: 36px; }
.section-heading h2 {
margin-bottom: 12px;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.12;
letter-spacing: -0.045em;
}
.section-heading p { margin-bottom: 0; color: var(--muted); font-size: 17px; }
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.feature-card {
min-height: 226px;
padding: 27px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 5px 20px rgba(16, 47, 80, 0.025);
}
.feature-icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 24px;
border-radius: 14px;
background: var(--green-pale);
color: var(--green-dark);
font-size: 20px;
font-weight: 800;
}
.feature-card:nth-child(2) .feature-icon { background: var(--blue-pale); color: var(--ink); }
.feature-card h3 { margin-bottom: 8px; font-size: 18px; letter-spacing: -0.02em; }
.feature-card p { margin-bottom: 0; color: var(--muted); font-size: 14px; }
.how-grid { display: grid; grid-template-columns: 0.85fr 1.15fr; gap: 70px; align-items: start; }
.how-intro { position: sticky; top: 118px; }
.how-intro p { color: var(--ink-soft); }
.steps-list { display: grid; gap: 0; }
.step {
display: grid;
grid-template-columns: 48px 1fr;
gap: 19px;
padding: 0 0 30px;
}
.step + .step { padding-top: 26px; border-top: 1px solid var(--line); }
.step-number {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid var(--step-line);
border-radius: 50%;
background: var(--green-pale);
color: var(--green-dark);
font-size: 14px;
font-weight: 800;
}
.step h3 { margin: 3px 0 6px; font-size: 17px; }
.step p { margin-bottom: 0; color: var(--muted); font-size: 14px; }
.roadmap-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 0; list-style: none; }
.roadmap-list li {
display: flex;
align-items: flex-start;
gap: 15px;
padding: 19px 20px;
border: 1px solid var(--line);
border-radius: 15px;
background: var(--surface);
color: var(--ink-soft);
font-size: 14px;
}
.version {
flex: 0 0 auto;
min-width: 48px;
padding: 4px 8px;
border-radius: 8px;
background: var(--blue-pale);
color: var(--ink);
font-size: 12px;
font-weight: 850;
text-align: center;
}
.setup-section { background: var(--setup-bg); color: white; }
.setup-section .eyebrow { color: #8be0bc; }
.setup-section .section-heading p { color: #c0cedb; }
.setup-section .section-heading a { color: #8be0bc; }
.setup-layout { display: grid; grid-template-columns: 1fr 0.9fr; gap: 22px; align-items: start; }
.setup-card {
padding: 27px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: var(--radius);
background: rgba(255,255,255,0.06);
}
.setup-card h3 { margin-bottom: 19px; font-size: 18px; }
.setup-steps { display: grid; gap: 20px; margin: 0; padding: 0; list-style: none; counter-reset: setup; }
.setup-steps li { position: relative; padding-left: 40px; counter-increment: setup; }
.setup-steps li::before {
position: absolute;
top: 1px;
left: 0;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(139, 224, 188, 0.15);
color: #8be0bc;
content: counter(setup);
font-size: 12px;
font-weight: 800;
}
.setup-steps strong { display: block; margin-bottom: 7px; font-size: 14px; }
.setup-steps p, .setup-card > p { margin-bottom: 9px; color: #c0cedb; font-size: 13px; }
.required-vars { margin: 8px 0 12px; padding-left: 18px; color: #c0cedb; font-size: 12px; }
.required-vars li { position: static; padding-left: 0; counter-increment: none; }
.required-vars li::before { content: none; }
.required-vars li + li { margin-top: 5px; }
.required-vars code { overflow-wrap: anywhere; }
code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
code { color: inherit; }
.setup-card code:not(pre code) {
padding: 2px 5px;
border-radius: 5px;
background: rgba(255,255,255,0.1);
color: #d5f5e6;
overflow-wrap: anywhere;
}
pre {
overflow-x: auto;
margin: 9px 0 0;
padding: 13px 15px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 11px;
background: rgba(4, 18, 31, 0.7);
color: #e9f3fb;
font-size: 12px;
line-height: 1.7;
white-space: pre;
}
.webhook-card { border-color: rgba(139, 224, 188, 0.3); background: rgba(20, 155, 114, 0.11); }
.webhook-card h3 { display: flex; align-items: center; gap: 10px; }
.webhook-card h3::before { color: #8be0bc; content: "↗"; }
.webhook-card .callout {
margin: 15px 0;
padding: 12px 14px;
border-left: 3px solid #8be0bc;
border-radius: 0 9px 9px 0;
background: rgba(255,255,255,0.07);
color: #e4f4ed;
font-size: 13px;
}
.setup-card .subtle { margin-top: 13px; color: #aabaca; font-size: 12px; }
.source-link { display: inline-flex; align-items: center; gap: 8px; margin-top: 24px; color: #8be0bc; font-size: 14px; font-weight: 750; text-decoration: none; }
.source-link:hover { color: white; }
footer { padding: 26px 0; background: #0b233b; color: #b6c5d3; font-size: 13px; }
.footer-inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
footer a { color: white; text-decoration: none; }
footer a:hover { color: #8be0bc; }
.footer-brand { color: white; font-weight: 800; letter-spacing: 0.1em; }
:focus-visible { outline: 3px solid #35bb8e; outline-offset: 4px; }
@media (max-width: 820px) {
.nav-links { gap: 18px; }
.hero-copy, .how-grid, .setup-layout { grid-template-columns: 1fr; gap: 26px; }
.hero-copy { padding: 36px 4px 0; }
.hero-aside p { max-width: 620px; }
.how-intro { position: static; }
.features { grid-template-columns: 1fr; }
.feature-card { min-height: auto; }
}
@media (max-width: 600px) {
.container { width: min(var(--content), calc(100% - 32px)); }
.nav { min-height: 66px; gap: 12px; }
.nav-links { gap: 12px; }
.nav-links a { font-size: 12px; }
.nav-links a:nth-child(1), .nav-links a:nth-child(2), .nav-links .nav-cta { display: none; }
.theme-toggle { padding: 8px 10px; font-size: 12px; }
.wordmark { font-size: 15px; }
.wordmark-mark { width: 30px; height: 30px; }
.hero { padding: 22px 0 64px; }
.brand-banner { border-radius: 16px; }
.hero-copy { padding-top: 29px; }
h1 { font-size: clamp(38px, 11vw, 54px); }
.section { padding: 62px 0; }
.roadmap-list { grid-template-columns: 1fr; }
.setup-card { padding: 21px; }
.footer-inner { align-items: flex-start; flex-direction: column; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; }
}
</style>
<script>
try {
const savedTheme = localStorage.getItem("steep-theme");
if (savedTheme === "light" || savedTheme === "dark") {
document.documentElement.dataset.theme = savedTheme;
}
} catch (_) {}
</script>
</head>
<body>
<header class="site-header">
<nav class="nav container" aria-label="Main navigation">
<a class="wordmark" href="#top" aria-label="Steep home"><span class="wordmark-mark">S</span> STEEP</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#roadmap">Roadmap</a>
<a href="#setup">Get started</a>
<a class="nav-cta" href="https://gitea.qpismont.fr/qpismont/herald">View source <span class="arrow" aria-hidden="true">↗</span></a>
<button class="theme-toggle" id="theme-toggle" type="button" role="switch" aria-checked="true" aria-label="Dark theme">
<span class="theme-icon" id="theme-icon" aria-hidden="true">☾</span><span>Dark theme</span>
</button>
</div>
</nav>
</header>
<main id="top">
<section class="hero container" aria-labelledby="hero-title">
<div class="brand-banner">
<img src="banniere_readme.png" alt="Steep — Let your code steep">
</div>
<div class="hero-copy">
<div>
<p class="eyebrow">AI code review · built for Gitea</p>
<h1 id="hero-title">Code reviews with the full picture.</h1>
</div>
<div class="hero-aside">
<p>Steep explores pull requests inside an isolated Dev Container, then leaves focused, line-by-line feedback right where your team works.</p>
<div class="button-row">
<a class="button" href="#setup">Get started <span class="arrow" aria-hidden="true">↓</span></a>
<a class="button button-secondary" href="#how-it-works">How it works</a>
</div>
</div>
</div>
</section>
<section class="section section-white" id="features" aria-labelledby="features-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">Thoughtful by design</p>
<h2 id="features-title">Useful reviews. No noisy pipeline.</h2>
<p>Bring an AI reviewer into your Gitea workflow with a single comment.</p>
</div>
<div class="features">
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">↳</div>
<h3>Reviews on demand</h3>
<p>Mention Steep in a pull-request comment and get an AI review streamed back to the PR. No CI hook to wire, no pipeline to learn.</p>
</article>
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">◇</div>
<h3>Sandboxed by design</h3>
<p>Every review runs in an ephemeral Dev Container with read-only tools. The network is cut after setup, and the container is removed when the review ends.</p>
</article>
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">⌁</div>
<h3>Lightweight & observable</h3>
<p>A lean Rust service designed for fast reviews, with Prometheus metrics and Sentry error tracking.</p>
</article>
</div>
</div>
</section>
<section class="section" id="how-it-works" aria-labelledby="how-title">
<div class="container how-grid">
<div class="how-intro">
<p class="eyebrow">From comment to context</p>
<div class="section-heading">
<h2 id="how-title">Your code stays in its environment.</h2>
<p>Steep gives the model tools to inspect the repository instead of sending the source code in the prompt.</p>
</div>
<a class="button button-secondary" href="#setup">Set up Steep <span class="arrow" aria-hidden="true">↓</span></a>
</div>
<div class="steps-list">
<article class="step">
<span class="step-number">01</span>
<div><h3>Steep hears your request</h3><p>A Gitea pull-request comment triggers a review when you mention the bot.</p></div>
</article>
<article class="step">
<span class="step-number">02</span>
<div><h3>A disposable workspace spins up</h3><p>Steep clones the pull-request head and starts the repository's Dev Container—or a safe default when none is configured.</p></div>
</article>
<article class="step">
<span class="step-number">03</span>
<div><h3>The model explores, read-only</h3><p>It can inspect files with tools such as <code>ls</code>, <code>read_file</code>, and <code>grep</code>. Setup hooks run first; then network access is removed.</p></div>
</article>
<article class="step">
<span class="step-number">04</span>
<div><h3>Feedback lands on the changed lines</h3><p>Steep posts actionable findings with severity labels, plus a concise summary of what the PR does well.</p></div>
</article>
</div>
</div>
</section>
<section class="section section-white" id="roadmap" aria-labelledby="roadmap-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">What’s brewing</p>
<h2 id="roadmap-title">The roadmap</h2>
<p>From safer reviews to an assistant that can help move work forward.</p>
</div>
<ul class="roadmap-list">
<li><span class="version">1.2</span><span>Sandboxed pull-request reviews.</span></li>
<li><span class="version">1.2.1</span><span>A default Dev Container when a repository has no configuration.</span></li>
<li><span class="version">1.3</span><span>Type-safe routing between review and implementation requests.</span></li>
<li><span class="version">2.0</span><span>Implement requested changes from issues or existing PRs, then create or update a PR automatically.</span></li>
<li><span class="version">2.1</span><span>Project instructions from <code>AGENTS.md</code> and separate review/implementation models.</span></li>
<li><span class="version">3.0</span><span>Automatically investigate and fix project errors reported by Sentry.</span></li>
</ul>
</div>
</section>
<section class="section setup-section" id="setup" aria-labelledby="setup-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">A few minutes to get steeping</p>
<h2 id="setup-title">Bring Steep to your Gitea.</h2>
<p>Follow these steps in order. You will need a <a href="https://rustup.rs/">Rust toolchain</a>, a reachable Docker or Podman daemon, an OpenRouter API key, and a Gitea account for Steep.</p>
</div>
<div class="setup-layout">
<article class="setup-card">
<h3>1. Install and configure</h3>
<ol class="setup-steps">
<li>
<strong>Clone the source</strong>
<pre>git clone https://gitea.qpismont.fr/qpismont/herald.git
cd herald</pre>
</li>
<li>
<strong>Create a Gitea token</strong>
<p>Create a token for the account Steep should act as. Give it access to the repositories it will review, including permission to read pull requests and publish comments/reviews. Steep uses this token to identify the account; you will use that account's username when asking for a review.</p>
</li>
<li>
<strong>Configure every required setting</strong>
<pre>cp .env.example .env
$EDITOR .env</pre>
<p>Set all nine required variables:</p>
<ul class="required-vars">
<li><code>GITEA_URL</code> — base URL, e.g. <code>https://gitea.example.com</code> (no <code>/api/v1</code>)</li>
<li><code>GITEA_TOKEN</code> — the token created above</li>
<li><code>GITEA_TIMEOUT</code> — API timeout in seconds</li>
<li><code>OPEN_ROUTER_API_KEY</code> — your OpenRouter key</li>
<li><code>OPEN_ROUTER_MODEL</code> — an available OpenRouter model ID</li>
<li><code>OPEN_ROUTER_TIMEOUT</code> — request timeout in seconds</li>
<li><code>BOT_MAX_CONCURRENT</code> — maximum simultaneous reviews</li>
<li><code>HTTP_PORT</code> — web server port; use <code>3001</code> for the commands below</li>
<li><code>WEBHOOK_SIG_HEADER_SECRET</code> — generate this in the next step</li>
</ul>
<p class="subtle">Keep the example's numeric values for the timeouts and concurrency, or replace them with positive integers. Do not commit your completed <code>.env</code>.</p>
</li>
<li>
<strong>Generate and configure the webhook secret</strong>
<pre>openssl rand -hex 32</pre>
<p>Copy the output into <code>WEBHOOK_SIG_HEADER_SECRET</code> in <code>.env</code>. You will enter the exact same value in Gitea in the next step.</p>
</li>
<li>
<strong>Start the daemon, build, and launch Steep</strong>
<pre>podman system service --time=0 &amp;
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
cargo build --release -p steep-server
./target/release/steep-server</pre>
<p class="subtle">Keep this process running. With Docker, omit the Podman command and use its socket; the default is <code>unix:///var/run/docker.sock</code>. Optional settings such as metrics and Sentry can be added later.</p>
</li>
</ol>
<a class="source-link" href="https://gitea.qpismont.fr/qpismont/herald">Explore the source on Gitea <span aria-hidden="true">↗</span></a>
</article>
<article class="setup-card webhook-card">
<h3>2. Add the Gitea webhook</h3>
<ol class="setup-steps">
<li>
<strong>Choose an address Gitea can reach</strong>
<p>In the repository's <strong>Settings → Webhooks</strong>, add a Gitea webhook. Set the target to Steep's public/reachable address and port:</p>
<div class="callout"><code>http://steep.example.com:3001/webhook</code><br>Replace the host and scheme with yours. Do not use <code>localhost</code> unless Gitea and Steep share that network.</div>
</li>
<li>
<strong>Select the event and set the secret</strong>
<p>Enable at least <strong>Pull Request Comment</strong>. Paste the generated value into Gitea's <strong>Secret</strong> field; it must exactly match <code>WEBHOOK_SIG_HEADER_SECRET</code>.</p>
</li>
<li>
<strong>Allow the connection</strong>
<p>Make sure Gitea can connect to Steep on port <code>3001</code> (or your configured <code>HTTP_PORT</code>), including through any firewall or reverse proxy. Steep must also be able to reach Gitea's API, OpenRouter, and the container daemon.</p>
</li>
<li>
<strong>Trigger a review</strong>
<p>Open a pull request and add a comment that starts with <code>@username</code>, using the username of the Gitea account behind <code>GITEA_TOKEN</code>. For example:</p>
<pre>@steep review this PR</pre>
<p class="subtle">The mention must be at the very start of the comment. If nothing happens, check the webhook's delivery history in Gitea and Steep's logs.</p>
</li>
</ol>
</article>
</div>
</div>
</section>
</main>
<footer>
<div class="container footer-inner">
<span><span class="footer-brand">STEEP</span> · Let your code steep.</span>
<span>Built with Rust · <a href="https://gitea.qpismont.fr/qpismont/herald">Source on Gitea</a></span>
</div>
</footer>
<script>
const themeToggle = document.getElementById("theme-toggle");
const themeIcon = document.getElementById("theme-icon");
const themeColor = document.querySelector('meta[name="theme-color"]');
function setTheme(theme, persist = true) {
document.documentElement.dataset.theme = theme;
themeToggle.setAttribute("aria-checked", String(theme === "dark"));
themeIcon.textContent = theme === "dark" ? "☾" : "☀";
themeColor.content = theme === "dark" ? "#0b1520" : "#f7f8f5";
if (persist) {
try {
localStorage.setItem("steep-theme", theme);
} catch (_) {}
}
}
setTheme(document.documentElement.dataset.theme || "dark", false);
themeToggle.addEventListener("click", () => {
setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
});
</script>
</body>
</html>