first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:59:58 +01:00
commit 0efaae1715
27 files changed
+1455

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
__pycache__
*.pyc
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.13-alpine
WORKDIR /app
COPY app.py /app/app.py
COPY web /app/web
USER 65532:65532
EXPOSE 8000
ENTRYPOINT ["python3", "/app/app.py"]
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, urlsplit
LOG = logging.getLogger("rustbox.repository")
REPOSITORY = Path(os.environ.get("REPOSITORY_ROOT", "/srv/repository"))
WEB = Path(__file__).resolve().parent / "web"
PORT = int(os.environ.get("DASHBOARD_PORT", "8000"))
CACHE_SECONDS = max(1, int(os.environ.get("DASHBOARD_CACHE_SECONDS", "15")))
REPOSITORY_URL = os.environ.get("REPOSITORY_URL", "http://localhost:18765").rstrip("/")
SITES = ("linux", "suse")
META_PATHS = {"linux": Path("LINUX-BUILD-INFO.txt"), "suse": Path("SUSE-BUILD-INFO.txt")}
DEB_FILENAME = re.compile(r"^(?P<name>.+?)_(?P<version>[^_]+)_(?P<arch>[^_]+)\.deb$")
RPM_FILENAME = re.compile(r"^(?P<name>.+)-(?P<version>\d[^-]*)-(?P<release>[^/]+)\.(?P<arch>x86_64|amd64|aarch64|noarch|arm64)\.rpm$")
def iso_time(stamp: float) -> str:
return datetime.fromtimestamp(stamp, tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def parse_metadata(path: Path) -> dict[str, str]:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return {}
result: dict[str, str] = {}
for line in text.splitlines():
key, sep, value = line.partition("=")
if sep and re.fullmatch(r"[a-z][a-z0-9_]*", key):
result[key] = value.strip()[:1024]
return result
def get_current(repository: Path) -> Path | None:
try:
root = repository.resolve(strict=True)
current = (root / "current").resolve(strict=True)
if current.is_dir() and current.is_relative_to(root):
return current
except (OSError, RuntimeError):
pass
return None
def publication_info(current: Path, channel: str) -> dict[str, object]:
location = current / META_PATHS[channel]
raw = parse_metadata(location)
built_at = raw.get("built_at") or None
return {
"channel": channel,
"published": bool(raw),
"app_version": raw.get("app_version", ""),
"package_release": raw.get("package_release", ""),
"package_version": f"{raw.get('app_version','')}-{raw.get('package_release','')}" if raw.get("app_version") and raw.get("package_release") else raw.get("app_version", ""),
"source_commit": raw.get("source_commit", ""),
"source_ref": raw.get("source_ref", ""),
"source_url": raw.get("source_url", ""),
"packaging_revision": raw.get("packaging_revision", ""),
"built_at": built_at,
}
def parse_package(path: Path, kind: str) -> tuple[str, str, str] | None:
pattern = DEB_FILENAME if kind == "deb" else RPM_FILENAME
m = pattern.fullmatch(path.name)
if not m:
return None
if kind == "deb":
return m["name"], m["version"], m["arch"]
return m["name"], f"{m['version']}-{m['release']}", m["arch"]
def generate_catalog(repository: Path = REPOSITORY) -> dict[str, object]:
current = get_current(repository)
empty = {"published": False, "app_version": "", "package_release": "", "package_version": "", "source_commit": "", "source_ref": "", "source_url": "", "packaging_revision": "", "built_at": None}
builds = {site: publication_info(current, site) if current else {"channel": site, **empty} for site in SITES}
packages: list[dict[str, object]] = []
if current:
scans = [
("linux", "DEB", "deb", current / "apt" / "pool", "*.deb"),
("linux", "RPM", "rpm", current / "rpm" / "x86_64", "*.rpm"),
("suse", "RPM", "rpm", current / "suse" / "x86_64", "*.rpm"),
]
for channel, label, kind, directory, pattern in scans:
if not directory.is_dir():
continue
for path in directory.rglob(pattern):
parsed = parse_package(path, kind)
if not parsed or not path.is_file():
continue
name, version, arch = parsed
stat = path.stat()
rel = path.relative_to(current)
encoded = "/".join(quote(part, safe="") for part in rel.parts)
packages.append({
"name": name,
"version": version,
"architecture": arch,
"format": label,
"channel": channel,
"size_bytes": stat.st_size,
"built_at": iso_time(stat.st_mtime),
"download_url": f"{REPOSITORY_URL}/{encoded}",
"relative_path": rel.as_posix(),
})
packages.sort(key=lambda item: (str(item["built_at"]), str(item["version"])), reverse=True)
build_times = [str(item["built_at"]) for item in builds.values() if item.get("built_at")]
return {
"generated_at": iso_time(time.time()),
"ready": bool(packages),
"repository_url": REPOSITORY_URL,
"apt_distribution": os.environ.get("REPO_DISTRIBUTION", "stable"),
"apt_component": os.environ.get("REPO_COMPONENT", "main"),
"builds": builds,
"packages": packages,
"summary": {
"packages_count": len(packages),
"channels_published": sum(bool(item.get("published")) for item in builds.values()),
"latest_build_at": max(build_times, default=None),
},
}
class Cache:
def __init__(self) -> None:
self.lock = threading.Lock()
self.expires = 0.0
self.snapshot: dict[str, object] | None = None
def get(self) -> dict[str, object]:
with self.lock:
if self.snapshot is None or time.monotonic() >= self.expires:
self.snapshot = generate_catalog()
self.expires = time.monotonic() + CACHE_SECONDS
return self.snapshot
CACHE = Cache()
class Handler(BaseHTTPRequestHandler):
server_version = "RustBoxRepository/1.0"
sys_version = ""
def do_GET(self) -> None:
path = urlsplit(self.path).path
if path == "/healthz":
self.send_bytes(b"ok\n", "text/plain; charset=utf-8", "no-store")
elif path == "/api/v1/packages":
self.send_bytes(json.dumps(CACHE.get(), ensure_ascii=False, separators=(",", ":")).encode(), "application/json; charset=utf-8", "no-store")
elif path in ("/", "/index.html"):
self.send_bytes((WEB / "index.html").read_bytes(), "text/html; charset=utf-8", "no-store", True)
elif path in ("/ui/style.css", "/ui/app.js", "/ui/favicon.svg"):
name = path.rsplit("/", 1)[-1]
mime = {"style.css": "text/css", "app.js": "application/javascript", "favicon.svg": "image/svg+xml"}[name]
self.send_bytes((WEB / name).read_bytes(), f"{mime}; charset=utf-8", "public, max-age=600")
else:
self.send_error(HTTPStatus.NOT_FOUND)
def do_HEAD(self) -> None:
self.do_GET()
def send_bytes(self, payload: bytes, mime: str, cache: str, html: bool = False) -> None:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", cache)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
if html:
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
def log_message(self, fmt: str, *args: object) -> None:
LOG.info("%s - %s", self.address_string(), fmt % args)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.daemon_threads = True
LOG.info("RustBox repository dashboard ready on %s", PORT)
server.serve_forever(poll_interval=0.5)
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
(() => {
const $ = (id) => document.getElementById(id);
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = {data:null, view:'overview', install:'apt', search:'', channel:'all', format:'all'};
const titles = {overview:'Przegląd',packages:'Paczki',install:'Instalacja',repositories:'Repozytoria'};
const fmtDate = (value) => value ? new Intl.DateTimeFormat('pl-PL',{dateStyle:'medium',timeStyle:'short'}).format(new Date(value)) : '—';
const fmtSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n/1024).toFixed(1)} KiB` : `${(n/1048576).toFixed(1)} MiB`;
function toast(text){const t=$('toast');t.textContent=text;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1600)}
function navigate(view){state.view=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('view-title').textContent=titles[view]||view;location.hash=view}
function packageRow(p){return `<div class="package-row"><div><div class="pkg-name">${esc(p.name)}</div><div class="muted">${esc(p.version)}</div></div><span class="pill">${esc(p.channel.toUpperCase())}</span><span class="pill">${esc(p.format)}</span><span class="muted">${esc(fmtSize(p.size_bytes))}</span><a class="download" href="${esc(p.download_url)}">Pobierz</a></div>`}
function renderOverview(){const d=state.data;if(!d)return;const linux=d.builds.linux||{};const suse=d.builds.suse||{};$('latest-version').textContent=linux.app_version||'—';$('latest-build').textContent=fmtDate(linux.built_at);$('packages-count').textContent=d.summary.packages_count;$('linux-status').textContent=linux.published?'GOTOWE':'BRAK';$('suse-status').textContent=suse.published?'GOTOWE':'BRAK';$('recent-packages').innerHTML=d.packages.slice(0,6).map(packageRow).join('')||'<div class="empty">Brak opublikowanych paczek.</div>'}
function renderPackages(){if(!state.data)return;const q=state.search.toLowerCase();const list=state.data.packages.filter(p=>(state.channel==='all'||p.channel===state.channel)&&(state.format==='all'||p.format===state.format)&&(!q||`${p.name} ${p.version} ${p.architecture}`.toLowerCase().includes(q)));$('packages-table').innerHTML=list.map(p=>`<div class="table-row"><div><div class="pkg-name">${esc(p.name)}</div><div class="muted">${esc(p.version)}</div></div><span class="pill">${esc(p.channel.toUpperCase())}</span><span class="pill">${esc(p.format)}</span><span class="muted">${esc(fmtDate(p.built_at))}</span><a class="download" href="${esc(p.download_url)}">Pobierz</a></div>`).join('')||'<div class="empty">Brak wyników.</div>'}
function instruction(kind){const d=state.data;const url=d.repository_url,dist=d.apt_distribution,comp=d.apt_component;if(kind==='dnf')return {title:'RHEL / Rocky / Alma / Fedora',desc:'Instalacja przez podpisane repozytorium DNF/YUM.',code:`sudo rpm --import ${url}/repo-key.gpg\n\nsudo tee /etc/yum.repos.d/rustbox.repo >/dev/null <<'REPO'\n[rustbox]\nname=RustBox Repository\nbaseurl=${url}/rpm/$basearch\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=${url}/repo-key.gpg\nREPO\n\nsudo dnf install rustbox`};if(kind==='suse')return {title:'SUSE / openSUSE',desc:'Instalacja przez podpisane repozytorium Zypper.',code:`sudo rpm --import ${url}/repo-key.gpg\nsudo zypper addrepo --refresh --gpgcheck ${url}/suse/x86_64/ rustbox\nsudo zypper refresh rustbox\nsudo zypper install rustbox`};return {title:'Debian / Ubuntu',desc:'Instalacja przez podpisane repozytorium APT.',code:`curl -fsSL ${url}/repo-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/rustbox.gpg\n\necho "deb [signed-by=/usr/share/keyrings/rustbox.gpg] ${url}/apt ${dist} ${comp}" | sudo tee /etc/apt/sources.list.d/rustbox.list\n\nsudo apt update\nsudo apt install rustbox`}}
function renderInstall(){if(!state.data)return;const x=instruction(state.install);$('install-title').textContent=x.title;$('install-desc').textContent=x.desc;$('install-command').textContent=x.code;document.querySelectorAll('[data-install]').forEach(b=>b.classList.toggle('active',b.dataset.install===state.install))}
function repoCard(title,url,links){return `<article class="repo-card"><h3>${esc(title)}</h3><a href="${esc(url)}">${esc(url)}</a><div class="repo-links">${links.map(([n,u])=>`<a href="${esc(u)}"><span>${esc(n)}</span></a>`).join('')}</div></article>`}
function renderRepositories(){const d=state.data;if(!d)return;const u=d.repository_url,dist=encodeURIComponent(d.apt_distribution);$('repo-cards').innerHTML=repoCard('APT + DNF',u,[['GPG public key',`${u}/repo-key.gpg`],['APT InRelease',`${u}/apt/dists/${dist}/InRelease`],['RPM repomd.xml',`${u}/rpm/x86_64/repodata/repomd.xml`],['Build info',`${u}/LINUX-BUILD-INFO.txt`]])+repoCard('SUSE / Zypper',`${u}/suse/x86_64`,[['RPM repomd.xml',`${u}/suse/x86_64/repodata/repomd.xml`],['Metadata signature',`${u}/suse/x86_64/repodata/repomd.xml.asc`],['Build info',`${u}/SUSE-BUILD-INFO.txt`]]);$('metadata').innerHTML=['linux','suse'].map(ch=>{const b=d.builds[ch]||{};return `<div class="meta-block"><h3>${ch==='linux'?'Linux':'SUSE'}</h3>${[['Wersja',b.app_version],['Wydanie paczki',b.package_release],['Build',fmtDate(b.built_at)],['Commit',b.source_commit?b.source_commit.slice(0,12):'—'],['Rewizja pakowania',b.packaging_revision]].map(([a,v])=>`<div class="meta-row"><span>${esc(a)}</span><strong>${esc(v||'—')}</strong></div>`).join('')}</div>`}).join('')}
function renderAll(){renderOverview();renderPackages();renderInstall();renderRepositories()}
async function refresh(){const btn=$('refresh');btn.disabled=true;try{const r=await fetch('/api/v1/packages',{cache:'no-store'});if(!r.ok)throw new Error(`HTTP ${r.status}`);state.data=await r.json();renderAll();$('last-sync').textContent=`Ostatnia synchronizacja: ${fmtDate(state.data.generated_at)}`;$('status-dot').classList.add('ok');$('status-text').textContent=state.data.ready?'Repozytorium aktywne':'Oczekiwanie na build'}catch(e){$('status-dot').classList.remove('ok');$('status-text').textContent='Błąd odczytu repozytorium';console.error(e)}finally{btn.disabled=false}}
function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('rustbox-repo-theme',theme)}
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.view)));
document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.go)));
document.querySelectorAll('[data-install]').forEach(b=>b.addEventListener('click',()=>{state.install=b.dataset.install;renderInstall()}));
$('search').addEventListener('input',e=>{state.search=e.target.value;renderPackages()});$('channel').addEventListener('change',e=>{state.channel=e.target.value;renderPackages()});$('format').addEventListener('change',e=>{state.format=e.target.value;renderPackages()});$('refresh').addEventListener('click',refresh);$('copy-install').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('install-command').textContent);toast('Skopiowano')}catch{toast('Nie udało się skopiować')}});$('theme-toggle').addEventListener('click',()=>setTheme(document.documentElement.dataset.theme==='dark'?'light':'dark'));
try{setTheme(localStorage.getItem('rustbox-repo-theme')||'dark')}catch{}navigate(location.hash.slice(1)||'overview');window.addEventListener('hashchange',()=>navigate(location.hash.slice(1)||'overview'));refresh();setInterval(()=>{if(!document.hidden)refresh()},60000);
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#8fb9a4"/><path d="M18 16h17c9 0 14 5 14 12 0 5-3 9-8 11l10 10H39L30 40h-2v9H18V16zm10 9v7h7c3 0 5-1 5-4s-2-3-5-3h-7z" fill="#102018"/></svg>

After

Width:  |  Height:  |  Size: 245 B

+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="pl" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>RustBox Repository</title>
<link rel="icon" href="/ui/favicon.svg">
<link rel="stylesheet" href="/ui/style.css">
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand"><div class="brand-icon">R</div><div><strong>RustBox</strong><span>Package Repository</span></div></div>
<nav>
<button class="nav active" data-view="overview">Przegląd</button>
<button class="nav" data-view="packages">Paczki</button>
<button class="nav" data-view="install">Instalacja</button>
<button class="nav" data-view="repositories">Repozytoria</button>
</nav>
<div class="sidebar-foot"><span id="status-dot" class="status-dot"></span><span id="status-text">Ładowanie…</span></div>
</aside>
<main>
<header class="topbar">
<div><h1 id="view-title">Przegląd</h1><p id="last-sync">—</p></div>
<div class="toolbar"><button id="theme-toggle" title="Motyw">◐</button><button id="refresh" title="Odśwież">↻</button></div>
</header>
<section id="overview" class="view active">
<div class="hero">
<div><span class="eyebrow">RUSTBOX / LINUX</span><h2>Repozytorium wydań</h2><p>Podpisane paczki APT, DNF/RPM i Zypper budowane bezpośrednio ze źródeł RustBox.</p></div>
<div class="version-box"><span>NAJNOWSZA WERSJA</span><strong id="latest-version">—</strong><small id="latest-build">—</small></div>
</div>
<div class="cards">
<article><span>Paczki</span><strong id="packages-count">0</strong><small>opublikowane artefakty</small></article>
<article><span>Linux</span><strong id="linux-status">—</strong><small>APT + DNF</small></article>
<article><span>SUSE</span><strong id="suse-status">—</strong><small>Zypper RPM</small></article>
</div>
<div class="panel"><div class="panel-head"><h3>Ostatnie paczki</h3><button data-go="packages">Pokaż wszystkie</button></div><div id="recent-packages" class="package-list"></div></div>
</section>
<section id="packages" class="view">
<div class="panel">
<div class="filters"><input id="search" type="search" placeholder="Szukaj paczki lub wersji"><select id="channel"><option value="all">Wszystkie kanały</option><option value="linux">Linux</option><option value="suse">SUSE</option></select><select id="format"><option value="all">Wszystkie formaty</option><option value="DEB">DEB</option><option value="RPM">RPM</option></select></div>
<div id="packages-table" class="table"></div>
</div>
</section>
<section id="install" class="view">
<div class="install-tabs"><button class="active" data-install="apt">Debian / Ubuntu</button><button data-install="dnf">RHEL / Rocky / Alma / Fedora</button><button data-install="suse">SUSE / openSUSE</button></div>
<div class="panel command-panel"><div class="panel-head"><div><h3 id="install-title">Debian / Ubuntu</h3><p id="install-desc">Instalacja przez podpisane repozytorium APT.</p></div><button id="copy-install">Kopiuj</button></div><pre><code id="install-command"></code></pre></div>
<div class="note">Po instalacji konfiguracja znajduje się w <code>/etc/rustbox/rustbox.toml</code>, dane w <code>/var/lib/rustbox</code>, a logi aplikacji w <code>/var/log/rustbox/rustbox.log</code>.</div>
</section>
<section id="repositories" class="view">
<div id="repo-cards" class="repo-grid"></div>
<div class="panel"><div class="panel-head"><h3>Metadane buildu</h3></div><div id="metadata" class="metadata"></div></div>
</section>
</main>
</div>
<div id="toast" class="toast"></div>
<script src="/ui/app.js" defer></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
:root{color-scheme:dark;--bg:#121513;--panel:#181c19;--panel2:#1e231f;--panel3:#242a25;--sidebar:#101310;--line:#313832;--line2:#485149;--text:#edf0ec;--muted:#9da79f;--subtle:#7c877f;--accent:#8fb9a4;--accent2:#a4c9b6;--good:#a7c892;--field:#121713;--hover:#202621;--shadow:rgba(0,0,0,.32);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
html[data-theme="light"]{color-scheme:light;--bg:#f2f4f1;--panel:#fff;--panel2:#f7f8f6;--panel3:#edf1ec;--sidebar:#e9eee9;--line:#cbd2cb;--line2:#aeb8af;--text:#202622;--muted:#647068;--subtle:#7a847d;--accent:#507b67;--accent2:#416b58;--good:#587a49;--field:#fff;--hover:#e8ede8;--shadow:rgba(31,39,33,.16)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-size:14px}.shell{min-height:100vh;display:grid;grid-template-columns:228px minmax(0,1fr)}.sidebar{position:sticky;top:0;height:100vh;background:var(--sidebar);border-right:1px solid var(--line);padding:18px 12px;display:flex;flex-direction:column}.brand{display:flex;gap:11px;align-items:center;padding:5px 8px 24px}.brand-icon{width:34px;height:34px;border-radius:9px;background:var(--accent);color:#102018;display:grid;place-items:center;font-weight:800;font-size:18px}.brand strong,.brand span{display:block}.brand span{color:var(--muted);font-size:11px;margin-top:2px}.sidebar nav{display:grid;gap:3px}.nav{border:0;background:transparent;color:var(--muted);text-align:left;padding:10px 12px;border-radius:5px;cursor:pointer;font-weight:600}.nav:hover{background:var(--hover);color:var(--text)}.nav.active{background:var(--panel3);color:var(--text);box-shadow:inset 2px 0 var(--accent)}.sidebar-foot{margin-top:auto;border-top:1px solid var(--line);padding:15px 8px 2px;color:var(--muted);display:flex;align-items:center;gap:8px;font-size:12px}.status-dot{width:8px;height:8px;border-radius:50%;background:var(--subtle)}.status-dot.ok{background:var(--good);box-shadow:0 0 0 3px color-mix(in srgb,var(--good) 15%,transparent)}main{min-width:0}.topbar{height:72px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:color-mix(in srgb,var(--bg) 84%,transparent);backdrop-filter:blur(10px);position:sticky;top:0;z-index:5}.topbar h1{font-size:18px;margin:0}.topbar p{margin:3px 0 0;color:var(--subtle);font-size:11px}.toolbar{display:flex;gap:7px}.toolbar button,.panel button,.install-tabs button{border:1px solid var(--line);background:var(--panel);color:var(--text);min-height:32px;padding:0 11px;border-radius:5px;cursor:pointer}.toolbar button:hover,.panel button:hover,.install-tabs button:hover{border-color:var(--line2);background:var(--hover)}.view{display:none;padding:24px;max-width:1320px;margin:0 auto}.view.active{display:block}.hero{display:grid;grid-template-columns:minmax(0,1fr) 240px;gap:18px;background:linear-gradient(135deg,var(--panel),var(--panel2));border:1px solid var(--line);border-radius:7px;padding:28px}.hero h2{font-size:30px;margin:7px 0 8px}.hero p{color:var(--muted);max-width:700px;margin:0;line-height:1.55}.eyebrow{color:var(--accent2);font-size:11px;font-weight:800;letter-spacing:.14em}.version-box{background:var(--field);border:1px solid var(--line);border-radius:6px;padding:18px;display:flex;flex-direction:column;justify-content:center}.version-box span{color:var(--subtle);font-size:10px;letter-spacing:.1em}.version-box strong{font-size:27px;margin:5px 0}.version-box small{color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:12px 0}.cards article{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:17px}.cards span,.cards small{display:block;color:var(--muted)}.cards strong{display:block;font-size:22px;margin:5px 0}.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;margin-top:12px;overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.panel-head h3{margin:0;font-size:14px}.panel-head p{margin:4px 0 0;color:var(--muted)}.package-row,.table-row{display:grid;grid-template-columns:minmax(220px,1fr) 150px 90px 110px 150px;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)}.package-row:last-child,.table-row:last-child{border-bottom:0}.package-row:hover,.table-row:hover{background:var(--hover)}.pkg-name{font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.muted{color:var(--muted)}.pill{display:inline-flex;align-items:center;border:1px solid var(--line2);border-radius:99px;padding:3px 7px;font-size:10px;color:var(--muted);width:max-content}.download{color:var(--accent2);text-decoration:none;font-weight:650}.filters{display:grid;grid-template-columns:minmax(240px,1fr) 190px 170px;gap:8px;padding:12px;border-bottom:1px solid var(--line)}input,select{width:100%;height:34px;background:var(--field);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 10px;outline:none}input:focus,select:focus{border-color:var(--accent)}.install-tabs{display:flex;gap:7px;flex-wrap:wrap}.install-tabs button.active{background:var(--accent);border-color:var(--accent);color:#102018;font-weight:700}.command-panel pre{margin:0;background:#0c0f0d;color:#e8efe9;padding:18px;overflow:auto;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace}.note{margin-top:12px;border:1px solid var(--line);background:var(--panel2);padding:14px;border-radius:6px;color:var(--muted)}code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.repo-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.repo-card{background:var(--panel);bordLine truncated