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

3
translator/.env.example Normal file
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
translator/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:
translator:
build: .
container_name: translator
restart: unless-stopped
ports:
- "127.0.0.1:3031: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
translator/main.py Normal file
View File

@@ -0,0 +1,150 @@
"""FastAPI: source text → translated text via Claude Sonnet 4.
Supports auto-detection of source language and a configurable tone.
"""
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="Translator")
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
# Display name (Czech) and English label for the LLM prompt.
LANGUAGES = [
{"code": "auto", "cs": "Automaticky rozpoznat", "en": "auto-detect"},
{"code": "cs", "cs": "Čeština", "en": "Czech"},
{"code": "en", "cs": "Angličtina", "en": "English"},
{"code": "sk", "cs": "Slovenština", "en": "Slovak"},
{"code": "de", "cs": "Němčina", "en": "German"},
{"code": "pl", "cs": "Polština", "en": "Polish"},
{"code": "uk", "cs": "Ukrajinština", "en": "Ukrainian"},
{"code": "ru", "cs": "Ruština", "en": "Russian"},
{"code": "fr", "cs": "Francouzština", "en": "French"},
{"code": "it", "cs": "Italština", "en": "Italian"},
{"code": "es", "cs": "Španělština", "en": "Spanish"},
{"code": "pt", "cs": "Portugalština", "en": "Portuguese"},
{"code": "nl", "cs": "Nizozemština", "en": "Dutch"},
{"code": "hu", "cs": "Maďarština", "en": "Hungarian"},
{"code": "ro", "cs": "Rumunština", "en": "Romanian"},
{"code": "bg", "cs": "Bulharština", "en": "Bulgarian"},
{"code": "tr", "cs": "Turečtina", "en": "Turkish"},
{"code": "zh", "cs": "Čínština", "en": "Chinese (Simplified)"},
{"code": "ja", "cs": "Japonština", "en": "Japanese"},
{"code": "ar", "cs": "Arabština", "en": "Arabic"},
]
LANG_BY_CODE = {l["code"]: l for l in LANGUAGES}
TONES = {
"formal": "formal, professional, polished business register",
"casual": "casual, friendly, conversational",
"technical": "technical, precise, preserving exact technical terms",
"marketing": "marketing copy — engaging, persuasive, brand-voice",
"legal": "legal / contractual — precise, neutral, preserving legal terms of art",
}
class TranslateRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=20000)
source_lang: str = "auto"
target_lang: str = "en"
tone: Literal["formal", "casual", "technical", "marketing", "legal"] = "formal"
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.get("/api/languages")
async def languages():
return {"languages": LANGUAGES}
@app.post("/api/translate")
async def translate(req: TranslateRequest):
target = LANG_BY_CODE.get(req.target_lang)
if not target or target["code"] == "auto":
raise HTTPException(400, "Vyberte cílový jazyk")
source_label = (
"auto-detect the source language"
if req.source_lang == "auto"
else f"the source language is {LANG_BY_CODE.get(req.source_lang, {}).get('en', req.source_lang)}"
)
system = f"""You are a professional translator producing high-quality business translations.
Translate the user's input into **{target['en']}**.
{source_label.capitalize()}.
Tone: **{TONES[req.tone]}**.
Strict rules:
- Output ONLY the translated text. No quotes, no preamble, no explanation, no language tag.
- Preserve formatting: line breaks, lists, paragraphs, code blocks, URLs.
- Keep proper names, brand names, product codes, and acronyms unchanged unless they have a well-established translation in the target language.
- Numbers, dates, and currencies: convert format conventions to the target language (e.g. decimal comma vs dot, date format) but do NOT convert values.
- If the input is already in the target language, return it unchanged.
- Never add information that isn't in the source."""
try:
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": req.text},
],
temperature=0.2,
max_tokens=8000,
)
except Exception as exc:
logger.exception("LLM call failed")
raise HTTPException(500, f"Překlad selhal: {exc}")
translated = (resp.choices[0].message.content or "").strip()
# Strip accidental markdown code fences
if translated.startswith("```") and translated.endswith("```"):
translated = translated.removeprefix("```").removesuffix("```").strip()
return {
"translated": translated,
"source_lang": req.source_lang,
"target_lang": req.target_lang,
"tone": req.tone,
}
@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

148
translator/static/app.js Normal file
View File

@@ -0,0 +1,148 @@
// Translator frontend
(() => {
const $ = (id) => document.getElementById(id);
// ── Populate language pickers ──
async function loadLanguages() {
try {
const r = await fetch("/api/languages");
const data = await r.json();
const srcSel = $("source-lang");
const tgtSel = $("target-lang");
for (const lang of data.languages) {
const o1 = document.createElement("option");
o1.value = lang.code;
o1.textContent = lang.cs;
srcSel.appendChild(o1);
if (lang.code === "auto") continue;
const o2 = document.createElement("option");
o2.value = lang.code;
o2.textContent = lang.cs;
tgtSel.appendChild(o2);
}
// Persisted prefs
srcSel.value = localStorage.getItem("trans_source") || "auto";
tgtSel.value = localStorage.getItem("trans_target") || "en";
$("tone").value = localStorage.getItem("trans_tone") || "formal";
updateSwapEnabled();
} catch (err) {
console.error("Failed to load languages", err);
}
}
function updateSwapEnabled() {
// Can only swap when source is a specific language (not auto)
$("swap-btn").disabled = $("source-lang").value === "auto";
}
// Persist on change
$("source-lang").addEventListener("change", () => {
localStorage.setItem("trans_source", $("source-lang").value);
updateSwapEnabled();
});
$("target-lang").addEventListener("change", () =>
localStorage.setItem("trans_target", $("target-lang").value));
$("tone").addEventListener("change", () =>
localStorage.setItem("trans_tone", $("tone").value));
$("swap-btn").addEventListener("click", () => {
const src = $("source-lang").value;
const tgt = $("target-lang").value;
if (src === "auto") return;
$("source-lang").value = tgt;
$("target-lang").value = src;
// Also swap text content if both panels have content
const sourceText = $("source-text").value;
const outputText = $("output-text").value;
if (outputText.trim()) {
$("source-text").value = outputText;
$("output-text").value = sourceText;
updateCount();
}
localStorage.setItem("trans_source", $("source-lang").value);
localStorage.setItem("trans_target", $("target-lang").value);
});
// ── Source text handling ──
const sourceTextEl = $("source-text");
const outputTextEl = $("output-text");
function updateCount() {
const n = sourceTextEl.value.length;
$("source-count").textContent = `${n.toLocaleString("cs-CZ")} znaků`;
updateRunButton();
}
function updateRunButton() {
const hasText = sourceTextEl.value.trim().length > 0;
$("translate-btn").disabled = !hasText;
$("translate-hint").textContent = hasText
? "Připraveno k překladu."
: "Vložte text výše.";
}
sourceTextEl.addEventListener("input", updateCount);
updateCount();
$("clear-btn").addEventListener("click", () => {
sourceTextEl.value = "";
outputTextEl.value = "";
$("copy-btn").disabled = true;
updateCount();
});
// ── Translate ──
$("translate-btn").addEventListener("click", async () => {
const text = sourceTextEl.value.trim();
if (!text) return;
$("translate-btn").disabled = true;
$("translate-loading").classList.remove("hidden");
outputTextEl.value = "";
try {
const r = await fetch("/api/translate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
text,
source_lang: $("source-lang").value,
target_lang: $("target-lang").value,
tone: $("tone").value,
}),
});
if (!r.ok) {
const errBody = await r.json().catch(() => ({detail: r.statusText}));
throw new Error(errBody.detail || r.statusText);
}
const data = await r.json();
outputTextEl.value = data.translated || "";
$("copy-btn").disabled = !data.translated;
} catch (err) {
alert("Chyba: " + err.message);
} finally {
$("translate-loading").classList.add("hidden");
$("translate-btn").disabled = false;
}
});
// ── Copy ──
$("copy-btn").addEventListener("click", (e) => {
const btn = e.target;
navigator.clipboard.writeText(outputTextEl.value).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."));
});
// Keyboard shortcut: Ctrl/Cmd+Enter in source area to translate
sourceTextEl.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!$("translate-btn").disabled) $("translate-btn").click();
}
});
loadLanguages();
})();

215
translator/static/extra.css Normal file
View File

@@ -0,0 +1,215 @@
/* Translator-specific styles */
/* Slightly wider main column for side-by-side panels */
.main-wide { max-width: 1200px; }
.translate-controls {
display: flex;
align-items: end;
gap: 14px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
}
.control-group-flex { flex: 1; min-width: 200px; }
.form-label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-select {
padding: 8px 10px;
border: 1px solid var(--border-default);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.swap-btn {
background: var(--bg-primary);
border: 1px solid var(--border-default);
color: var(--text-secondary);
width: 38px;
height: 38px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
margin-bottom: 0;
transition: color .15s, border-color .15s;
}
.swap-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.swap-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.translate-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 800px) {
.translate-grid { grid-template-columns: 1fr; }
}
.translate-panel {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.translate-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--border-default);
background: var(--bg-secondary);
}
.translate-panel-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
}
.translate-panel-actions {
display: flex;
gap: 10px;
align-items: center;
}
.char-count {
font-size: 11px;
color: var(--text-quaternary);
font-variant-numeric: tabular-nums;
}
.translate-textarea {
flex: 1;
width: 100%;
min-height: 360px;
padding: 16px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 14.5px;
font-family: inherit;
line-height: 1.6;
resize: vertical;
white-space: pre-wrap;
}
.translate-textarea:focus {
outline: none;
}
.translate-loading {
position: absolute;
inset: 38px 0 0 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: color-mix(in srgb, var(--card) 90%, transparent);
backdrop-filter: blur(2px);
color: var(--text-secondary);
font-size: 13px;
}
.spinner-sm {
width: 18px;
height: 18px;
border: 2px solid var(--border-strong);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.btn-link {
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-family: inherit;
}
.btn-link:hover { color: var(--primary); background: color-mix(in srgb, var(--primary) 6%, transparent); }
.btn-copy {
background: transparent;
border: 1px solid var(--border-default);
color: var(--text-secondary);
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.btn-copy:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.btn-copy:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-copy.copied {
background: rgba(34, 197, 94, 0.12);
border-color: #16a34a;
color: #15803d;
}
.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 26px; font-size: 14px; font-weight: 600; }
.run-hint { font-size: 13px; color: var(--text-tertiary); }
/* Back link (shared pattern) */
.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,135 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Překladač | 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">Překladač</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 main-wide">
<section>
<div class="section-intro">
<h1 class="section-title">Firemní překladač</h1>
<p class="section-desc">
Překlad s ohledem na firemní tón a kontext. Zachovává formátování,
názvy značek a technické termíny. Automatické rozpoznání zdrojového jazyka.
</p>
</div>
<!-- Controls bar -->
<div class="translate-controls">
<div class="control-group">
<label class="form-label" for="source-lang">Z</label>
<select id="source-lang" class="form-select"></select>
</div>
<button class="swap-btn" id="swap-btn" type="button" title="Prohodit jazyky">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/>
<path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
</button>
<div class="control-group">
<label class="form-label" for="target-lang">Do</label>
<select id="target-lang" class="form-select"></select>
</div>
<div class="control-group control-group-flex">
<label class="form-label" for="tone">Tón</label>
<select id="tone" class="form-select">
<option value="formal">Formální (obchodní)</option>
<option value="casual">Neformální</option>
<option value="technical">Technický</option>
<option value="marketing">Marketing</option>
<option value="legal">Právní</option>
</select>
</div>
</div>
<!-- Two-column translation panels -->
<div class="translate-grid">
<div class="translate-panel">
<div class="translate-panel-header">
<span class="translate-panel-label">Zdrojový text</span>
<div class="translate-panel-actions">
<span class="char-count" id="source-count">0 znaků</span>
<button class="btn-link" id="clear-btn" type="button">Vymazat</button>
</div>
</div>
<textarea id="source-text" class="translate-textarea"
placeholder="Vložte text k překladu…" maxlength="20000"></textarea>
</div>
<div class="translate-panel">
<div class="translate-panel-header">
<span class="translate-panel-label">Překlad</span>
<div class="translate-panel-actions">
<button class="btn-copy" id="copy-btn" type="button" disabled>Kopírovat</button>
</div>
</div>
<textarea id="output-text" class="translate-textarea"
placeholder="Zde se zobrazí překlad…"></textarea>
<div class="translate-loading hidden" id="translate-loading">
<div class="spinner-sm"></div>
<span>Překládám…</span>
</div>
</div>
</div>
<div class="run-row">
<button class="btn btn-primary btn-lg" id="translate-btn" type="button" disabled>
Přeložit
</button>
<span class="run-hint" id="translate-hint">Vložte text výše.</span>
</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; }