This commit is contained in:
team3
2026-07-23 16:07:36 +02:00
commit cb68cd671b
88 changed files with 10029 additions and 0 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>creator3</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2106
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "creator3-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@panzoom/panzoom": "^4.6.0",
"@shikijs/markdown-it": "^4.3.1",
"dompurify": "^3.4.7",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"shiki": "^4.3.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}

182
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,182 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import { api, wsVerbinden } from "./api.js";
import { layout, store } from "./store.js";
import Graph from "./components/Graph.vue";
import Guide from "./components/Guide.vue";
import Kennzahlen from "./components/Kennzahlen.vue";
const tab = ref("graph");
const navOffen = ref(false);
const fehler = ref("");
const neuName = ref("");
let wsTrennen = null;
let debounceId = null;
let pollId = null;
const laeuft = computed(() => store.run?.status === "running");
const quellenMax = computed(() => {
const t = store.topics.find((x) => x.name === store.topic);
return t?.quellen_max ?? 24;
});
async function quellenSetzen(ev) {
// Regler-Position 1 = ∞ (API-Wert 0); echte Deckel beginnen bei 2,
// denn 1 Quelle kann nie bestätigen (Konsens braucht 2 unabhängige)
const pos = parseInt(ev.target.value, 10);
const wert = pos === 1 ? 0 : pos;
try {
await api.einstellen(store.topic, wert);
await topicsLaden();
} catch (e) {
fehler.value = String(e);
}
}
async function topicsLaden() {
store.topics = await api.topics();
if (!store.topic && store.topics.length) store.topic = store.topics[0].name;
}
async function graphLaden() {
if (!store.topic) return;
try {
const g = await api.graph(store.topic);
layout.value = g.layout;
store.zaehler = g.zaehler;
store.gates = g.gates;
store.run = g.run;
} catch (e) {
fehler.value = String(e);
}
}
function onWs(ev) {
if (ev.typ === "verbunden") return alles();
clearTimeout(debounceId);
debounceId = setTimeout(() => {
graphLaden();
topicsLaden();
}, 250);
}
async function alles() {
await topicsLaden();
await graphLaden();
}
async function waehlen(name) {
store.topic = name;
navOffen.value = false;
await graphLaden();
}
async function anlegen() {
if (!neuName.value.trim()) return;
try {
await api.anlegen(neuName.value.trim(), "");
neuName.value = "";
await alles();
} catch (e) {
fehler.value = String(e);
}
}
async function aktion(fn) {
fehler.value = "";
try {
await fn(store.topic);
await graphLaden();
} catch (e) {
fehler.value = String(e);
}
}
async function starten() {
let budget = null;
const r = store.run;
const knapp = r && r.budget_tokens > 0 &&
(r.verbraucht || 0) >= r.budget_tokens * 0.9;
if (r?.status === "budget" || (r?.status === "paused" && knapp)) {
const alt = r.budget_tokens || 2000000;
const eingabe = window.prompt(
`Budget ${r.status === "budget" ? "erschöpft" : "fast erschöpft"} ` +
`(${((r.verbraucht || 0) / 1e6).toFixed(1)}M verbraucht). ` +
"Neues Token-Budget für diesen Lauf:",
String(alt + 2000000),
);
if (eingabe === null) return;
budget = parseInt(eingabe, 10) || null;
}
await aktion((t) => api.start(t, budget));
}
async function reset() {
if (!window.confirm(`${store.topic}" komplett zurücksetzen? ` +
"Quellen, Atome und Guide werden gelöscht."))
return;
await aktion(api.reset);
await topicsLaden();
}
onMounted(() => {
alles();
wsTrennen = wsVerbinden(onWs);
pollId = setInterval(() => laeuft.value && graphLaden(), 5000);
});
onUnmounted(() => {
wsTrennen?.();
clearInterval(pollId);
});
</script>
<template>
<div class="app">
<header class="topbar">
<button class="burger" @click="navOffen = !navOffen"></button>
<nav class="tabs">
<button :class="{ aktiv: tab === 'graph' }" @click="tab = 'graph'">Pipeline</button>
<button :class="{ aktiv: tab === 'guide' }" @click="tab = 'guide'">Guide</button>
<button :class="{ aktiv: tab === 'kennzahlen' }" @click="tab = 'kennzahlen'">
Kennzahlen
</button>
</nav>
<span class="spacer" />
<span v-if="store.run" class="runstatus" :data-status="store.run.status">
{{ store.run.status }}<template v-if="store.run.grund"> · {{ store.run.grund }}</template>
</span>
<button v-if="!laeuft" class="primaer" :disabled="!store.topic"
@click="starten()">Start</button>
<button v-else @click="aktion(api.pause)">Pause</button>
<button v-if="!laeuft && store.topic" title="Pipeline auf null zurücksetzen"
@click="reset()">Reset</button>
<label v-if="store.topic" class="quellen-regler"
:title="'Quellen-Deckel — ganz links: unbegrenzt, es stoppt nur die Sättigung'">
<span>Quellen: <b>{{ quellenMax === 0 ? "∞" : quellenMax }}</b></span>
<input type="range" min="1" max="48" step="1"
:value="quellenMax === 0 ? 1 : quellenMax"
:disabled="laeuft" @change="quellenSetzen($event)" />
</label>
</header>
<aside class="sidebar" :class="{ offen: navOffen }">
<form @submit.prevent="anlegen">
<input v-model="neuName" placeholder="Neues Thema…" />
</form>
<ul>
<li v-for="t in store.topics" :key="t.name"
:class="{ aktiv: t.name === store.topic }" @click="waehlen(t.name)">
<span>{{ t.titel || t.name }}</span>
<small>{{ t.status }}</small>
</li>
</ul>
</aside>
<main>
<p v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }}</p>
<Graph v-if="tab === 'graph'" />
<Guide v-else-if="tab === 'guide'" />
<Kennzahlen v-else />
</main>
</div>
</template>

69
frontend/src/api.js Normal file
View File

@@ -0,0 +1,69 @@
// Alle Pfad-Parameter durch encodeURIComponent (creator2-Lektion: '#'/'/'
// im Topic-Namen machten Topics unlöschbar).
const BASE = import.meta.env.DEV ? "http://localhost:8000" : "";
async function req(pfad, opts = {}) {
const r = await fetch(BASE + pfad, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`);
return r.json();
}
const e = encodeURIComponent;
export const api = {
topics: () => req("/api/topics"),
anlegen: (name, titel) =>
req("/api/topics", { method: "POST", body: JSON.stringify({ name, titel }) }),
loeschen: (t) => req(`/api/topics/${e(t)}`, { method: "DELETE" }),
start: (t, budget) =>
req(`/api/topics/${e(t)}/start`, {
method: "POST",
body: JSON.stringify({ budget }),
}),
pause: (t) => req(`/api/topics/${e(t)}/pause`, { method: "POST" }),
stop: (t) => req(`/api/topics/${e(t)}/stop`, { method: "POST" }),
reset: (t) => req(`/api/topics/${e(t)}/reset`, { method: "POST" }),
einstellen: (t, quellen_max) =>
req(`/api/topics/${e(t)}`, {
method: "PATCH",
body: JSON.stringify({ quellen_max }),
}),
graph: (t) => req(`/api/topics/${e(t)}/graph`),
knotenTasks: (t, k) => req(`/api/topics/${e(t)}/knoten/${e(k)}/tasks`),
guide: (t) => req(`/api/topics/${e(t)}/guide`),
kennzahlen: (t) => req(`/api/topics/${e(t)}/kennzahlen`),
};
// Reconnect mit Cleanup-Flag; onopen → synthetisches Event zum Resync.
export function wsVerbinden(onEvent) {
let offen = true;
let ws = null;
function verbinden() {
if (!offen) return;
const url =
(location.protocol === "https:" ? "wss://" : "ws://") +
(import.meta.env.DEV ? "localhost:8000" : location.host) +
"/ws";
try {
ws = new WebSocket(url);
} catch {
setTimeout(verbinden, 2000);
return;
}
ws.onopen = () => onEvent({ typ: "verbunden" });
ws.onmessage = (m) => {
try {
onEvent(JSON.parse(m.data));
} catch {}
};
ws.onclose = () => offen && setTimeout(verbinden, 2000);
}
verbinden();
return () => {
offen = false;
ws?.close();
};
}

View File

@@ -0,0 +1,59 @@
<script setup>
// Einfache Kachel-Ansicht: alle Knoten in Fluss-Reihenfolge,
// Titel + Task-Anzahl als Badge. Klick öffnet das Detail-Panel.
import { computed, ref } from "vue";
import { layout, store } from "../store.js";
import KnotenPanel from "./KnotenPanel.vue";
const gewaehlt = ref("");
const knoten = computed(() => {
if (!layout.value) return [];
return Object.entries(layout.value.knoten)
.map(([id, k]) => ({
id,
...k,
z: store.zaehler[id] || {},
gate: store.gates[id],
}))
.sort((a, b) => a.zeile - b.zeile || a.spalte - b.spalte)
.map((k) => ({
...k,
gesamt: (k.z.offen || 0) + (k.z.laufend || 0) + (k.z.fertig || 0) +
(k.z.fehler || 0),
}));
});
function statusKlasse(k) {
if (k.typ === "gate") return "gate-" + (k.gate?.status || "offen");
if ((k.z.fehler || 0) > 0) return "hat-fehler";
if ((k.z.laufend || 0) > 0) return "laeuft";
if ((k.z.offen || 0) > 0) return "wartet";
if ((k.z.fertig || 0) > 0) return "fertig";
return "";
}
function zahlKlasse(k) {
if (k.z.fehler) return "b-fehler";
if (k.z.laufend) return "b-laufend";
if (k.z.offen) return "b-offen";
return "b-fertig";
}
</script>
<template>
<div class="graph-wrap">
<div class="kacheln">
<button v-for="k in knoten" :key="k.id" class="knoten"
:class="[statusKlasse(k), { gewaehlt: k.id === gewaehlt }]"
@click="gewaehlt = k.id">
<span class="kn-titel">{{ k.titel }}</span>
<span class="kn-beschr">{{ k.beschreibung }}</span>
<span v-if="k.gate" class="kn-stufe">Runde {{ k.gate.runde }}</span>
<b v-if="k.gesamt" class="kn-zahl" :class="zahlKlasse(k)">{{
k.gesamt }}</b>
</button>
</div>
<KnotenPanel v-if="gewaehlt" :knoten="gewaehlt" @zu="gewaehlt = ''" />
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
// Guide-Ansicht (R10): 68ch-Prose, Kapitel-Sections mit content-visibility,
// TOC + IntersectionObserver-Scroll-Spy. HTML einmal rendern und cachen.
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { api } from "../api.js";
import { initMarkdown, render, slugify } from "../markdown.js";
import { store } from "../store.js";
const daten = ref(null);
const bereit = ref(false);
const aktiv = ref("");
const cache = new Map();
let beobachter = null;
let seq = 0;
async function laden() {
const meine = ++seq;
await initMarkdown();
bereit.value = true;
if (!store.topic) return;
const d = await api.guide(store.topic);
if (meine !== seq) return;
cache.clear();
daten.value = d;
await nextTick();
spyAufsetzen();
}
function html(key, text) {
if (!cache.has(key)) cache.set(key, render(text));
return cache.get(key);
}
const toc = computed(
() =>
daten.value?.kapitel.map((k) => ({
id: "kap-" + k.id,
titel: k.titel,
sections: k.sections.map((s) => ({ id: slugify(s.titel), titel: s.titel })),
})) || [],
);
function spyAufsetzen() {
beobachter?.disconnect();
beobachter = new IntersectionObserver(
(eintraege) => {
for (const e of eintraege) if (e.isIntersecting) aktiv.value = e.target.id;
},
{ rootMargin: "0px 0px -70% 0px" },
);
document
.querySelectorAll(".prose section[id]")
.forEach((el) => beobachter.observe(el));
}
function springe(id) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
}
watch(() => store.topic, laden);
watch(
() => store.run?.status,
(neu, alt) => alt === "running" && neu !== "running" && laden(),
);
onMounted(laden);
onUnmounted(() => beobachter?.disconnect());
</script>
<template>
<div class="guide-wrap">
<nav class="toc">
<template v-for="k in toc" :key="k.id">
<a :class="{ aktiv: aktiv === k.id }" @click="springe(k.id)">{{
k.titel
}}</a>
<a v-for="s in k.sections" :key="s.id" class="toc-sub"
:class="{ aktiv: aktiv === s.id }" @click="springe(s.id)">{{
s.titel
}}</a>
</template>
</nav>
<article v-if="daten && bereit" class="prose">
<p v-if="!daten.kapitel.length" class="leer">
Noch kein Guide Lauf starten.
</p>
<section v-for="k in daten.kapitel" :id="'kap-' + k.id" :key="k.id"
class="kapitel">
<h2>{{ k.titel }}</h2>
<p v-if="k.intro" class="intro">{{ k.intro }}</p>
<section v-for="s in k.sections" :id="slugify(s.titel)" :key="s.id">
<h3>{{ s.titel }}</h3>
<div v-html="html(s.id + ':' + (s.text || '').length, s.text)" />
</section>
</section>
</article>
</div>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { api } from "../api.js";
import { store } from "../store.js";
const kz = ref(null);
async function laden() {
if (store.topic) kz.value = await api.kennzahlen(store.topic);
}
watch(() => store.topic, laden);
watch(() => store.run?.status, laden);
onMounted(laden);
function fmtDauer(ms) {
const s = (ms || 0) / 1000;
return s >= 60 ? (s / 60).toFixed(1) + " m" : s.toFixed(1) + " s";
}
function fmtTok(n) {
n = n || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
return String(n);
}
const gesamt = computed(() => {
const g = { calls: 0, ok: 0, fehler: 0, tok_in: 0, tok_out: 0,
cache_read: 0, dur_ms: 0, wait_ms: 0 };
for (const z of kz.value?.zeilen || []) {
for (const k of Object.keys(g)) g[k] += z[k] || 0;
}
return g;
});
</script>
<template>
<div v-if="kz" class="kennzahlen">
<div class="kacheln">
<div class="kachel">
<b>{{ kz.coverage ?? "" }}<small v-if="kz.coverage !== null">%</small></b>
<span>Abdeckung ({{ kz.soll_gedeckt }}/{{ kz.soll_gesamt }} Soll-Punkte)</span>
</div>
<div class="kachel">
<b>{{ kz.redundanz_offen }}</b>
<span>offene Dopplungen</span>
</div>
<div class="kachel">
<b>{{ kz.eskaliert ?? 0 }}</b>
<span>eskalierte Befunde (Grenze dokumentiert)</span>
</div>
<div class="kachel">
<b>{{ kz.atome_aktiv ?? "" }}</b>
<span>aktive Atome ({{ kz.verdichtet_punkte ?? 0 }}/{{ kz.soll_gesamt }}
Punkte verdichtet)</span>
</div>
<div class="kachel">
<b>{{ kz.beleg_abdeckung ?? "" }}<small
v-if="kz.beleg_abdeckung !== null">%</small></b>
<span>Beleg-Abdeckung (Atome nahe jedem Soll-Beleg)</span>
</div>
<div class="kachel">
<b>{{ fmtTok(kz.verbraucht) }}</b>
<span>Tokens<template v-if="kz.budget"> / {{ fmtTok(kz.budget) }} Budget</template></span>
</div>
<div class="kachel">
<b>${{ kz.kosten_usd }}</b>
<span>geschätzte Kosten</span>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Stufe</th><th>Knoten</th><th>Calls</th><th>ok</th><th>Fehler</th>
<th>Tok ein</th><th>Tok aus</th><th>Cache</th><th>Dauer</th><th>Warte</th>
</tr>
</thead>
<tbody>
<tr v-for="(z, i) in kz.zeilen" :key="i">
<td>{{ z.stufe }}</td>
<td>{{ z.knoten }}</td>
<td>{{ z.calls }}</td>
<td>{{ z.ok }}</td>
<td>{{ z.fehler }}</td>
<td>{{ fmtTok(z.tok_in) }}</td>
<td>{{ fmtTok(z.tok_out) }}</td>
<td>{{ fmtTok(z.cache_read) }}</td>
<td>{{ fmtDauer(z.dur_ms) }}</td>
<td>{{ fmtDauer(z.wait_ms) }}</td>
</tr>
<tr v-if="kz.zeilen.length" class="gesamt">
<td>Gesamt</td>
<td></td>
<td>{{ gesamt.calls }}</td>
<td>{{ gesamt.ok }}</td>
<td>{{ gesamt.fehler }}</td>
<td>{{ fmtTok(gesamt.tok_in) }}</td>
<td>{{ fmtTok(gesamt.tok_out) }}</td>
<td>{{ fmtTok(gesamt.cache_read) }}</td>
<td>{{ fmtDauer(gesamt.dur_ms) }}</td>
<td>{{ fmtDauer(gesamt.wait_ms) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
// Seitenpanel: Task-Liste eines Knotens, lazy geladen (kein State-Monolith).
import { computed, ref, watch } from "vue";
import { api } from "../api.js";
import { layout, store } from "../store.js";
const props = defineProps({ knoten: String });
defineEmits(["zu"]);
const info = computed(() => layout.value?.knoten[props.knoten] || {});
const skills = computed(() => info.value.skills || []);
const daten = ref(null);
let seq = 0;
async function laden() {
const meine = ++seq;
const d = await api.knotenTasks(store.topic, props.knoten);
if (meine === seq) daten.value = d;
}
watch(() => props.knoten, laden, { immediate: true });
</script>
<template>
<aside class="panel">
<header>
<strong>{{ info.titel || knoten }}</strong>
<button @click="$emit('zu')">×</button>
</header>
<p v-if="info.beschreibung" class="kn-beschr">{{ info.beschreibung }}</p>
<span v-if="skills.length" class="sk-badges">
<i v-for="s in skills" :key="s.name" :data-art="s.art">{{ s.name }}</i>
</span>
<template v-if="daten">
<h4 v-if="daten.befunde.length">Befunde</h4>
<ul class="befunde">
<li v-for="(b, i) in daten.befunde" :key="i" :data-status="b.status">
<b>{{ b.art }}</b> {{ b.item }}
<small v-if="b.detail">{{ b.detail }}</small>
<em>{{ b.status }}</em>
</li>
</ul>
<h4>Tasks ({{ daten.tasks.length }})</h4>
<ul class="tasks">
<li v-for="t in daten.tasks" :key="t.id" :data-status="t.status">
<code>{{ t.item }}</code>
<em>{{ t.status }}</em>
<small v-if="t.fehler">{{ t.fehler }}</small>
</li>
</ul>
</template>
</aside>
</template>

6
frontend/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import "@fontsource-variable/inter";
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");

61
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,61 @@
// markdown-it (html:false) + anchor + DOMPurify + Shiki Dual-Theme (R10).
// Shiki einmal async initialisieren, danach synchron rendern.
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import anchor from "markdown-it-anchor";
import { fromHighlighter } from "@shikijs/markdown-it/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
export function slugify(s) {
return s
.normalize("NFKC")
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9äöüß-]/g, "");
}
const md = new MarkdownIt({ html: false, linkify: true });
md.use(anchor, { slugify, level: [2, 3] });
// Tabellen scrollen in eigenem Wrapper (Seite scrollt nie horizontal)
md.renderer.rules.table_open = () => '<div class="table-wrap"><table>';
md.renderer.rules.table_close = () => "</table></div>";
let bereit = null;
export function initMarkdown() {
if (bereit) return bereit;
bereit = createHighlighterCore({
themes: [
import("shiki/themes/github-light.mjs"),
import("shiki/themes/github-dark.mjs"),
],
langs: [
import("shiki/langs/php.mjs"),
import("shiki/langs/twig.mjs"),
import("shiki/langs/yaml.mjs"),
import("shiki/langs/bash.mjs"),
import("shiki/langs/sql.mjs"),
import("shiki/langs/json.mjs"),
import("shiki/langs/xml.mjs"),
import("shiki/langs/javascript.mjs"),
import("shiki/langs/python.mjs"),
],
engine: createJavaScriptRegexEngine(),
}).then((hl) => {
md.use(
fromHighlighter(hl, {
themes: { light: "github-light", dark: "github-dark" },
defaultLanguage: "text",
fallbackLanguage: "text",
}),
);
});
return bereit;
}
export function render(text) {
const ohneMarker = (text || "").replace(/<!--\s*atom:\s*\d+\s*-->/g, "");
return DOMPurify.sanitize(md.render(ohneMarker));
}

13
frontend/src/store.js Normal file
View File

@@ -0,0 +1,13 @@
// Zentraler Zustand: Zähler-Snapshot (reaktiv) + statische Graphstruktur
// (shallowRef — nur die Zähler patchen, kein Full-Redraw, R7).
import { reactive, shallowRef } from "vue";
export const store = reactive({
topics: [],
topic: "",
zaehler: {},
gates: {},
run: null,
});
export const layout = shallowRef(null);

267
frontend/src/style.css Normal file
View File

@@ -0,0 +1,267 @@
/* Custom-Properties-Themes (dark default), EIN Breakpoint 768px (R1/R10). */
:root {
--bg: #14161a;
--bg2: #1c1f25;
--rand: #2b303a;
--text: #d8dce3;
--text2: #8b93a1;
--akzent: #5b9dff;
--gruen: #3fb47d;
--gelb: #d9a441;
--rot: #d9534f;
--lese-text: #cfd4dc;
}
:root[data-theme="light"] {
--bg: #fafafa;
--bg2: #ffffff;
--rand: #dfe3e8;
--text: #23272e;
--text2: #667085;
--lese-text: #2b3037;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: "Inter Variable", system-ui, sans-serif;
font-size: 15px;
}
button {
background: var(--bg2);
color: var(--text);
border: 1px solid var(--rand);
border-radius: 6px;
padding: 0.35rem 0.7rem;
cursor: pointer;
}
button.primaer { background: var(--akzent); color: #fff; border: none; }
input {
background: var(--bg2);
color: var(--text);
border: 1px solid var(--rand);
border-radius: 6px;
padding: 0.4rem 0.6rem;
width: 100%;
}
.app {
display: grid;
grid-template: "top top" auto "side main" 1fr / 230px 1fr;
height: 100%;
}
.topbar {
grid-area: top;
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0.9rem;
border-bottom: 1px solid var(--rand);
background: var(--bg2);
}
.topbar .spacer { flex: 1; }
.tabs { display: flex; gap: 0.3rem; }
.tabs button { border: none; background: none; color: var(--text2); }
.tabs button.aktiv { color: var(--text); border-bottom: 2px solid var(--akzent); border-radius: 0; }
.burger { display: none; }
.runstatus { font-size: 0.8rem; color: var(--text2); max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.quellen-regler { display: flex; align-items: center; gap: 0.45rem; font-size: 0.8rem; color: var(--text2); }
.quellen-regler input { width: 110px; accent-color: var(--akzent); }
.quellen-regler b { color: var(--text); }
.runstatus[data-status="running"] { color: var(--gruen); }
.runstatus[data-status="paused"], .runstatus[data-status="budget"] { color: var(--gelb); }
.runstatus[data-status="failed"] { color: var(--rot); }
.sidebar {
grid-area: side;
border-right: 1px solid var(--rand);
padding: 0.7rem;
overflow-y: auto;
}
.sidebar ul { list-style: none; margin: 0.7rem 0; padding: 0; }
.sidebar li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.5rem;
border-radius: 6px;
cursor: pointer;
}
.sidebar li.aktiv { background: var(--bg2); }
.sidebar small { color: var(--text2); }
main { grid-area: main; overflow: auto; position: relative; }
.fehler {
margin: 0.6rem;
padding: 0.5rem 0.8rem;
background: color-mix(in srgb, var(--rot) 18%, transparent);
border: 1px solid var(--rot);
border-radius: 6px;
cursor: pointer;
}
/* ── Knoten-Kacheln ── */
.graph-wrap { display: flex; height: 100%; }
.kacheln {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 0.7rem;
padding: 1rem;
align-content: start;
}
.knoten {
position: relative;
min-height: 96px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
padding: 0.65rem 0.75rem;
text-align: left;
border-radius: 9px;
border: 1.5px solid var(--rand);
background: var(--bg2);
}
.kn-beschr { font-size: 0.74rem; color: var(--text2); line-height: 1.35; }
.knoten .kn-titel { font-size: 0.9rem; padding-right: 2.2rem; }
.kn-zahl {
position: absolute;
right: 0.6rem;
top: 0.6rem;
font-size: 0.74rem;
font-weight: 600;
padding: 0 0.4rem;
border-radius: 8px;
}
.panel .kn-beschr { margin: 0.35rem 0 0; }
.knoten.gewaehlt { outline: 2px solid var(--akzent); }
.knoten.laeuft { border-color: var(--akzent); }
.knoten.wartet { border-color: var(--text2); }
.knoten.fertig { border-color: var(--gruen); }
.knoten.hat-fehler { border-color: var(--rot); }
.knoten.gate-gruen { border-color: var(--gruen); background: color-mix(in srgb, var(--gruen) 12%, var(--bg2)); }
.knoten.gate-rot { border-color: var(--rot); background: color-mix(in srgb, var(--rot) 12%, var(--bg2)); }
.kn-titel { font-weight: 600; font-size: 0.82rem; }
.kn-stufe { font-size: 0.68rem; color: var(--text2); }
.sk-badges { display: flex; flex-wrap: wrap; gap: 3px 4px; margin: 0.5rem 0 0.2rem; }
.sk-badges i {
font-style: normal;
font-size: 0.68rem;
line-height: 1.6;
padding: 0 0.4rem;
border-radius: 7px;
border: 1px solid var(--rand);
color: var(--text2);
white-space: nowrap;
}
.sk-badges i[data-art="rolle"] { border-color: var(--akzent); color: var(--akzent); }
.sk-badges i[data-art="aufgabe"] { border-color: var(--gruen); color: var(--gruen); }
.sk-badges i[data-art="stil"] { border-color: var(--gelb); color: var(--gelb); }
.b-offen { background: var(--rand); }
.b-laufend { background: var(--akzent); color: #fff; }
.b-fertig { background: var(--gruen); color: #fff; }
.b-fehler { background: var(--rot); color: #fff; }
.panel {
width: 330px;
border-left: 1px solid var(--rand);
overflow-y: auto;
padding: 0.7rem;
background: var(--bg2);
}
.panel header { display: flex; justify-content: space-between; align-items: center; }
.panel ul { list-style: none; padding: 0; }
.panel li {
padding: 0.35rem 0.4rem;
border-bottom: 1px solid var(--rand);
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.8rem;
}
.panel li em { margin-left: auto; color: var(--text2); font-style: normal; }
.panel li[data-status="fehler"] em, .panel li[data-status="offen"] em { color: var(--rot); }
.panel li[data-status="fertig"] em, .panel li[data-status="behoben"] em { color: var(--gruen); }
.panel small { flex-basis: 100%; color: var(--text2); }
/* ── Guide (Lesetypografie, R10) ── */
.guide-wrap { display: flex; gap: 1rem; height: 100%; }
.toc {
width: 230px;
flex-shrink: 0;
position: sticky;
top: 0;
align-self: flex-start;
max-height: 100%;
overflow-y: auto;
padding: 1rem 0.5rem;
display: flex;
flex-direction: column;
}
.toc a { padding: 0.25rem 0.5rem; color: var(--text2); cursor: pointer; border-radius: 5px; font-size: 0.85rem; }
.toc a.toc-sub { padding-left: 1.3rem; font-size: 0.8rem; }
.toc a.aktiv { color: var(--text); background: var(--bg2); }
.prose {
max-width: 68ch;
margin-inline: auto;
padding: 1.5rem 1rem 5rem;
font-size: clamp(1rem, 0.95rem + 0.3vw, 1.125rem);
line-height: 1.65;
color: var(--lese-text);
}
.prose h2, .prose h3 { line-height: 1.25; margin: 2em 0 0.6em; scroll-margin-top: 5rem; color: var(--text); }
.prose .intro { color: var(--text2); font-style: italic; }
.prose section.kapitel { content-visibility: auto; contain-intrinsic-size: auto 1200px; }
.prose a { color: var(--akzent); }
.prose pre { overflow-x: auto; padding: 1rem; border-radius: 8px; font-size: 0.875em; line-height: 1.5; }
.prose :not(pre) > code { background: var(--bg2); padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; }
.table-wrap { overflow-x: auto; }
.table-wrap table { border-collapse: collapse; width: 100%; }
.table-wrap th, .table-wrap td { border: 1px solid var(--rand); padding: 0.4rem 0.6rem; max-width: 40ch; overflow-wrap: anywhere; text-align: left; }
.table-wrap tr.gesamt td { font-weight: 600; background: var(--bg2); }
.leer { color: var(--text2); text-align: center; margin-top: 4rem; }
:root:not([data-theme="light"]) .prose .shiki,
:root:not([data-theme="light"]) .prose .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
/* ── Kennzahlen ── */
.kennzahlen { padding: 1rem; max-width: 900px; margin-inline: auto; }
.kacheln { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0.7rem; margin-bottom: 1.2rem; }
.kachel { background: var(--bg2); border: 1px solid var(--rand); border-radius: 9px; padding: 0.8rem; display: flex; flex-direction: column; gap: 0.2rem; }
.kachel b { font-size: 1.5rem; }
.kachel span { color: var(--text2); font-size: 0.8rem; }
.kennzahlen th, .kennzahlen td { font-size: 0.82rem; white-space: nowrap; }
/* ── Mobil ── */
@media (max-width: 768px) {
.app { grid-template: "top" auto "main" 1fr / 1fr; }
.burger { display: block; }
.sidebar {
position: fixed;
z-index: 20;
top: 0;
left: 0;
bottom: 0;
width: min(85vw, 300px);
background: var(--bg);
transform: translateX(-100%);
transition: transform 0.2s;
}
.sidebar.offen { transform: none; }
.toc { display: none; }
.panel { position: fixed; z-index: 15; right: 0; top: 48px; bottom: 0; width: min(90vw, 330px); }
.runstatus { display: none; }
}
@media print {
.topbar, .sidebar, .toc, .panel { display: none !important; }
.prose { max-width: none; }
.prose h2, .prose h3 { break-after: avoid; }
.prose pre, .table-wrap { break-inside: avoid; }
.prose p { orphans: 3; widows: 3; }
}

6
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
});