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:
co-authored by
Claude Opus 4.8
parent
ad57707dbc
commit
78d1000f61
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
+20
-4
@@ -1,12 +1,28 @@
|
||||
# Werkbaum · Frontend
|
||||
|
||||
Editor: `index.html` ist der funktionierende Prototyp (Text links,
|
||||
Diagramm rechts, Toggles für transponierte Ansicht und verworfene Elemente).
|
||||
Editor: Text links, Diagramm rechts, Toggles für transponierte Ansicht und
|
||||
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
|
||||
- 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 —
|
||||
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);
|
||||
Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md;
|
||||
Pastelltöne nie im Logo.
|
||||
|
||||
+1
-1836
File diff suppressed because it is too large
Load Diff
Generated
+1897
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user