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,3 @@
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
LITELLM_API_KEY=sk-...
LLM_MODEL=anthropic/claude-sonnet-4-20250514

12
email-drafter/Dockerfile Normal file
View File

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

View File

@@ -0,0 +1,19 @@
services:
email-drafter:
build: .
container_name: email-drafter
restart: unless-stopped
ports:
- "127.0.0.1:3028:8000"
environment:
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1}
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- localai
networks:
localai:
external: true

150
email-drafter/main.py Normal file
View File

@@ -0,0 +1,150 @@
"""FastAPI: bullet notes → polished business email via Claude Sonnet 4."""
import json
import logging
import os
from typing import Literal
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Email Drafter")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
_client: AsyncOpenAI | None = None
def _get_client() -> AsyncOpenAI:
global _client
if _client is None:
_client = AsyncOpenAI(
base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000/v1"),
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
)
return _client
class GenerateRequest(BaseModel):
notes: str = Field(..., min_length=1, max_length=4000)
recipient: str = ""
signature: str = ""
tone: Literal["formal", "friendly", "firm", "apologetic"] = "formal"
language: Literal["cs", "en"] = "cs"
reply_to: str = "" # Optional original email being replied to
TONE_HINTS = {
"formal": {"cs": "formální, oficiální, profesionální",
"en": "formal, professional, polite"},
"friendly": {"cs": "přátelský, vstřícný, kolegiální",
"en": "friendly, warm, collegial"},
"firm": {"cs": "důrazný, asertivní, věcný, ale zdvořilý",
"en": "firm, assertive, direct yet polite"},
"apologetic": {"cs": "omluvný, smířlivý, akceptující odpovědnost",
"en": "apologetic, conciliatory, owning responsibility"},
}
def _system_prompt(language: str, tone: str) -> str:
tone_hint = TONE_HINTS[tone][language]
if language == "cs":
return f"""Jste asistent píšící obchodní e-maily v češtině.
Vstupem jsou poznámky / odrážky od pisatele. Z nich vytvoříte uhlazený, profesionální e-mail.
Tón: {tone_hint}.
Vraťte POUZE platný JSON v tomto tvaru, žádné markdown obaly:
{{
"subject": "Předmět e-mailu (krátký, výstižný, do 70 znaků)",
"body": "Tělo e-mailu včetně oslovení na začátku a podpisu na konci. Použijte odstavce oddělené prázdným řádkem."
}}
Pravidla:
- Pište v 1. osobě jednotného čísla (jako pisatel sám)
- Oslovení voľte podle kontextu příjemce (Vážený pane / Ahoj / atd.)
- Pokud vstup obsahuje žádost, zformulujte ji jasně a zdvořile
- Nevymýšlejte si fakta, která nejsou ve vstupu
- Pokud je dodán podpis, použijte ho doslovně. Jinak ukončete obecným podpisem typu „S pozdravem,\\nVaše jméno"
- Pokud je dodán původní e-mail (reply_to), navažte na něj — odkažte na to, co píše"""
else:
return f"""You are an assistant writing business emails in English.
The input is bullet notes from the writer. Turn them into a polished, professional email.
Tone: {tone_hint}.
Return ONLY valid JSON in this shape, no markdown wrappers:
{{
"subject": "Email subject (short, to the point, under 70 chars)",
"body": "Email body including greeting at top and signature at end. Use paragraphs separated by blank lines."
}}
Rules:
- Write in first person (as the sender)
- Choose greeting based on recipient context (Dear / Hi / Hello)
- If input contains a request, phrase it clearly and politely
- Do not invent facts not in the input
- If a signature is provided, use it verbatim. Otherwise end with a generic "Best regards,\\nYour name"
- If an original email is provided (reply_to), reference it appropriately"""
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/generate")
async def generate(req: GenerateRequest):
user_parts = [f"POZNÁMKY PISATELE / WRITER NOTES:\n{req.notes.strip()}"]
if req.recipient.strip():
user_parts.append(f"\nPŘÍJEMCE / RECIPIENT:\n{req.recipient.strip()}")
if req.signature.strip():
user_parts.append(f"\nPODPIS / SIGNATURE:\n{req.signature.strip()}")
if req.reply_to.strip():
user_parts.append(
f"\nPŮVODNÍ E-MAIL (pro reply) / ORIGINAL EMAIL TO REPLY TO:\n{req.reply_to.strip()}"
)
try:
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": _system_prompt(req.language, req.tone)},
{"role": "user", "content": "\n".join(user_parts)},
],
temperature=0.3,
max_tokens=2000,
)
except Exception as exc:
logger.exception("LLM call failed")
raise HTTPException(500, f"Generování selhalo: {exc}")
raw = (resp.choices[0].message.content or "").strip()
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
logger.error("JSON parse failed: %s\nraw=%s", exc, raw[:500])
raise HTTPException(500, "Nepodařilo se zpracovat odpověď AI")
return {
"subject": data.get("subject", "").strip(),
"body": data.get("body", "").strip(),
}
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.30
openai>=1.50
python-dotenv>=1.0
pydantic>=2.0

120
email-drafter/static/app.js Normal file
View File

@@ -0,0 +1,120 @@
// Email drafter frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
compose: $("s-compose"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
// ── Persist signature & last-used tone/language in localStorage ──
const PERSIST_KEYS = ["signature", "tone", "language"];
for (const k of PERSIST_KEYS) {
const v = localStorage.getItem("email_" + k);
if (v !== null) $(k).value = v;
}
for (const k of PERSIST_KEYS) {
$(k).addEventListener("change", () =>
localStorage.setItem("email_" + k, $(k).value));
$(k).addEventListener("input", () =>
localStorage.setItem("email_" + k, $(k).value));
}
// ── Reply toggle ──
$("reply-toggle").addEventListener("click", () => {
const t = $("reply-toggle");
const p = $("reply-panel");
const expanded = t.getAttribute("aria-expanded") === "true";
t.setAttribute("aria-expanded", String(!expanded));
p.classList.toggle("hidden", expanded);
});
// ── Hint + button state ──
function updateHint() {
const notes = $("notes").value.trim();
const btn = $("generate-btn");
const hint = $("generate-hint");
if (notes.length < 5) {
btn.disabled = true;
hint.textContent = "Zadejte alespoň krátké poznámky.";
} else {
btn.disabled = false;
hint.textContent = `${notes.length} znaků zadáno. Klikněte pro vygenerování.`;
}
}
$("notes").addEventListener("input", updateHint);
updateHint();
// ── Generate ──
async function generate() {
const payload = {
notes: $("notes").value.trim(),
recipient: $("recipient").value.trim(),
signature: $("signature").value.trim(),
tone: $("tone").value,
language: $("language").value,
reply_to: $("reply-to").value.trim(),
};
if (payload.notes.length < 5) {
alert("Zadejte alespoň krátké poznámky.");
return;
}
show("processing");
try {
const r = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const errBody = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(errBody.detail || r.statusText);
}
const data = await r.json();
$("out-subject").value = data.subject || "";
$("out-body").value = data.body || "";
// Auto-resize body textarea to fit content
const ta = $("out-body");
ta.style.height = "auto";
ta.style.height = Math.max(280, ta.scrollHeight + 4) + "px";
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("compose");
}
}
$("generate-btn").addEventListener("click", generate);
$("regenerate-btn").addEventListener("click", generate);
$("back-btn").addEventListener("click", () => show("compose"));
// ── Copy buttons ──
function copyText(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const original = btn.textContent;
btn.textContent = "Zkopírováno";
btn.classList.add("copied");
setTimeout(() => {
btn.textContent = original;
btn.classList.remove("copied");
}, 1500);
}).catch(() => alert("Kopírování selhalo. Vyberte text a stiskněte Ctrl+C."));
}
document.querySelectorAll(".btn-copy").forEach((btn) => {
btn.addEventListener("click", () => {
const target = $(btn.dataset.target);
copyText(target.value, btn);
});
});
$("copy-all-btn").addEventListener("click", (e) => {
const subject = $("out-subject").value;
const body = $("out-body").value;
const combined = `Předmět: ${subject}\n\n${body}`;
copyText(combined, e.target);
});
})();

View File

@@ -0,0 +1,201 @@
/* Email-drafter specific styles */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 18px;
margin-bottom: 24px;
}
.form-row { display: flex; flex-direction: column; gap: 6px; }
.form-row-full { grid-column: 1 / -1; }
@media (max-width: 720px) {
.form-grid { grid-template-columns: 1fr; }
}
.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);
text-transform: lowercase;
}
.form-input, .form-select, .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-select: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: 120px; }
.form-select { cursor: pointer; }
.reply-toggle {
background: transparent;
border: 1px dashed var(--border-strong);
color: var(--text-secondary);
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
text-align: left;
width: fit-content;
display: flex;
align-items: center;
gap: 8px;
transition: border-color .15s, color .15s;
}
.reply-toggle:hover { border-color: var(--primary); color: var(--primary); }
.reply-toggle[aria-expanded="true"] .reply-toggle-icon { transform: rotate(45deg); }
.reply-toggle-icon {
display: inline-block;
font-size: 16px;
line-height: 1;
transition: transform .2s;
}
.reply-panel { margin-top: 10px; }
.run-row {
display: flex;
align-items: center;
gap: 14px;
padding-top: 16px;
border-top: 1px solid var(--border-default);
flex-wrap: wrap;
}
.btn-lg { padding: 12px 24px; font-size: 14px; font-weight: 600; }
.run-hint { font-size: 13px; color: var(--text-tertiary); }
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 360px;
}
/* Output email card */
.email-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 18px 20px;
margin-top: 16px;
box-shadow: var(--shadow-card);
}
.email-field { display: flex; flex-direction: column; gap: 8px; }
.email-field-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.email-field-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.email-subject-input {
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
}
.email-subject-input:focus {
outline: none;
border-color: var(--primary);
background: var(--bg-primary);
}
.email-body-textarea {
width: 100%;
padding: 14px 16px;
border: 1px solid transparent;
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
line-height: 1.6;
resize: vertical;
min-height: 280px;
white-space: pre-wrap;
}
.email-body-textarea:focus {
outline: none;
border-color: var(--primary);
background: var(--bg-primary);
}
.email-divider {
height: 1px;
background: var(--border-default);
margin: 16px 0;
}
.email-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.btn-copy {
background: transparent;
border: 1px solid var(--border-default);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.btn-copy:hover {
border-color: var(--primary);
color: var(--primary);
}
.btn-copy.copied {
background: rgba(34, 197, 94, 0.12);
border-color: #16a34a;
color: #15803d;
}
/* Back link (shared with other apps) */
.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,181 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Návrh e-mailu | 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 e-mailu</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-compose">
<div class="section-intro">
<h1 class="section-title">Návrh e-mailu z poznámek</h1>
<p class="section-desc">
Zadejte odrážky toho, co chcete napsat. AI z toho udělá uhlazený
profesionální e-mail.
</p>
</div>
<div class="form-grid">
<div class="form-row form-row-full">
<label class="form-label" for="notes">Poznámky / odrážky</label>
<textarea id="notes" class="form-textarea" rows="8" maxlength="4000"
placeholder="Co chcete v e-mailu sdělit? Stačí odrážky, nemusí být v celých větách.
Příklad:
- omlouvám se za pozdní reakci
- dohodli jsme se na termínu 24.5.
- pošlu fakturu do středy
- prosím o potvrzení dodací adresy"></textarea>
</div>
<div class="form-row">
<label class="form-label" for="recipient">
Příjemce / kontext
<span class="form-label-hint">nepovinné</span>
</label>
<input type="text" id="recipient" class="form-input" maxlength="200"
placeholder="např. „CEO partnerské firmy, formální vztah"">
</div>
<div class="form-row">
<label class="form-label" for="signature">
Podpis
<span class="form-label-hint">uloží se pro příště</span>
</label>
<input type="text" id="signature" class="form-input" maxlength="200"
placeholder="např. „S pozdravem, Ondřej Glaser, ředitel"">
</div>
<div class="form-row">
<label class="form-label" for="tone">Tón</label>
<select id="tone" class="form-select">
<option value="formal">Formální</option>
<option value="friendly">Přátelský</option>
<option value="firm">Důrazný</option>
<option value="apologetic">Omluvný</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="language">Jazyk</label>
<select id="language" class="form-select">
<option value="cs">Čeština</option>
<option value="en">Angličtina</option>
</select>
</div>
<div class="form-row form-row-full">
<button class="reply-toggle" id="reply-toggle" type="button" aria-expanded="false">
<span class="reply-toggle-icon">+</span>
Odpovídám na e-mail (vložit původní zprávu)
</button>
<div class="reply-panel hidden" id="reply-panel">
<textarea id="reply-to" class="form-textarea" rows="6" maxlength="6000"
placeholder="Vložte zde celý e-mail, na který odpovídáte. AI ho použije jako kontext pro vaši reakci."></textarea>
</div>
</div>
</div>
<div class="run-row">
<button class="btn btn-primary btn-lg" id="generate-btn" type="button">
Vygenerovat e-mail
</button>
<span class="run-hint" id="generate-hint">Zadejte alespoň krátké poznámky.</span>
</div>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Píšu e-mail…</h2>
<p class="processing-sub">Obvykle 515 sekund.</p>
</div>
</section>
<section id="s-result" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Návrh e-mailu</h2>
<p class="results-meta">Můžete upravit ručně a poté zkopírovat.</p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="back-btn" type="button">
Upravit zadání
</button>
<button class="btn btn-secondary" id="regenerate-btn" type="button">
Vygenerovat znovu
</button>
</div>
</div>
<div class="email-card">
<div class="email-field">
<div class="email-field-row">
<label class="email-field-label" for="out-subject">Předmět</label>
<button class="btn-copy" data-target="out-subject" type="button">Kopírovat</button>
</div>
<input type="text" id="out-subject" class="email-subject-input">
</div>
<div class="email-divider"></div>
<div class="email-field">
<div class="email-field-row">
<label class="email-field-label" for="out-body">Tělo e-mailu</label>
<button class="btn-copy" data-target="out-body" type="button">Kopírovat</button>
</div>
<textarea id="out-body" class="email-body-textarea" rows="20"></textarea>
</div>
<div class="email-actions">
<button class="btn btn-primary" id="copy-all-btn" type="button">
Kopírovat předmět i tělo
</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; }