Initial portal commit: landing + 9 AI-powered apps

Apps:
- dwg-rooms: extract room numbers from DWG/DXF
- dwg-counting: count symbols in PDF drawings (OpenCV template matching)
- contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback)
- email-drafter: bullet notes → polished Czech/English business emails
- invoice-extractor: PDF/image invoice → structured data → Excel
- translator: Czech-first translator across 19 languages with tone control
- vv-check: find inconsistent unit prices across VV sheets in one workbook
- vv-compare: diff original vs new VV files (changes / added / removed)
- feature-request: portal users submit ideas + sample files

Infrastructure:
- LiteLLM gateway with per-app virtual keys + budgets
- Langfuse observability
- Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL
- Caddy reverse proxy on *.klas.chat
This commit is contained in:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /data/requests
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,18 @@
services:
feature-request:
build: .
container_name: feature-request
restart: unless-stopped
ports:
- "127.0.0.1:3032:8000"
environment:
STORAGE_DIR: /data/requests
volumes:
# Bind mount on the host so requests are easy to inspect/review
- /home/klas/Prace/AI/portal/feature-request/data:/data/requests
networks:
- localai
networks:
localai:
external: true

94
feature-request/main.py Normal file
View File

@@ -0,0 +1,94 @@
"""FastAPI: collect feature requests from portal users.
Stores each request to a timestamped folder on disk so the team can review
later. No LLM, no external calls — pure form handler.
"""
import datetime
import json
import logging
import os
import re
import uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Feature Request")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
# Volume-mounted; survives container rebuilds.
STORAGE = Path(os.getenv("STORAGE_DIR", "/data/requests"))
STORAGE.mkdir(parents=True, exist_ok=True)
MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB cap
def _slug(text: str) -> str:
text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE).strip().lower()
text = re.sub(r"[\s_-]+", "-", text)
return text[:40] or "request"
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/submit")
async def submit(
title: str = Form(..., min_length=3, max_length=120),
description: str = Form(..., min_length=10, max_length=8000),
name: str = Form("", max_length=120),
email: str = Form("", max_length=200),
file: UploadFile | None = File(None),
):
title = title.strip()
description = description.strip()
if not title or not description:
raise HTTPException(400, "Vyplňte název a popis")
now = datetime.datetime.now()
folder_name = f"{now.strftime('%Y%m%d_%H%M%S')}_{_slug(title)}_{uuid.uuid4().hex[:8]}"
folder = STORAGE / folder_name
folder.mkdir(parents=True, exist_ok=True)
record = {
"submitted_at": now.isoformat(timespec="seconds"),
"title": title,
"description": description,
"name": name.strip(),
"email": email.strip(),
"attachment": None,
}
if file is not None and file.filename:
raw = await file.read()
if len(raw) > MAX_FILE_BYTES:
raise HTTPException(400, f"Soubor je příliš velký (max {MAX_FILE_BYTES // (1024*1024)} MB)")
safe_name = re.sub(r"[^\w\.\-]", "_", file.filename)[:200]
attachment_path = folder / safe_name
attachment_path.write_bytes(raw)
record["attachment"] = safe_name
record["attachment_bytes"] = len(raw)
record["attachment_content_type"] = file.content_type or ""
(folder / "request.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info("Stored feature request → %s (%s)", folder_name, title)
return {"ok": True, "id": folder_name}
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,3 @@
fastapi>=0.115
uvicorn[standard]>=0.30
python-multipart>=0.0.9

View File

@@ -0,0 +1,95 @@
// Feature request form
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
form: $("s-form"),
processing: $("s-processing"),
thanks: $("s-thanks"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
// Persist name + email for next submission
for (const k of ["name", "email"]) {
const v = localStorage.getItem("req_" + k);
if (v) $(k).value = v;
$(k).addEventListener("input", () => localStorage.setItem("req_" + k, $(k).value));
}
// ── File handling ──
const fileInput = $("file-input");
const dropZone = $("file-drop");
const dropText = $("file-drop-text");
let attachedFile = null;
function setFile(f) {
if (!f) return;
attachedFile = f;
const size = f.size > 1024 * 1024
? `${(f.size / 1024 / 1024).toFixed(1)} MB`
: `${(f.size / 1024).toFixed(0)} kB`;
dropZone.classList.add("has-file");
dropText.innerHTML = `
<span class="file-pill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
${escapeHtml(f.name)} <span style="color:var(--text-tertiary)">(${size})</span>
<button type="button" class="file-pill-clear" id="file-clear">×</button>
</span>
`;
$("file-clear").addEventListener("click", clearFile);
}
function clearFile() {
attachedFile = null;
fileInput.value = "";
dropZone.classList.remove("has-file");
dropText.innerHTML = `Přetáhněte soubor sem nebo
<button type="button" class="btn-link" id="file-pick-btn">vyberte z disku</button>`;
$("file-pick-btn").addEventListener("click", () => fileInput.click());
}
$("file-pick-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => setFile(e.target.files[0]));
["dragenter", "dragover"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
["dragleave", "drop"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
dropZone.addEventListener("drop", (e) => { e.preventDefault(); setFile(e.dataTransfer.files[0]); });
// ── Submit ──
$("request-form").addEventListener("submit", async (e) => {
e.preventDefault();
show("processing");
try {
const fd = new FormData();
fd.append("title", $("title").value);
fd.append("description", $("description").value);
fd.append("name", $("name").value);
fd.append("email", $("email").value);
if (attachedFile) fd.append("file", attachedFile);
const r = await fetch("/api/submit", { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
show("thanks");
} catch (err) {
alert("Chyba: " + err.message);
show("form");
}
});
$("another-btn").addEventListener("click", () => {
$("title").value = "";
$("description").value = "";
clearFile();
show("form");
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();

View File

@@ -0,0 +1,164 @@
/* Feature-request app styles */
.request-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
.form-row { display: flex; flex-direction: column; gap: 6px; }
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.form-label-hint {
font-size: 11px;
font-weight: 400;
color: var(--text-quaternary);
}
.form-input, .form-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-default);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
line-height: 1.5;
transition: border-color .15s, box-shadow .15s;
}
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.form-textarea { resize: vertical; min-height: 160px; }
.file-drop {
padding: 24px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--bg-secondary);
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
transition: border-color .15s, background .15s;
cursor: default;
}
.file-drop.drag-over {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary));
}
.file-drop.has-file {
border-style: solid;
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, var(--bg-secondary));
text-align: left;
}
.file-drop-text { color: var(--text-secondary); }
.btn-link {
background: transparent;
border: none;
color: var(--primary);
cursor: pointer;
font-size: inherit;
font-family: inherit;
text-decoration: underline;
padding: 0;
}
.btn-link:hover { color: var(--primary-hover); }
.file-pill {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
}
.file-pill-clear {
background: transparent;
border: none;
color: var(--text-quaternary);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
}
.file-pill-clear:hover { color: #dc2626; background: rgba(239,68,68,0.08); }
.form-actions {
display: flex;
align-items: center;
gap: 14px;
padding-top: 16px;
border-top: 1px solid var(--border-default);
flex-wrap: wrap;
}
.btn-lg { padding: 12px 26px; font-size: 14px; font-weight: 600; }
.run-hint { font-size: 12.5px; color: var(--text-tertiary); }
/* Thanks card */
.thanks-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 56px 32px;
text-align: center;
box-shadow: var(--shadow-card);
}
.thanks-icon {
width: 56px;
height: 56px;
color: #16a34a;
margin: 0 auto 18px;
}
.thanks-title {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.thanks-text {
font-size: 14px;
color: var(--text-secondary);
max-width: 480px;
margin: 0 auto 28px;
line-height: 1.55;
}
.thanks-actions {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
/* Back link */
.back-link {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px 6px 10px; border-radius: 8px;
font-size: 13px; font-weight: 500; color: var(--text-tertiary);
text-decoration: none; border: 0.5px solid var(--border-default);
background: var(--bg-primary); flex-shrink: 0;
transition: color .15s, border-color .15s, background .15s;
}
.back-link:hover {
color: var(--primary); border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
}
.back-link svg { opacity: 0.8; }
@media (max-width: 640px) {
.back-link span { display: none; }
.back-link { padding: 6px; }
}

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Návrh nového nástroje | Colsys AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="/static/styles.css">
<link rel="stylesheet" href="/static/extra.css">
<script>
(function () {
var t = null;
try {
var p = new URL(window.location.href).searchParams.get("theme");
if (p === "dark" || p === "light") t = p;
} catch (e) {}
if (!t) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
if (!t) {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (m) t = decodeURIComponent(m[1]);
}
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
</script>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="https://ai.klas.chat" class="brand">
<span class="brand-icon">C</span>
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
</a>
<span class="header-crumb">Návrh nového nástroje</span>
<a href="https://ai.klas.chat" class="back-link" title="Zpět na portál">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6"/>
</svg>
<span>Zpět na portál</span>
</a>
</div>
</header>
<main class="main">
<section id="s-form">
<div class="section-intro">
<h1 class="section-title">Chybí vám nějaký nástroj?</h1>
<p class="section-desc">
Popište, co byste potřebovali. Pokud máte ukázkový soubor
(faktura, smlouva, výkres…), přiložte ho — pomůže nám pochopit
kontext. Návrh se uloží a my se na něj podíváme.
</p>
</div>
<form id="request-form" class="request-form">
<div class="form-row">
<label class="form-label" for="title">Krátký název *</label>
<input type="text" id="title" name="title" class="form-input" required
minlength="3" maxlength="120"
placeholder="např. „Generování zápisů z porad"">
</div>
<div class="form-row">
<label class="form-label" for="description">Popis *</label>
<textarea id="description" name="description" class="form-textarea"
required minlength="10" maxlength="8000" rows="8"
placeholder="Co by tento nástroj měl umět? Jaký problém řeší? Jak ho budete používat?
Klidně i v odrážkách — nemusí být napsané dokonale."></textarea>
</div>
<div class="form-grid">
<div class="form-row">
<label class="form-label" for="name">
Vaše jméno
<span class="form-label-hint">nepovinné</span>
</label>
<input type="text" id="name" name="name" class="form-input"
maxlength="120" autocomplete="name">
</div>
<div class="form-row">
<label class="form-label" for="email">
E-mail
<span class="form-label-hint">nepovinné — pro zpětnou vazbu</span>
</label>
<input type="email" id="email" name="email" class="form-input"
maxlength="200" autocomplete="email"
placeholder="vase.jmeno@colsys.cz">
</div>
</div>
<div class="form-row">
<label class="form-label">
Ukázkový soubor
<span class="form-label-hint">nepovinné · max 25 MB</span>
</label>
<div id="file-drop" class="file-drop">
<span class="file-drop-text" id="file-drop-text">
Přetáhněte soubor sem nebo
<button type="button" class="btn-link" id="file-pick-btn">vyberte z disku</button>
</span>
<input type="file" id="file-input" name="file" style="display:none">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-lg" id="submit-btn">
Odeslat návrh
</button>
<span class="run-hint">Návrhy ukládáme interně — uvidí je jen tým, který portál vyvíjí.</span>
</div>
</form>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Odesílám návrh…</h2>
</div>
</section>
<section id="s-thanks" class="hidden">
<div class="thanks-card">
<svg class="thanks-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="m9 12 2 2 4-4"/>
</svg>
<h2 class="thanks-title">Děkujeme!</h2>
<p class="thanks-text">
Návrh byl uložen. Podíváme se na něj a pokud má smysl, ozveme se
nebo rovnou přidáme nástroj do portálu.
</p>
<div class="thanks-actions">
<a href="https://ai.klas.chat" class="btn btn-primary">Zpět na portál</a>
<button class="btn btn-secondary" id="another-btn" type="button">Odeslat další návrh</button>
</div>
</div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,461 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f2f4f7;
--text-primary: #101828;
--text-secondary: #354052;
--text-tertiary: #676f83;
--text-quaternary: #98a2b2;
--border-default: rgb(16 24 40 / 0.08);
--border-strong: #d0d5dc;
--border-subtle: rgb(16 24 40 / 0.04);
--card: #ffffff;
--primary: #155aef;
--primary-hover: #004aeb;
--accent-indigo: #444ce7;
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
--radius-md: 8px;
--radius-lg: 12px;
}
/* Dark theme — applies when (a) user OS prefers dark and no .light override,
or (b) :root has explicit .dark class (set by portal_theme cookie). */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
}
:root.dark {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
body {
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Header ─────────────────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 30;
border-bottom: 0.5px solid var(--border-default);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */
margin: 0 auto;
height: 56px; /* portal uses h-14 */
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px; /* portal px-4 */
gap: 16px;
}
@media (min-width: 640px) {
.header-inner { padding: 0 32px; } /* portal sm:px-8 */
}
.brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
}
.brand-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%);
box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
letter-spacing: -0.04em;
color: white;
}
.brand-name {
font-size: 14px; /* tailwind text-sm */
font-weight: 600;
letter-spacing: -0.025em; /* tailwind tracking-tight */
color: var(--text-primary);
}
.brand-ai { color: var(--primary); }
.header-crumb {
font-size: 13px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Main ────────────────────────────────────────── */
.main {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px 80px;
display: flex;
flex-direction: column;
gap: 32px;
}
@media (max-width: 640px) {
.header-inner { padding: 0 16px; }
.main { padding: 24px 16px 60px; }
}
/* ── Section intro ───────────────────────────────── */
.section-intro { margin-bottom: 24px; }
.section-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.section-desc {
color: var(--text-tertiary);
max-width: 580px;
font-size: 14px;
line-height: 1.6;
}
/* ── Examples panel ──────────────────────────────── */
.examples-panel {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--card);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 16px;
}
.examples-header {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 12px;
}
.examples-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.examples-subtitle {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.examples-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
margin-bottom: 10px;
}
.examples-list:empty::before {
content: "Zatím žádné vzory — přidejte alespoň jeden níže";
font-size: 12px;
color: var(--text-quaternary);
font-style: italic;
padding: 6px 0;
}
.example-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
color: var(--text-primary);
padding: 4px 4px 4px 10px;
border-radius: 999px;
font-size: 12px;
font-family: ui-monospace, monospace;
}
.example-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-tertiary);
padding: 0;
transition: background 0.1s, color 0.1s;
}
.example-chip-remove:hover {
background: color-mix(in srgb, var(--primary) 18%, transparent);
color: var(--text-primary);
}
.example-chip-remove svg {
width: 12px;
height: 12px;
}
.examples-input-row {
display: flex;
gap: 8px;
}
.example-input {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 13px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
min-width: 0;
}
.example-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.example-add-btn {
flex-shrink: 0;
}
/* ── Drop zone ───────────────────────────────────── */
.drop-zone {
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-lg);
background: var(--card);
padding: 56px 32px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
box-shadow: var(--shadow-card);
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, var(--card));
}
.drop-icon {
width: 44px;
height: 44px;
color: var(--text-quaternary);
margin: 0 auto 18px;
display: block;
transition: color 0.15s;
}
.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon {
color: var(--primary);
}
.drop-text {
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.drop-or {
font-size: 13px;
color: var(--text-quaternary);
margin-bottom: 14px;
}
.drop-formats {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 14px;
}
/* ── Buttons ─────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, box-shadow 0.15s;
line-height: 1;
}
.btn-primary {
background: var(--primary);
color: #fff;
box-shadow: 0 1px 2px rgb(21 90 239 / 0.25);
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-strong);
}
.btn-secondary:hover { background: var(--border-strong); }
.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
/* ── Processing card ─────────────────────────────── */
.processing-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 56px 32px;
text-align: center;
box-shadow: var(--shadow-card);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border-strong);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin: 0 auto 22px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.processing-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-primary);
}
.steps-list {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
margin: 0 auto;
text-align: left;
}
.step-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-tertiary);
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
}
.step-item.active {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
}
.step-item.done { color: #17b26a; }
.step-item.error { color: #d92d20; background: #fef3f2; }
.step-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
/* ── Results ─────────────────────────────────────── */
.results-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.results-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.015em;
margin-bottom: 4px;
}
.results-meta {
font-size: 13px;
color: var(--text-tertiary);
}
.results-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── Table ───────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
background: var(--card);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 500px;
}
thead {
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-default);
}
th {
padding: 10px 14px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
white-space: nowrap;
}
td {
padding: 8px 14px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: var(--bg-secondary); }
td[contenteditable]:focus {
outline: none;
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
box-shadow: inset 0 0 0 1.5px var(--primary);
border-radius: 3px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 500;
}
.badge-rule { background: #d1e0ff; color: #004aeb; }
.badge-llm { background: #d1fae5; color: #065f46; }
@media (prefers-color-scheme: dark) {
.badge-rule { background: #1e3a8a; color: #93c5fd; }
.badge-llm { background: #064e3b; color: #6ee7b7; }
}
.table-hint {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 8px;
}
.hidden { display: none !important; }