frontend: Projektgerüst mit Vite — Single-HTML in ES-Module + Bündelung

Erster Schritt der Modularisierung (ROADMAP „tragfähige Codebasis",
TASKS Phase 1). Der bisher einzelne index.html-Prototyp wird zur Vite-App:

- CSS -> src/style.css, gesamtes JS -> src/app.js (noch als ein Modul;
  Parser/Renderer werden in den folgenden Checkboxen herausgelöst).
- index.html ist der Vite-Entry (<script type="module" src="/src/app.js">).
- npm run build (vite + vite-plugin-singlefile) inlint JS+CSS+Favicon (als
  data:-URI) in EINE self-contained dist/index.html -> bleibt file://-tauglich.
- Vitest als Testrunner eingerichtet (tests/, passWithNoTests bis Schritt 2/3).
- Pages-Workflow: Node + npm ci + npm test + npm run build, deployt dist/;
  Favicon-sed entfällt (inline), nur ../LICENSE + Footer-Version bleiben.
- .claude/launch.json auf Vite-Dev-Server (Port 8137) umgestellt.

Entscheidung als D19 dokumentiert (weicht D11 „kein Build-Zwang" bewusst auf,
hält aber D16 file://-Versprechen über die gebündelte Datei). README (de/en)
und frontend/CLAUDE.md um Dev-/Build-/Test-Workflow ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-21 20:47:23 +02:00
co-authored by Claude Opus 4.8
parent ad57707dbc
commit 78d1000f61
14 changed files with 3938 additions and 1883 deletions
+26 -23
View File
@@ -35,25 +35,27 @@ jobs:
# abgeleitet (Standard wäre ein flacher Klon mit nur einem Commit). # abgeleitet (Standard wäre ein flacher Klon mit nur einem Commit).
fetch-depth: 0 fetch-depth: 0
# Platzhalter für spätere Vitest-Läufe (Tests existieren noch nicht, # Node einrichten und den Vite-Build fahren (D19): der Editor liegt jetzt
# siehe docs/TASKS.md, Phase 1). Bei Aktivierung: Node einrichten und # als ES-Module unter frontend/src/, Vite bündelt sie zu EINER
# Tests ausführen — schlägt der Testlauf fehl, wird nicht deployt. # self-contained frontend/dist/index.html (JS+CSS+Favicon inline). Tests
# - name: Node einrichten # laufen zuerst — schlägt Vitest fehl, wird nicht deployt.
# uses: actions/setup-node@v4 - name: Node einrichten
# with: uses: actions/setup-node@v4
# node-version: 20 with:
# cache: npm node-version: 20
# - name: Abhängigkeiten installieren cache: npm
# run: npm ci cache-dependency-path: frontend/package-lock.json
# - name: Tests - name: Abhängigkeiten installieren
# run: npm test run: npm ci --prefix frontend
- name: Tests
run: npm test --prefix frontend
- name: Build (Vite)
run: npm run build --prefix frontend
# Site-Ordner zusammenstellen: index.html an die Wurzel-URL, dazu die # Site-Ordner zusammenstellen: die gebaute dist/index.html an die
# vom Editor referenzierten Dateien (Favicon aus docs/brand/, LICENSE für # Wurzel-URL, dazu LICENSE für den MIT-Link im Footer. Das Favicon ist im
# den MIT-Link im Footer). Die Editor-Quelle bleibt unverändert — die # Build bereits als data:-URI inline (kein docs/brand-Kopieren mehr nötig).
# ../-Pfade werden nur auf der Kopie im Site-Ordner geradegezogen, weil # Im Footer werden gesetzt:
# sie von der Wurzel-URL aus sonst über die Site hinauszeigen würden.
# Zusätzlich werden im Footer gesetzt:
# - die Versionsnummer: Major.Minor aus der VERSION-Datei (per # - die Versionsnummer: Major.Minor aus der VERSION-Datei (per
# Bump-Commit gepflegt), die Micro-Stelle aus der Anzahl der Commits # Bump-Commit gepflegt), die Micro-Stelle aus der Anzahl der Commits
# seit dem letzten VERSION-Bump — steigt also je Commit und beginnt # seit dem letzten VERSION-Bump — steigt also je Commit und beginnt
@@ -61,6 +63,9 @@ jobs:
# - der Link der Versionsnummer: zeigt exakt auf den deployten Commit # - der Link der Versionsnummer: zeigt exakt auf den deployten Commit
# (…/commit/<sha>). „Werkbaum" selbst verlinkt weiter die Repo- # (…/commit/<sha>). „Werkbaum" selbst verlinkt weiter die Repo-
# Startseite. Braucht die volle Historie (fetch-depth: 0). # Startseite. Braucht die volle Historie (fetch-depth: 0).
# Der ../LICENSE-Link ist eine Laufzeit-Verknüpfung (kein Vite-Asset) und
# zeigt von der Wurzel-URL aus sonst über die Site hinaus — daher hier auf
# der Kopie geradegezogen.
- name: Site zusammenstellen - name: Site zusammenstellen
run: | run: |
MAJORMINOR="$(tr -d '[:space:]' < VERSION)" MAJORMINOR="$(tr -d '[:space:]' < VERSION)"
@@ -70,13 +75,11 @@ jobs:
BUILD_VERSION="${MAJORMINOR}.${MICRO}" BUILD_VERSION="${MAJORMINOR}.${MICRO}"
COMMIT_URL="https://github.com/mhoennig/werkbaum/commit/$(git rev-parse HEAD)" COMMIT_URL="https://github.com/mhoennig/werkbaum/commit/$(git rev-parse HEAD)"
echo "Footer-Version: ${BUILD_VERSION} -> ${COMMIT_URL}" echo "Footer-Version: ${BUILD_VERSION} -> ${COMMIT_URL}"
mkdir -p site/docs/brand mkdir -p site
sed -e 's#\.\./docs/brand/#docs/brand/#g' \ sed -e 's#\.\./LICENSE#LICENSE#g' \
-e 's#\.\./LICENSE#LICENSE#g' \
-e "s#\(<a class=\"ver\" href=\"\)[^\"]*#\1${COMMIT_URL}#" \ -e "s#\(<a class=\"ver\" href=\"\)[^\"]*#\1${COMMIT_URL}#" \
-e "s#\(<a class=\"ver\"[^>]*>\)[0-9.]\+</a>#\1${BUILD_VERSION}</a>#" \ -e "s#\(<a class=\"ver\"[^>]*>\)[0-9.]\+</a>#\1${BUILD_VERSION}</a>#" \
frontend/index.html > site/index.html frontend/dist/index.html > site/index.html
cp docs/brand/* site/docs/brand/
cp LICENSE site/LICENSE cp LICENSE site/LICENSE
- name: Pages-Konfiguration - name: Pages-Konfiguration
+28 -10
View File
@@ -34,10 +34,27 @@ der sie als Diagramm rendert.
![Werkbaum-Editor: Live-Diagramm oben, Textnotation darunter, mit Statusfarben, T-Shirt-Größen, Tags und Export-Schaltflächen](docs/screenshot.png) ![Werkbaum-Editor: Live-Diagramm oben, Textnotation darunter, mit Statusfarben, T-Shirt-Größen, Tags und Export-Schaltflächen](docs/screenshot.png)
Den [gehosteten Editor](https://mhoennig.github.io/werkbaum/) öffnen — oder Den [gehosteten Editor](https://mhoennig.github.io/werkbaum/) öffnen — links
`frontend/index.html` im Browser — links Text bearbeiten, rechts entsteht das Text bearbeiten, rechts entsteht das Diagramm live. Toggles: transponierte
Diagramm live. Toggles: transponierte (schmale) Darstellung, verworfene (schmale) Darstellung, verworfene Elemente einblenden.
Elemente einblenden.
### Lokal ausführen
Die Editor-Quelle liegt jetzt als ES-Module unter `frontend/src/`, gebündelt mit
[Vite](https://vitejs.dev/) (siehe `docs/DECISIONS.md` D19). Da Browser
ES-Modul-Importe über `file://` blocken, funktioniert das direkte Öffnen von
`frontend/index.html` nicht mehr — stattdessen:
```bash
cd frontend
npm install # einmalig
npm run dev # Dev-Server unter http://localhost:8137
npm test # Vitest-Unit-Tests
npm run build # -> frontend/dist/index.html (eine self-contained Datei)
```
Die gebaute `dist/index.html` inlint JS, CSS und Favicon — **diese** Datei öffnet
also standalone per `file://` und ist zugleich das, was deployt wird.
## Projektdokumente ## Projektdokumente
@@ -56,12 +73,13 @@ Der Editor wird per GitHub Actions als statische Seite auf **GitHub Pages**
veröffentlicht (Workflow: `.github/workflows/pages.yml`). Ausgelöst bei jedem veröffentlicht (Workflow: `.github/workflows/pages.yml`). Ausgelöst bei jedem
Push auf `main` sowie manuell (`workflow_dispatch`). Push auf `main` sowie manuell (`workflow_dispatch`).
Der Workflow stellt einen Site-Ordner zusammen: `frontend/index.html` liegt als Der Workflow richtet Node ein, führt `npm ci`, `npm test` (Vitest) und
`index.html` an der Wurzel-URL, dazu die vom Editor referenzierten Dateien `npm run build` (Vite) aus und veröffentlicht die gebündelte
(`docs/brand/` fürs Favicon, `LICENSE` für den MIT-Link im Footer). Die `frontend/dist/index.html` als `index.html` an der Wurzel-URL, dazu `LICENSE`
`../`-Pfade der Editor-Quelle werden dabei nur auf der Kopie geradegezogen — die für den MIT-Link im Footer. Das Favicon ist im Build bereits inline, es muss also
Quelldatei bleibt unverändert. `backend/` und die übrigen `docs/` werden nicht nichts weiter kopiert werden; nur der Laufzeit-Link `../LICENSE` wird auf der
veröffentlicht. Kopie geradegezogen. Ein fehlschlagender Test blockiert das Deployment.
`backend/` und die übrigen `docs/` werden nicht veröffentlicht.
Beim Zusammenstellen setzt der Workflow zudem die Versionsnummer im Footer: Beim Zusammenstellen setzt der Workflow zudem die Versionsnummer im Footer:
**Major.Minor** stammt aus der Datei `VERSION` (per bewusstem „Bump-Commit" **Major.Minor** stammt aus der Datei `VERSION` (per bewusstem „Bump-Commit"
+27 -9
View File
@@ -33,10 +33,27 @@ and/or decomposition — plus a live editor that renders it as a diagram.
![Werkbaum editor: live diagram on top, text notation below, with status colours, T-shirt sizes, tags and export buttons](docs/screenshot.png) ![Werkbaum editor: live diagram on top, text notation below, with status colours, T-shirt sizes, tags and export buttons](docs/screenshot.png)
Open the [hosted editor](https://mhoennig.github.io/werkbaum/) — or Open the [hosted editor](https://mhoennig.github.io/werkbaum/) — edit text on
`frontend/index.html` in the browser — edit text on the left, the diagram is the left, the diagram is built live on the right. Toggles: transposed (narrow)
built live on the right. Toggles: transposed (narrow) layout, show discarded layout, show discarded elements.
elements.
### Running it locally
The editor source now lives as ES modules under `frontend/src/`, bundled by
[Vite](https://vitejs.dev/) (see `docs/DECISIONS.md` D19). Because browsers block
ES-module imports over `file://`, opening `frontend/index.html` directly no
longer works — use one of:
```bash
cd frontend
npm install # once
npm run dev # dev server at http://localhost:8137
npm test # Vitest unit tests
npm run build # -> frontend/dist/index.html (single self-contained file)
```
The built `dist/index.html` inlines all JS, CSS and the favicon, so **that** file
does open standalone via `file://` and is what gets deployed.
## Project documents ## Project documents
@@ -59,11 +76,12 @@ The editor is published as a static page on **GitHub Pages** via GitHub
Actions (workflow: `.github/workflows/pages.yml`). Triggered on every push to Actions (workflow: `.github/workflows/pages.yml`). Triggered on every push to
`main` and manually (`workflow_dispatch`). `main` and manually (`workflow_dispatch`).
The workflow assembles a site folder: `frontend/index.html` is placed as The workflow sets up Node, runs `npm ci`, `npm test` (Vitest) and `npm run build`
`index.html` at the root URL, along with the files the editor references (Vite), then publishes the bundled `frontend/dist/index.html` as `index.html` at
(`docs/brand/` for the favicon, `LICENSE` for the MIT link in the footer). The the root URL, plus `LICENSE` for the MIT link in the footer. The favicon is
`../` paths in the editor source are straightened only on the copy — the source already inlined into the build, so nothing else needs copying; only the runtime
file stays unchanged. `backend/` and the remaining `docs/` are not published. `../LICENSE` link is straightened on the copy. A failing test blocks the deploy.
`backend/` and the remaining `docs/` are not published.
While assembling the site, the workflow also stamps the version into the footer: While assembling the site, the workflow also stamps the version into the footer:
**major.minor** comes from the `VERSION` file (bumped by an explicit "bump **major.minor** comes from the `VERSION` file (bumped by an explicit "bump
+37
View File
@@ -214,3 +214,40 @@ Knoten von seiner Linie von oben abgetrennt. Fix: `ul.or>li.has-and` bekommt
vertikal **symmetrisches** Padding (Mitte bleibt bei 50 %), der Abzweig vertikal **symmetrisches** Padding (Mitte bleibt bei 50 %), der Abzweig
(`::before`) und die Rail-Endkante (`:last-child::after`) werden auf 50 % (`::before`) und die Rail-Endkante (`:last-child::after`) werden auf 50 %
gesetzt — analog zu den bereits zentrierten all-of-Zwischenknoten. gesetzt — analog zu den bereits zentrierten all-of-Zwischenknoten.
## D19 — Modularisierung mit Vite-Bündelung zu einer self-contained Datei
Das Nahziel „tragfähige Codebasis" (ROADMAP) verlangt, den Single-HTML-
Prototyp in headless nutzbare Module (`parser`, `model`, `render`, `app`) zu
zerlegen und gegen SPEC zu testen. Die Zwickmühle: **modulare Einzeldateien**,
**`file://`-Tauglichkeit** (D16) und **kein Build** (D11) sind nicht gleichzeitig
erfüllbar — ES-`import` über `file://` blockt der Browser (CORS).
**Entscheidung: Vite als Bündler/Testrunner.** `frontend/src/*.js` sind echte
ES-Module und die Quelle der Wahrheit; `frontend/index.html` lädt im Dev-Server
per `<script type="module">`. `npm run build` (Vite + `vite-plugin-singlefile`)
inlint **alle** Module, das CSS und das Favicon (als `data:`-URI) in **eine**
`dist/index.html` — damit bleibt das `file://`-Versprechen aus D16 erhalten
(die gebaute Datei ist standalone), und der Deploy lädt nichts extern nach.
Damit wird **D11 („kein Build-Zwang") bewusst aufgeweicht**: Zum *Weiter­ent­
wickeln/Testen* braucht es nun Node + Vite (Dev-Abhängigkeiten, keine
Laufzeit-Abhängigkeiten — das Ergebnis ist reines HTML/CSS/JS ohne Framework).
Das *Ergebnis* bleibt im Geist von D11: eine einzelne, framework-freie Datei,
die überall ohne Server läuft. Verworfene Alternativen:
- **ES-Module ohne Build:** bräche `file://` (Dev-Server-Zwang lokal) — verwarf
der Nutzer, weil das lokale Öffnen erhalten bleiben soll.
- **Klassische `<script>`+Globals:** hielte `file://` ohne Build, ist aber kein
echtes ESM und erschwert headless-Tests/Tree-Shaking.
**Deploy (D16-Fortschreibung):** Der Pages-Workflow richtet Node ein, führt
`npm ci` + `npm test` (Vitest) + `npm run build` aus und nimmt
`frontend/dist/index.html`. Die frühere `sed`-Kur der `../docs/brand/`-Pfade
entfällt (Favicon inline); nur der Laufzeit-Link `../LICENSE` und die
Footer-Version werden weiter auf der Site-Kopie gesetzt. Schlägt der Testlauf
fehl, wird nicht deployt (der frühere auskommentierte Platzhalter ist nun
aktiv).
(Favicon-Inlining: ein kleiner `transformIndexHtml`-Plugin in `vite.config.js`
liest `../docs/brand/favicon.svg` und ersetzt den `<link rel="icon">` durch eine
`data:`-URI — so bleibt die Brand-Quelle unverändert und die Ausgabe eine
einzige Datei.)
+5 -1
View File
@@ -3,9 +3,13 @@
Abhaken beim Erledigen; neue Aufgaben unten anfügen. Abhaken beim Erledigen; neue Aufgaben unten anfügen.
## Phase 1 — Modularisierung & Tests ## Phase 1 — Modularisierung & Tests
- [ ] Projektgerüst: `src/` (parser.js, model.js, render.js, app.js), - [x] Projektgerüst: `src/` (parser.js, model.js, render.js, app.js),
`tests/`, `index.html` bindet Module ein; weiterhin ohne Build nutzbar `tests/`, `index.html` bindet Module ein; weiterhin ohne Build nutzbar
(ES-Module) oder mit minimalem Setup (Vite) — Entscheidung dokumentieren. (ES-Module) oder mit minimalem Setup (Vite) — Entscheidung dokumentieren.
**Vite** gewählt (D19): `src/` als ES-Module, `npm run build` bündelt zu
einer self-contained `dist/index.html` (file://-tauglich). Gerüst steht
(aktuell `src/app.js` + `src/style.css`); Parser/Renderer werden in den
folgenden Checkboxen herausgelöst.
- [ ] Parser extrahieren; Verhalten exakt wie in `docs/SPEC.md` §1–§8. - [ ] Parser extrahieren; Verhalten exakt wie in `docs/SPEC.md` §1–§8.
- [ ] Unit-Tests für den Parser (Vitest): kanonisches Beispiel aus SPEC §10 - [ ] Unit-Tests für den Parser (Vitest): kanonisches Beispiel aus SPEC §10
als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung, als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung,
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+20 -4
View File
@@ -1,12 +1,28 @@
# Werkbaum · Frontend # Werkbaum · Frontend
Editor: `index.html` ist der funktionierende Prototyp (Text links, Editor: Text links, Diagramm rechts, Toggles für transponierte Ansicht und
Diagramm rechts, Toggles für transponierte Ansicht und verworfene Elemente). verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
**Vite-Entry** (lädt im Dev-Server `src/app.js` als `<script type="module">`).
## Build & Entwicklung (D19)
- **Vite** ist Bündler + Testrunner (nur Dev-Abhängigkeiten, keine Laufzeit-
Abhängigkeit — das Ergebnis ist framework-freies HTML/CSS/JS).
- `npm --prefix frontend run dev` — Dev-Server (Port 8137, `.claude/launch.json`).
Direktes Öffnen von `frontend/index.html` per `file://` funktioniert **nicht**
mehr (ES-`import` braucht http); stattdessen Dev-Server oder die gebaute Datei.
- `npm --prefix frontend run build``vite-plugin-singlefile` inlint JS + CSS +
Favicon (als `data:`-URI, via `transformIndexHtml`-Plugin in `vite.config.js`)
in **eine** self-contained `dist/index.html` — die bleibt `file://`-tauglich
(D16) und ist die Deploy-Artefakt-Quelle (Pages-Workflow, siehe README).
- `npm --prefix frontend test` — Vitest (`tests/**/*.test.js`).
- `node_modules/` und `dist/` sind ge-`.gitignore`-t; `package-lock.json` ist
eingecheckt (der Workflow nutzt `npm ci`).
## Konventionen ## Konventionen
- Vanilla HTML/CSS/JS, ES-Module; keine Frameworks. Testwerkzeug (Vitest) ok. - Vanilla HTML/CSS/JS, ES-Module; keine Frameworks. Testwerkzeug: Vitest.
- Parser und Renderer müssen headless (ohne Editor-UI) nutzbar bleiben — - Parser und Renderer müssen headless (ohne Editor-UI) nutzbar bleiben —
Basis für SVG-Export und Mermaid-Plugin (docs/ROADMAP.md). Basis für SVG-Export und Mermaid-Plugin (docs/ROADMAP.md). `app.js` ist der
DOM-/UI-Einstieg; reine Logik gehört in `parser.js`/`model.js`/`render.js`.
- Design: Farben/Typografie beibehalten (CSS-Variablen, IBM Plex); - Design: Farben/Typografie beibehalten (CSS-Variablen, IBM Plex);
Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md; Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md;
Pastelltöne nie im Logo. Pastelltöne nie im Logo.
+1 -1836
View File
File diff suppressed because it is too large Load Diff
+1897
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "werkbaum-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Werkbaum WBS-Editor (Vanilla HTML/CSS/JS, mit Vite gebündelt zu einer einzelnen self-contained index.html).",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"jsdom": "^29.1.1",
"vite": "^8.1.5",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.10"
}
}
+1226
View File
File diff suppressed because it is too large Load Diff
+607
View File
@@ -0,0 +1,607 @@
:root{
--paper:#F3F6F9;
--grid:rgba(36,52,71,.055);
--ink:#243447;
--line:#41556E;
--card:#FFFFFF;
--muted:#6B7A8C;
--or:#0F766E;
--warn:#B45309;
--r:8px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font-family:'IBM Plex Sans',system-ui,sans-serif;
color:var(--ink);
background:
repeating-linear-gradient(0deg,transparent 0 23px,var(--grid) 23px 24px),
repeating-linear-gradient(90deg,transparent 0 23px,var(--grid) 23px 24px),
var(--paper);
/* Mobile Viewport-Höhe: 100vh ist die *große* Höhe (inkl. Fläche hinter
der unteren Browserleiste) — Footer/Editor-Titelzeile rutschen dahinter.
100dvh folgt der sichtbaren Fläche, versagt aber bei Browsern mit
Overlay-Leiste (z. B. Brave). Deshalb setzt JS zusätzlich --app-height
aus window.visualViewport.height (die wirklich sichtbare Höhe). Reihen-
folge = Fallback-Kaskade: vh → dvh → exakte JS-Höhe. */
height:100vh;
height:100dvh;
height:var(--app-height, 100dvh);
overflow:hidden;
display:flex;flex-direction:column;
padding:24px 20px 12px;
}
header{max-width:1200px;width:100%;margin:0 auto 16px;flex:0 0 auto}
.header-row{display:flex;align-items:center;justify-content:space-between;gap:16px}
.header-tools{display:flex;align-items:center;gap:12px}
.fsbtn{
display:inline-flex;align-items:center;justify-content:center;
width:30px;height:29px;padding:0;cursor:pointer;
border:1px solid rgba(36,52,71,.2);border-radius:8px;
background:var(--card);color:var(--muted);
}
.fsbtn svg{display:block}
.fsbtn:hover{border-color:var(--or);color:var(--or)}
.fsbtn.active{background:var(--or);color:#fff;border-color:var(--or)}
/* Vollbild: Panels nutzen fast die ganze Fensterbreite; es bleibt ein
schmaler Rand (halber Panel-Zwischenraum = 7 px), damit es nicht gedrängt
wirkt. */
body.fullscreen{padding-left:7px;padding-right:7px}
body.fullscreen header,
body.fullscreen .app,
body.fullscreen .site-footer{max-width:none}
h1{font-size:1.25rem;font-weight:600;letter-spacing:-.01em}
header p{margin-top:4px;color:var(--muted);font-size:.9rem}
.sub-short{display:none} /* Kurzfassung nur auf kleinem Bildschirm */
.langsel{
display:inline-flex;flex:0 0 auto;
border:1px solid rgba(36,52,71,.2);border-radius:999px;overflow:hidden;
}
.langsel button{
border:0;background:var(--card);cursor:pointer;color:var(--muted);
font-family:'IBM Plex Mono',monospace;font-size:.72rem;letter-spacing:.04em;
padding:5px 10px;
}
.langsel button + button{border-left:1px solid rgba(36,52,71,.14)}
.langsel button:hover:not(.active){color:var(--or)}
.langsel button.active{background:var(--or);color:#fff}
.langmore{letter-spacing:0;padding:5px 8px;font-weight:600}
.langsel .lang-extra{display:none} /* PL/RU/HI erst nach Aufklappen */
.langsel.expanded .lang-extra{display:block}
.site-footer{
max-width:1200px;margin:14px auto 0;
color:var(--muted);font-size:.78rem;text-align:center;
}
.site-footer a{color:var(--or);text-decoration:none}
.site-footer a:hover{text-decoration:underline}
.site-footer .vibe{cursor:help;text-decoration:underline dotted;text-underline-offset:2px}
/* Horizontal (Toggle aus): Diagramm oben, Editor unten — je volle Breite.
Vertikal (Toggle an, .side): Editor links, Diagramm rechts.
Zwischen beiden liegt ein verschiebbarer Gutter (Splitter). */
.app{
max-width:1200px;width:100%;margin:0 auto;
flex:1 1 auto;min-height:0;
display:grid;gap:0;
grid-template-columns:minmax(0,1fr);
grid-template-rows:var(--drow,60%) 14px minmax(0,1fr);
grid-template-areas:"diagram" "gutter" "editor";
align-items:stretch;
}
.app.side{
grid-template-columns:var(--col,360px) 14px minmax(0,1fr);
grid-template-rows:minmax(0,1fr);
grid-template-areas:"editor gutter diagram";
}
.panel.editor{grid-area:editor}
.panel.right{grid-area:diagram}
.gutter{grid-area:gutter}
/* Splitter/Gutter: stapelweise horizontal, seitlich vertikal. */
.gutter{
position:relative;align-self:stretch;justify-self:stretch;
height:14px;cursor:row-resize;touch-action:none;
}
.app.side .gutter{height:auto;width:14px;cursor:col-resize}
.gutter::before{
content:'';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:46px;height:4px;border-radius:999px;background:rgba(36,52,71,.16);
transition:background .15s;
}
.app.side .gutter::before{width:4px;height:46px}
.gutter:hover::before,.gutter.dragging::before{background:var(--or)}
/* Minimieren: Panel auf seine Titelzeile schrumpfen, Körper ausblenden.
'a' = Diagramm minimiert (Editor maximal), 'b' = Editor minimiert. */
.panel.collapsed .editor-body,
.panel.collapsed .diagram,
.panel.collapsed .warnings{display:none}
.panel.collapsed .copybtn,
.panel.collapsed .switch,
.panel.collapsed .seg{display:none}
.panel.collapsed .panel-head{cursor:pointer}
.panel.collapsed .panel-head>span{cursor:pointer}
/* Gestapelt: kollabiertes Panel = Titelzeile (auto), anderes füllt. */
.app:not(.side).collapse-diagram{grid-template-rows:auto 14px minmax(0,1fr)}
.app:not(.side).collapse-editor{grid-template-rows:minmax(0,1fr) 14px auto}
/* Seitlich: schmaler vertikaler Streifen mit gedrehter Beschriftung. */
.app.side.collapse-editor{grid-template-columns:auto 14px minmax(0,1fr)}
.app.side.collapse-diagram{grid-template-columns:minmax(0,1fr) 14px auto}
.app.side .panel.collapsed{align-self:stretch}
.app.side .panel.collapsed .panel-head{
flex-direction:column;align-items:center;gap:12px;
height:100%;padding:12px 6px;border-bottom:0;
}
.app.side .panel.collapsed .panel-head>span{
writing-mode:vertical-rl;transform:rotate(180deg);
margin:0;white-space:nowrap;
}
.app.side .panel.collapsed .winbtns{flex-direction:column}
/* Schmaler Viewport: Seitenmodus fällt auf gestapelt zurück. */
@media (max-width:820px){
.app.side{
grid-template-columns:minmax(0,1fr);
grid-template-rows:var(--drow,60%) 14px minmax(0,1fr);
grid-template-areas:"diagram" "gutter" "editor";
}
.app.side.collapse-diagram{grid-template-columns:minmax(0,1fr);grid-template-rows:auto 14px minmax(0,1fr)}
.app.side.collapse-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) 14px auto}
.app.side .panel.collapsed .panel-head{flex-direction:row;align-items:center;height:auto;padding:10px 16px}
.app.side .panel.collapsed .panel-head>span{writing-mode:horizontal-tb;transform:none}
.app.side .panel.collapsed .winbtns{flex-direction:row}
.app.side .gutter{height:14px;width:auto;cursor:row-resize}
.app.side .gutter::before{width:46px;height:4px}
}
/* Editor-Innenaufbau: Textarea + aufklappbare Agenda.
Horizontal nebeneinander, vertikal (.side) untereinander. */
.editor-body{display:flex;align-items:stretch;flex:1 1 auto;min-height:0}
.editor-body textarea{flex:1 1 auto;min-width:0;min-height:0}
.agenda{
flex:0 0 auto;
border-left:1px dashed rgba(36,52,71,.18);
display:flex;flex-direction:column;
}
.agenda[open]{width:300px}
.agenda>summary{
list-style:none;cursor:pointer;white-space:nowrap;
padding:10px 16px;
font-family:'IBM Plex Mono',monospace;
font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);
display:flex;align-items:center;gap:7px;
}
.agenda>summary::-webkit-details-marker{display:none}
.agenda>summary::before{content:'▸';display:inline-block;transition:transform .15s}
.agenda[open]>summary::before{transform:rotate(90deg)}
.agenda{min-height:0}
.agenda[open]{overflow:hidden}
.agenda .hint{border-top:0;flex:1 1 auto;min-height:0;overflow:auto}
.side .editor-body{flex-direction:column;align-items:stretch}
.side .agenda{border-left:0;border-top:1px dashed rgba(36,52,71,.18)}
.side .agenda[open]{width:auto}
.panel{
background:var(--card);
border:1px solid rgba(15,118,110,.30); /* dezentes Petrol */
border-radius:12px;
overflow:hidden;
display:flex;flex-direction:column;min-height:0;min-width:0;
}
.panel-head{
flex:0 0 auto;
padding:10px 16px;
display:flex;justify-content:space-between;align-items:center;gap:14px;
font-family:'IBM Plex Mono',monospace;
font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;
color:var(--muted);
border-bottom:1px solid rgba(36,52,71,.1);
flex-wrap:wrap;
}
.panel-head>span{margin-right:auto}
.switch{
display:flex;align-items:center;gap:7px;cursor:pointer;
text-transform:none;letter-spacing:0;
font-family:'IBM Plex Sans',sans-serif;font-size:.78rem;color:var(--muted);
}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch i{
width:30px;height:17px;border-radius:999px;background:#C6CFD8;
position:relative;transition:background .15s;flex:0 0 auto;
}
.switch i::before{
content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;
border-radius:50%;background:#fff;transition:left .15s;
box-shadow:0 1px 2px rgba(36,52,71,.25);
}
.switch input:checked + i{background:var(--or)}
.switch input:checked + i::before{left:15px}
.seg{
display:inline-flex;align-items:stretch;
border:1px solid rgba(36,52,71,.2);border-radius:999px;overflow:hidden;
}
.seg label{display:flex;cursor:pointer}
.seg input{position:absolute;opacity:0;width:0;height:0}
.seg span{
display:grid;place-items:center;padding:4px 10px;color:var(--muted);
transition:background .15s,color .15s;
}
.seg span svg{display:block}
.seg label + label span{border-left:1px solid rgba(36,52,71,.14)}
.seg input:checked + span{background:var(--or);color:#fff}
.seg input:focus-visible + span{outline:2px solid var(--or);outline-offset:-2px}
.winbtns{display:inline-flex;gap:4px;align-items:center}
.winbtn{
width:19px;height:19px;display:grid;place-items:center;
border:1px solid rgba(36,52,71,.2);border-radius:5px;
background:var(--card);color:var(--muted);cursor:pointer;
font-size:.72rem;line-height:1;padding:0;
}
.winbtn:hover{border-color:var(--or);color:var(--or)}
.winbtn.active{background:var(--or);color:#fff;border-color:var(--or)}
.copybtn{
color:var(--muted);background:var(--card);
border:1px solid rgba(36,52,71,.2);border-radius:6px;
padding:3px 6px;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;
}
.copybtn:hover,.copybtn.done{border-color:var(--or);color:var(--or)}
.copybtn svg{display:block;width:15px;height:15px}
.copybtn .ic-done{display:none}
.copybtn.done .ic-copy{display:none}
.copybtn.done .ic-done{display:block}
.dlbtn{gap:3px}
.dl-label{font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600;letter-spacing:.02em}
/* Download-Gruppe: Desktop zeigt SVG/PNG inline, der Trigger ist versteckt.
Auf Mobil (body.mobile, s. u.) klappt sie als Overlay-Menü auf. */
.dlgroup{display:inline-flex;align-items:center;gap:3px}
.dlmenu{display:inline-flex;gap:3px}
.dltrigger{display:none}
.legendbtn{display:none} /* nur auf kleinem Bildschirm sichtbar */
.togglebtn[aria-pressed="true"]{background:var(--or);color:#fff;border-color:var(--or)}
.togglebtn[aria-pressed="true"]:hover{color:#fff}
textarea{
display:block;width:100%;min-height:0;
border:0;outline:none;resize:none;
padding:14px 16px;
font-family:'IBM Plex Mono',monospace;
font-size:.85rem;line-height:1.7;
color:var(--ink);background:var(--card);
tab-size:2;
}
.hint{
padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18);
font-size:.8rem;color:var(--muted);line-height:1.7;
}
.hint code{
font-family:'IBM Plex Mono',monospace;font-size:.75rem;
background:var(--paper);border:1px solid rgba(36,52,71,.12);
border-radius:4px;padding:0 5px;
}
.hint .or-code{color:var(--or);border-color:rgba(15,118,110,.4)}
.diagram{
flex:1 1 auto;min-height:0;
padding:28px 16px 24px;
overflow:auto;
background:transparent;
}
.panel.right{background:rgba(255,255,255,.6);position:relative}
.warnings{padding:0 16px 14px;font-size:.8rem;color:var(--warn)}
.warnings div{margin-top:4px}
.empty{color:var(--muted);font-size:.85rem;padding:8px 4px}
/* Zoom-Steuerung: schwebt unten rechts über dem Diagramm. */
.zoomctl{
position:absolute;right:14px;bottom:14px;z-index:5;
display:inline-flex;align-items:center;gap:2px;padding:2px;
background:var(--card);border:1px solid rgba(36,52,71,.18);
border-radius:8px;box-shadow:0 1px 4px rgba(36,52,71,.14);
}
.zoomctl button{
border:0;background:transparent;cursor:pointer;color:var(--muted);
font-family:'IBM Plex Mono',monospace;font-size:.82rem;line-height:1;
padding:4px 8px;border-radius:6px;min-width:30px;
}
#zoomReset{min-width:48px;font-size:.72rem}
.zoomctl button:hover{background:var(--paper);color:var(--or)}
.panel.collapsed .zoomctl{display:none}
/* ---------- Baum ---------- */
.tree{width:max-content;margin:0 auto;list-style:none;position:relative}
.tree ul{list-style:none}
.tree li{display:flex;flex-direction:column;align-items:center;position:relative}
.tree > li + li{margin-top:36px}
.node{
position:relative;
color:var(--ink);
background:var(--card);
border:1.5px solid var(--line);
border-radius:var(--r);
padding:7px 14px;
font-size:.88rem;font-weight:500;line-height:1.3;
white-space:nowrap;
box-shadow:0 1px 2px rgba(36,52,71,.08);
}
a.node{text-decoration:none;cursor:pointer}
a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)}
.ext{font-size:.72em;margin-left:5px;opacity:.55}
.size{
position:absolute;top:-9px;right:-9px;
font-family:'IBM Plex Mono',monospace;
font-size:.58rem;font-weight:500;letter-spacing:.02em;
background:var(--or);color:#fff;
border:1.5px solid var(--card);
border-radius:999px;padding:1px 6px;line-height:1.4;
}
.tags{
position:absolute;right:-9px;bottom:-9px;
display:flex;gap:3px;
}
.tag{
font-family:'IBM Plex Mono',monospace;
font-size:.58rem;font-weight:500;letter-spacing:.02em;
background:#EEF1F4;color:var(--ink);
border:1.5px solid var(--line);
border-radius:999px;padding:1px 6px;line-height:1.4;
box-shadow:0 0 0 1.5px var(--card);
}
.ghost-node{
position:relative;margin-top:14px;
border:1.5px dashed var(--warn);
color:var(--warn);background:rgba(180,83,9,.06);
border-radius:var(--r);padding:4px 12px;
font-size:.74rem;font-style:italic;white-space:nowrap;
}
.ghost-node::before{
content:'';position:absolute;top:-14px;left:50%;height:14px;
border-left:2px dashed var(--warn);
}
.root-node{background:var(--ink);border-color:var(--ink);color:#fff;font-weight:600;padding:9px 20px}
/* Günstigster Pfad (Inversion): nicht benötigte Knoten (nicht-gewählte
any-of-Alternativen samt Teilbaum) treten zurück (blass, entsättigt);
der günstige Pfad hebt sich dadurch von allein ab. */
.cheap-on .node:not(.cheap){opacity:.32;filter:saturate(.4)}
/* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */
.size.implicit{background:var(--card);color:var(--or);border-color:var(--or)}
/* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch
die Endknoten (Blätter) des Pfads. Zwei Overlays in #out (erben dessen
zoom): die kräftige Linie liegt HINTER den Knoten (nur in den Lücken
sichtbar), eine abgetönte Kopie liegt DAVOR (deutet den Verlauf beim
Durchschreiten von Knoten nur schwach an). Kleine Stationspunkte an den
Blättern zeigen, welche Knoten wirklich auf dem Pfad enden — durchquerte
Fremdknoten tragen keinen Punkt. Reihenfolge im DOM steuert vorn/hinten. */
.cheap-overlay{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
.cheap-path{fill:none;stroke:var(--or);stroke-width:2.5;stroke-linecap:round;
stroke-dasharray:8 6;opacity:.9}
.cheap-path.faint{opacity:.2}
/* Stationspunkt: groß, aber blass gefüllt — der Knotentext bleibt lesbar */
.cheap-dot{fill:var(--or);fill-opacity:.2;stroke:var(--or);stroke-opacity:.35;stroke-width:1.5}
/* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */
ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px}
ul.and::before{
content:'';position:absolute;top:0;left:50%;height:14px;border-left:2px solid var(--line);
}
ul.and>li{padding:16px 12px 0}
ul.and>li::before,
ul.and>li::after{
content:'';position:absolute;top:0;right:50%;
border-top:2px solid var(--line);width:50%;height:16px;
}
ul.and>li::after{right:auto;left:50%;border-left:2px solid var(--line)}
ul.and>li:only-child{padding-top:14px}
ul.and>li:only-child::before,ul.and>li:only-child::after{border-top:none}
ul.and>li:only-child::after{border-left:2px solid var(--line);height:14px}
ul.and>li:first-child::before,
ul.and>li:last-child::after{border:0 none}
ul.and>li:last-child::before{border-right:2px solid var(--line);border-radius:0 6px 0 0}
ul.and>li:first-child::after{border-radius:6px 0 0 0}
/* --- ANY OF: durchgehend gestrichelte graue Linien (Haupt- + Abzweige) --- */
li.has-or{align-items:flex-start}
ul.or{
display:flex;flex-direction:column;
align-items:flex-start;
position:relative;
padding-top:14px;
}
ul.or::before{
content:'';position:absolute;top:0;left:9px;height:14px;border-left:2px dashed var(--muted);
}
ul.or>li{position:relative;align-items:flex-start;padding:5px 0 5px 30px}
/* Abzweig auf Höhe der Knoten-Mitte (5px li-Padding + halbe Knotenhöhe), nicht Mitte des Teilbaums */
ul.or>li::before{
content:'';position:absolute;left:9px;top:23px;width:21px;border-top:2px dashed var(--muted);
}
ul.or>li::after{
content:'';position:absolute;left:9px;top:0;height:100%;border-left:2px dashed var(--muted);
}
ul.or>li:last-child::after{height:23px}
/* all-of-Teilbaum UNTER einer any-of-Alternative (nur horizontal):
schmal transponiert stapeln — Kinder untereinander mit linkem, solidem
Verteiler (wie im kompakten Modus), statt breitem Fächer. Sonst zwingt der
breite Fächer ein Zentrieren, das den Alternativknoten (und damit den
ganzen Elternbaum) weit nach rechts schiebt. Passt zugleich stilistisch
zur gestapelten any-of-Spalte darüber. Vertikal/kompakt haben ihre eigene
`ul.and`-Behandlung und bleiben unberührt. */
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and{
flex-direction:column;align-items:flex-start;padding-top:14px;
}
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and::before{left:9px;height:14px}
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li{padding:5px 0 5px 30px}
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li::before{
left:9px;right:auto;top:23px;width:21px;height:0;
border:0;border-top:2px solid var(--line);border-radius:0;
}
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li::after{
left:9px;right:auto;top:0;width:0;height:100%;
border:0;border-left:2px solid var(--line);border-radius:0;
}
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li:last-child::after{height:23px}
ul.or .node{
border-color:var(--muted);
box-shadow:none;
font-weight:400;
}
/* ---------- Status (Pastell) ---------- */
.node.st-idee {background:#EBEDEF;border-color:#A2ABB5;color:var(--ink)}
.node.st-geplant {background:#EBE4F6;border-color:#A991D4;color:var(--ink)}
.node.st-arbeit {background:#FADDE4;border-color:#D897A8;color:var(--ink)}
.node.st-durchstich{background:#FBF2CE;border-color:#D9BE63;color:var(--ink)}
.node.st-fertig {background:#DCF1DE;border-color:#86C293;color:var(--ink)}
.node.st-prod {background:#DBEAF8;border-color:#85ACD7;color:var(--ink)}
.node.st-verworfen{
background:#F1F2F4;border-color:#B3BAC2;border-style:dashed;
color:var(--muted);text-decoration:line-through;box-shadow:none;
}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.chip{
font-family:'IBM Plex Mono',monospace;font-size:.72rem;
border:1.5px solid;border-radius:6px;padding:2px 8px;
color:var(--ink);white-space:nowrap;
}
.chip.st-idee {background:#EBEDEF;border-color:#A2ABB5}
.chip.st-geplant {background:#EBE4F6;border-color:#A991D4}
.chip.st-arbeit {background:#FADDE4;border-color:#D897A8}
.chip.st-durchstich{background:#FBF2CE;border-color:#D9BE63}
.chip.st-fertig {background:#DCF1DE;border-color:#86C293}
.chip.st-prod {background:#DBEAF8;border-color:#85ACD7}
.chip.st-verworfen{background:#F1F2F4;border-color:#B3BAC2;border-style:dashed;color:var(--muted);text-decoration:line-through}
/* ---------- Transponierte Modi (vertikal + kompakt) ---------- */
/* Gemeinsame Basis: Kinder untereinander, Sammelleiste links,
Abzweig auf Knotenmitte (23-px-Offset). */
.tree.vertical, .tree.kompakt{margin:0}
.tree.vertical li, .tree.kompakt li{align-items:flex-start}
.tree.vertical ul.and, .tree.kompakt ul.and{flex-direction:column;align-items:flex-start;padding-top:14px}
.tree.vertical ul.and::before, .tree.kompakt ul.and::before{left:9px;height:14px}
.tree.vertical ul.and>li, .tree.kompakt ul.and>li{padding:5px 0 5px 30px}
.tree.vertical ul.and>li::before, .tree.kompakt ul.and>li::before{
left:9px;right:auto;top:23px;width:21px;height:0;
border:0;border-top:2px solid var(--line);border-radius:0;
}
.tree.vertical ul.and>li::after, .tree.kompakt ul.and>li::after{
left:9px;right:auto;top:0;width:0;height:100%;
border:0;border-left:2px solid var(--line);border-radius:0;
}
.tree.vertical ul.and>li:last-child::after, .tree.kompakt ul.and>li:last-child::after{height:23px}
/* Nur vertikal: echter transponierter, ZENTRIERTER LR-Baum — analog zur
horizontalen Darstellung, nur um 90° gedreht. Der Überknoten sitzt
vertikal mittig zu seiner Kindergruppe; die all-of-Verbindung tritt rechts
auf halber Höhe aus, eine vertikale Sammelleiste verteilt auf die Kinder.
Kompakt bleibt unberührt (führt all-of nach unten). */
.tree.vertical li.has-and{flex-direction:row;align-items:center}
.tree.vertical li.has-and>ul.and{padding-top:0}
/* Stub von der Parent-Mitte (halbe Höhe) zur vertikalen Sammelleiste */
.tree.vertical li.has-and>ul.and::before{
left:0;top:50%;width:9px;height:0;
border-left:0;border-top:2px solid var(--line);
}
/* Abzweig trifft die tatsächliche Knotenmitte des Kindes:
Blatt- und Any-of-Kinder sitzen oben (Offset 23 px), all-of-Zwischenknoten
sind selbst vertikal zentriert (50 %). Ohne diese Unterscheidung liefen die
Linien zu zentrierten Zwischenknoten ins Leere. Die Enden der Sammelleiste
folgen demselben Abzweigpunkt des jeweiligen Rand-Kindes. */
.tree.vertical li.has-and>ul.and>li:first-child::after{top:23px;height:calc(100% - 23px)}
.tree.vertical li.has-and>ul.and>li:only-child::after{border:0}
.tree.vertical li.has-and>ul.and>li.has-and::before{top:50%}
.tree.vertical li.has-and>ul.and>li.has-and:first-child::after{top:50%;height:50%}
.tree.vertical li.has-and>ul.and>li.has-and:last-child::after{top:0;height:50%}
.tree.vertical li.has-and>ul.and>li.has-and:only-child::after{border:0}
/* Mehr Luft zwischen gestapelten Knoten (transponierte Modi): sonst
überlappt das Größen-Badge (oben rechts) eines Knotens mit den
Namens-Tags (unten rechts) des Knotens darüber. Nur zusätzlicher Abstand
NACH unten, damit der obere 23-px-Abzweig unverändert bleibt. */
.tree.kompakt ul.and>li,
.tree.kompakt ul.or>li,
.tree.vertical ul.or>li,
.tree.vertical ul.and>li:not(.has-and){
padding-bottom:20px;
}
/* Zentrierte all-of-Zwischenknoten (nur vertikal) brauchen den Abstand
symmetrisch, damit ihr 50%-Abzweig auf der Knotenmitte bleibt. */
.tree.vertical li.has-and>ul.and>li.has-and{
padding-top:16px;padding-bottom:16px;
}
/* Vertikal + any-of-Alternative, die selbst all-of zerlegt (z. B. „Web+Nativ“):
Der Knoten sitzt vertikal mittig zu seinem Rechts-Fächer, also muss auch der
EINKOMMENDE any-of-Abzweig auf die Knotenmitte (50 %) zeigen statt auf 23 px —
sonst ist der Knoten von seiner Linie von oben abgetrennt. Symmetrisches
Padding hält die Mitte bei 50 % (überschreibt das asymmetrische ul.or>li). */
.tree.vertical ul.or>li.has-and{padding-top:16px;padding-bottom:16px}
.tree.vertical ul.or>li.has-and::before{top:50%}
.tree.vertical ul.or>li.has-and:last-child::after{height:50%}
/* Kompakt: auch All-of läuft nach unten (spart Breite) — kein Rechts-Fächer.
Die graue, gestrichelte Any-of-Führung kommt aus der Basis und gilt hier
wie in allen Modi. Gate-Codierung: und = durchgezogen dunkel (Tinte),
oder = gestrichelt grau. */
/* ---------- Kleiner Bildschirm (Smartphone) ----------
body.mobile wird per matchMedia (<=640px) gesetzt. Standardmäßig ist ein
Bereich groß, der andere schrumpft auf seine Titelzeile und dient als
Umschalter (Tippen bringt ihn nach vorn). Die Min/Normal/Max-Buttons
entfallen; geteilt wird weiterhin über den **Splitter**, der sichtbar
bleibt. Das gestapelte Layout inkl. Gutter liefert bereits die
820-px-Regel weiter oben. Die Legende bekommt einen eigenen Umschalter
statt der dauerhaften „AGENDA“-Zeile. */
body.mobile .winbtns{display:none}
/* Mobil: freie Aufteilung per Splitter — beide Titelzeilen bleiben IMMER
stehen (Zeilen-Minimum --pmin ≈ Titelzeilenhöhe). Kein diskretes
Minimieren/Snap; ein zusammengeschobenes Panel schrumpft nur bis auf seine
volle Titelzeile (Körper flext auf 0, Panel ist overflow:hidden). Gilt auch
für den Seitenmodus, der auf Mobil ohnehin gestapelt ist. Der Drag setzt
--drow (JS klemmt), das Tippen auf eine Titelzeile maximiert das Panel. */
body.mobile .app,
body.mobile .app.side{
grid-template-columns:minmax(0,1fr);
grid-template-rows:minmax(var(--pmin-d,49px),var(--drow,60%)) 14px minmax(var(--pmin-e,44px),1fr);
grid-template-areas:"diagram" "gutter" "editor";
}
/* Untertitel: Kurzfassung, garantiert einzeilig (Ellipsis als Sicherung) */
body.mobile .sub-full{display:none}
body.mobile .sub-short{display:inline}
body.mobile header p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Editor immer gestapelt (Textarea oben, Legende darunter) */
body.mobile .editor-body{flex-direction:column;align-items:stretch}
body.mobile .agenda{border-left:0;border-top:1px dashed rgba(36,52,71,.18)}
body.mobile .agenda[open]{width:auto}
/* Legende: keine dauerhafte Summary-Zeile, dafür der #legendBtn im Kopf */
body.mobile .agenda>summary{display:none}
body.mobile .legendbtn{display:inline-flex}
/* Sprachwahl schlank: eingeklappt nur die aktive Sprache. Ein Tipp klappt die
volle Leiste als Overlay über die Kopfzeile auf (verdeckt fsToggle usw.,
statt die Zeile zu verbreitern); nach der Auswahl klappt sie wieder ein
(JS). Das „…“ entfällt hier — die aktive Sprache ist selbst der Auslöser. */
body.mobile .header-tools{position:relative}
body.mobile .langsel button[data-lang]{display:none}
body.mobile .langsel button[data-lang].active{display:inline-block}
body.mobile .langmore{display:none}
body.mobile .langsel.expanded{
position:absolute;top:0;right:0;z-index:40;
background:var(--card);
box-shadow:0 6px 18px rgba(36,52,71,.28);
}
body.mobile .langsel.expanded button[data-lang]{display:inline-block}
/* Modus-Wähler kompakt: nur das aktive Icon (ein Drittel Platz), Tippen
schaltet reihum weiter (horizontal → kompakt → vertikal → …, siehe JS). */
body.mobile .seg label{display:none}
body.mobile .seg label:has(input:checked){display:flex}
/* Download schlank: nur der Trigger-Icon-Button; ein Tipp klappt SVG/PNG als
Dropdown-Overlay unter der Kopfzeile auf (verhindert den Zeilenumbruch durch
die breiten „SVG“/„PNG“-Labels). Kopieren bleibt sichtbar. */
body.mobile .dlgroup{position:relative}
body.mobile .dltrigger{display:inline-flex}
body.mobile .dltrigger[aria-expanded="true"]{border-color:var(--or);color:var(--or)}
body.mobile .dlmenu{display:none}
body.mobile .dlgroup.open .dlmenu{
display:flex;flex-direction:column;gap:5px;
position:absolute;top:calc(100% + 6px);right:0;z-index:40;
background:var(--card);border:1px solid rgba(36,52,71,.15);
border-radius:8px;padding:5px;box-shadow:0 6px 18px rgba(36,52,71,.28);
}
View File
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vitest/config';
import { viteSingleFile } from 'vite-plugin-singlefile';
// Bettet das Favicon (../docs/brand/favicon.svg, außerhalb des Roots) als
// data:-URI direkt in <link rel="icon"> ein, damit die gebaute Datei wirklich
// EINE self-contained Datei ist (kein Sibling-Asset). Läuft vor Vites eigener
// HTML-Asset-Auflösung ('pre'), damit Vite die data:-URI unangetastet lässt.
function inlineFavicon() {
return {
name: 'werkbaum-inline-favicon',
transformIndexHtml: {
order: 'pre',
handler(html) {
const svg = readFileSync(new URL('../docs/brand/favicon.svg', import.meta.url), 'utf8');
const uri = 'data:image/svg+xml,' + encodeURIComponent(svg.trim());
return html.replace(/href="[^"]*favicon\.svg"/, `href="${uri}"`);
},
},
};
}
// Werkbaum bleibt bewusst eine einzelne, self-contained Datei (file://-tauglich,
// D16). Vite dient nur als Bündler/Testrunner (D19): `vite build` inlint alle
// Module + CSS in dist/index.html; im Dev-Server (`vite`) werden sie einzeln
// geladen. `fs.allow: ['..']` lässt den Dev-Server das Favicon aus
// ../docs/brand/ ausliefern (liegt außerhalb des Projekt-Roots frontend/).
export default defineConfig({
root: '.',
plugins: [inlineFavicon(), viteSingleFile()],
server: { port: 8137, strictPort: true, fs: { allow: ['..'] } },
build: {
// Alles inlinen -> keine externen Assets, eine Datei.
assetsInlineLimit: 100_000_000,
cssCodeSplit: false,
},
test: {
environment: 'node',
include: ['tests/**/*.test.js'],
passWithNoTests: true,
},
});