Files
werkbaum/frontend/index.html
T
mhoennigandClaude Opus 4.8 e2bdd73b57 frontend: Sprach-Aufklapper "…" bleibt am rechten Ende
Die aufklappbaren Sprachen (PL/RU/HI) erscheinen nun vor dem "…"-Button,
sodass dieser am rechten, gerundeten Ende der Sprachleiste bleibt und
das Auf-/Zuklappen dort stattfindet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:24:43 +02:00

1158 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Werkbaum WBS-Editor für Projektstrukturplan &amp; Feature-Tree (Und/Oder-Baum)</title>
<meta name="description" content="Werkbaum ist ein textbasierter Editor für Projektstrukturpläne (PSP / Work Breakdown Structure, WBS): Struktur als Text schreiben, das Und/Oder-Baum-Diagramm entsteht live. Ebenso als Feature-Tree für Anforderungen (Requirements) und Aufwandsschätzung.">
<link rel="icon" type="image/svg+xml" href="../docs/brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
: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);
height:100vh;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}
h1{font-size:1.25rem;font-weight:600;letter-spacing:-.01em}
header p{margin-top:4px;color:var(--muted);font-size:.9rem}
.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:28px 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}
/* 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}
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}
.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}
/* --- 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}
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);
}
/* Sammelleiste nur von erster bis letzter Kindmitte (23-px-Offset);
mittlere Kinder tragen die volle Höhe (Basis), Rand-Kinder halbieren. */
.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}
/* 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. */
</style>
</head>
<body>
<header>
<div class="header-row">
<h1 style="display:flex;align-items:center;gap:11px">
<svg width="34" height="34" viewBox="0 0 64 64" aria-hidden="true">
<rect x="22" y="5" width="20" height="12" rx="3.5" fill="#243447"/>
<path d="M32 17 V22 M14 22 H50 M14 22 V27 M50 22 V27" fill="none" stroke="#243447" stroke-width="3"/>
<rect x="4" y="27" width="20" height="12" rx="3.5" fill="#fff" stroke="#243447" stroke-width="3"/>
<rect x="40" y="27" width="20" height="12" rx="3.5" fill="#fff" stroke="#243447" stroke-width="3"/>
<path d="M50 39 V46" fill="none" stroke="#0F766E" stroke-width="3" stroke-dasharray="4 4"/>
<rect x="40" y="46" width="20" height="12" rx="3.5" fill="#fff" stroke="#0F766E" stroke-width="3"/>
</svg>
<span>Werk<span style="color:var(--or)">baum</span></span>
</h1>
<div class="langsel" role="radiogroup" aria-label="Sprache / Language">
<button type="button" data-lang="de" title="Deutsch">DE</button>
<button type="button" data-lang="en" title="English">EN</button>
<button type="button" data-lang="es" title="Español">ES</button>
<button type="button" data-lang="fr" title="Français">FR</button>
<button type="button" class="lang-extra" data-lang="pl" title="Polski">PL</button>
<button type="button" class="lang-extra" data-lang="ru" title="Русский">RU</button>
<button type="button" class="lang-extra" data-lang="hi" title="हिन्दी">HI</button>
<button type="button" id="langMore" class="langmore" aria-expanded="false" data-i18n-title="langMore" data-i18n-aria="langMore" title="weitere Sprachen" aria-label="weitere Sprachen"></button>
</div>
</div>
<p data-i18n="subtitle">Werkbaum Ein Text-basierter WBS/Projektstrukturplan/Feature-Tree Diagramm-Editor.</p>
</header>
<div class="app" id="app">
<div class="panel editor">
<div class="panel-head">
<span data-i18n="editorTitle">Struktur (Text)</span>
<button type="button" class="copybtn" id="copy" data-i18n-title="copyTitle" data-i18n-aria="copyTitle" title="Text in die Zwischenablage kopieren" aria-label="Text in die Zwischenablage kopieren">
<svg class="ic-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<svg class="ic-done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
</button>
<div class="winbtns" aria-label="Editor">
<button type="button" class="winbtn" data-state="b" data-i18n-title="minimize" title="minimieren"></button>
<button type="button" class="winbtn" data-state="normal" data-i18n-title="normal" title="normal"></button>
<button type="button" class="winbtn" data-state="a" data-i18n-title="maximize" title="maximieren"></button>
</div>
</div>
<div class="editor-body">
<textarea id="src" spellcheck="false"></textarea>
<details class="agenda">
<summary data-i18n="agenda">Agenda</summary>
<div class="hint" id="hint">
Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.<br>
<code>-</code>&nbsp; Teilpaket, alle erforderlich <em>(all of)</em><br>
<code class="or-code">|</code>&nbsp; Alternative, eine wählen <em>(any of)</em><br>
Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.<br>
Status als Kästchen nach dem Zeichen, z.&nbsp;B. <code>- [~] Frontend</code>:
<div class="chips">
<span class="chip st-idee">[?] Idee</span>
<span class="chip st-geplant">[&nbsp;] geplant</span>
<span class="chip st-arbeit">[~] in Arbeit</span>
<span class="chip st-durchstich">[/] Durchstich</span>
<span class="chip st-fertig">[x] fertig</span>
<span class="chip st-prod">[^] in Produktion</span>
<span class="chip st-verworfen">[-] verworfen</span>
</div>
Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:<br>
<code>- [ ] Backend (L) https://…</code><br>
Ab <code>(M)</code> gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.<br>
Kommentare mit <code>%%</code> — als ganze Zeile oder am Zeilenende.
Personen mit <code>@name</code> — erscheinen unten rechts am Knoten.
</div>
</details>
</div>
</div>
<div class="gutter" id="gutter" role="separator" aria-label="Bereiche größenverändern" title="Ziehen zum Verschieben"></div>
<div class="panel right">
<div class="panel-head">
<span data-i18n="diagramTitle">Diagramm</span>
<label class="switch">
<input type="checkbox" id="showc"><i></i><span data-i18n="discarded">verworfene</span>
</label>
<div class="seg" role="radiogroup" aria-label="Darstellung">
<label data-i18n-title="modeHorizontal" title="Horizontal Organigramm, Diagramm über dem Editor">
<input type="radio" name="layout" value="horizontal" checked data-i18n-aria="modeHorizontal" aria-label="Horizontal Organigramm">
<span><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="4" rx="1"/><rect x="2.5" y="17" width="6" height="4" rx="1"/><rect x="15.5" y="17" width="6" height="4" rx="1"/><path d="M12 7V13M5.5 13H18.5M5.5 13V17M18.5 13V17"/></svg></span>
</label>
<label data-i18n-title="modeKompakt" title="Kompakt alles nach unten, platzsparend">
<input type="radio" name="layout" value="kompakt" data-i18n-aria="modeKompakt" aria-label="Kompakt alles nach unten">
<span><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="6" height="4" rx="1"/><path d="M6 7V20"/><path d="M6 11H10M6 17H10"/><rect x="10" y="9" width="9" height="4" rx="1"/><rect x="10" y="15" width="9" height="4" rx="1"/></svg></span>
</label>
<label data-i18n-title="modeVertikal" title="Vertikal Baum nach rechts, Diagramm neben dem Editor">
<input type="radio" name="layout" value="vertikal" data-i18n-aria="modeVertikal" aria-label="Vertikal Baum nach rechts">
<span><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="9.5" width="5" height="5" rx="1"/><rect x="16" y="3" width="6" height="4" rx="1"/><rect x="16" y="17" width="6" height="4" rx="1"/><path d="M7.5 12H12M12 5V19M12 5H16M12 19H16"/></svg></span>
</label>
</div>
<button type="button" class="copybtn" id="copyDiagram" data-i18n-title="copyDiagramTitle" data-i18n-aria="copyDiagramTitle" title="Diagramm als Textbaum kopieren" aria-label="Diagramm als Textbaum kopieren">
<svg class="ic-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<svg class="ic-done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
</button>
<div class="winbtns" aria-label="Diagramm">
<button type="button" class="winbtn" data-state="a" data-i18n-title="minimize" title="minimieren"></button>
<button type="button" class="winbtn" data-state="normal" data-i18n-title="normal" title="normal"></button>
<button type="button" class="winbtn" data-state="b" data-i18n-title="maximize" title="maximieren"></button>
</div>
</div>
<div class="diagram"><ul class="tree" id="out"></ul></div>
<div class="warnings" id="warn"></div>
<div class="zoomctl" data-i18n-aria="zoomAria" aria-label="Zoom (Strg/Cmd + Mausrad)">
<button type="button" id="zoomOut" data-i18n-title="zoomOut" title="verkleinern"></button>
<button type="button" id="zoomReset" data-i18n-title="zoomReset" title="zurücksetzen">100&nbsp;%</button>
<button type="button" id="zoomIn" data-i18n-title="zoomIn" title="vergrößern">+</button>
</div>
</div>
</div>
<footer class="site-footer">Werkbaum 1.0 © 2026 <a href="https://michael.hoennig.de" target="_blank" rel="noopener">Michael Hönnig</a> · Vibe-Coded with Claude Code Fable 5 and Opus 4.8 <a href="../LICENSE" target="_blank" rel="noopener">MIT-License</a></footer>
<script>
const INITIAL = `%% Projektstruktur Stand Sprint 14
[~] Website-Relaunch (XL) https://wiki.example.de/relaunch
- [x] Konzeption (M)
- [x] Zielgruppenanalyse (S)
- [x] Sitemap (XS)
- [~] Umsetzung (XL)
- [/] Frontend (S) https://git.example.de/frontend @anna
- [ ] Backend (L) @ben @carla
- [ ] CMS-Anbindung (M)
| [ ] WordPress
| [?] Headless CMS
| [-] Eigenentwicklung %% Aufwand zu hoch
- [?] Hosting (M)
| Cloud
| On-Premise`;
const SIZE_RANK = {XS:0, S:1, M:2, L:3, XL:4, XXL:5};
const STATUS_BY_CODE = {
'?': {key:'idee', name:'Idee'},
' ': {key:'geplant', name:'geplant'},
'~': {key:'arbeit', name:'in Arbeit'},
'/': {key:'durchstich', name:'Durchstich funktionsbereit, Feinarbeiten offen'},
'x': {key:'fertig', name:'fertig'},
'^': {key:'prod', name:'in Produktion'},
'-': {key:'verworfen', name:'verworfen'}
};
/* Rück-Abbildung Status-Key → Code (für den Text-Export des Diagramms) */
const CODE_BY_KEY = {idee:'?', geplant:' ', arbeit:'~', durchstich:'/', fertig:'x', prod:'^', verworfen:'-'};
const src = document.getElementById('src');
const out = document.getElementById('out');
const warnBox = document.getElementById('warn');
function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ---------- Parser ---------- */
function parse(text){
const virtualRoot = {label:'', type:'and', children:[]};
const stack = [{node:virtualRoot, width:-1}];
const warnings = [];
text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
if(!raw.trim()) return;
const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([ ?~xX^\/-])]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : 'and';
const status = m[3] ? STATUS_BY_CODE[m[3].toLowerCase()] : null;
let rest = m[4], url = null, size = null;
const tags = [];
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; });
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
const label = rest.replace(/\s+/g, ' ').trim();
if(!label) return;
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, status, url, size, tags, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
});
return {roots: virtualRoot.children, warnings};
}
/* ---------- Renderer ---------- */
function gateOf(children){
return children.length && children[0].type === 'or' ? 'or' : 'and';
}
function needsBreakdown(n){
if(n.status && n.status.key === 'verworfen') return false;
return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length;
}
function visibleChildren(n){
if(showc.checked) return n.children;
return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
}
function nodeHtml(n, extra){
const need = needsBreakdown(n);
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
.filter(Boolean).join(' ');
const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'&quot;')}"` : '';
const tagsHtml = n.tags && n.tags.length
? `<span class="tags">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
: '';
const inner = esc(n.label) +
(n.url ? '<span class="ext">↗</span>' : '') +
(n.size ? `<span class="size">${n.size}</span>` : '') +
tagsHtml;
const html = n.url
? `<a class="${cls}" href="${esc(n.url).replace(/"/g,'&quot;')}" target="_blank" rel="noopener"${title}>${inner}</a>`
: `<div class="${cls}"${title}>${inner}</div>`;
const ghost = `<div class="ghost-node">${esc(t('ghost'))}</div>`;
return html + (need ? ghost : '');
}
function renderChildren(node, warnings){
const kids = visibleChildren(node);
if(!kids.length) return '';
const types = new Set(kids.map(k => k.type));
if(types.size > 1){
warnings.push(t('mixedWarn', {line: kids[0].line, label: esc(node.label)}));
}
const gate = gateOf(kids);
const items = kids.map(k => {
const vk = visibleChildren(k);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` +
nodeHtml(k) +
renderChildren(k, warnings) +
`</li>`;
}).join('');
return `<ul class="${gate}">${items}</ul>`;
}
function render(){
let {roots} = parse(src.value);
const warnings = [];
if(!showc.checked){
roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
}
if(!roots.length){
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
warnBox.innerHTML = '';
return;
}
out.innerHTML = roots.map(root => {
const vk = visibleChildren(root);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` +
nodeHtml(root, 'root-node') +
renderChildren(root, warnings) +
`</li>`;
}).join('');
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${w}</div>`).join('');
}
/* ---------- Diagramm als einfacher Text-Baum ---------- */
/* Einfachstes, aber überall einsetzbares Format: eingerückter ASCII-Baum.
all-of-Abzweige mit ├─/└─, any-of-Alternativen mit ├◇/└◇. Je Knoten:
[Status] Label (Größe) @tags. Respektiert den „verworfene“-Filter, gibt
also genau die sichtbare Struktur wieder. */
function nodeLabelText(n){
let s = n.status ? '[' + (CODE_BY_KEY[n.status.key] ?? ' ') + '] ' : '';
s += n.label;
if(n.size) s += ' (' + n.size + ')';
if(n.tags && n.tags.length) s += ' ' + n.tags.map(x => '@' + x).join(' ');
return s;
}
function walkText(node, prefix, lines){
const kids = visibleChildren(node);
const gate = gateOf(kids);
kids.forEach((k, i) => {
const last = i === kids.length - 1;
const conn = gate === 'or'
? (last ? '└◇ ' : '├◇ ')
: (last ? '└─ ' : '├─ ');
lines.push(prefix + conn + nodeLabelText(k));
walkText(k, prefix + (last ? ' ' : '│ '), lines);
});
}
function treeToText(){
let {roots} = parse(src.value);
if(!showc.checked){
roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
}
const blocks = roots.map(root => {
const lines = [nodeLabelText(root)];
walkText(root, '', lines);
return lines.join('\n');
});
return blocks.join('\n\n'); /* mehrere Wurzeln durch Leerzeile trennen */
}
/* Tab-Taste rückt ein statt den Fokus zu wechseln */
src.addEventListener('keydown', e => {
if(e.key === 'Tab'){
e.preventDefault();
const {selectionStart:s, selectionEnd:eEnd, value} = src;
src.value = value.slice(0, s) + ' ' + value.slice(eEnd);
src.selectionStart = src.selectionEnd = s + 2;
render();
}
});
src.addEventListener('input', render);
const app = document.getElementById('app');
function applyLayout(mode){
out.classList.toggle('vertical', mode === 'vertikal');
out.classList.toggle('kompakt', mode === 'kompakt');
app.classList.toggle('side', mode !== 'horizontal');
applySplit(); /* Preset für die neue Orientierung neu setzen */
}
document.querySelectorAll('input[name="layout"]').forEach(radio => {
radio.addEventListener('change', () => { if(radio.checked) applyLayout(radio.value); });
});
/* ---------- Min/Normal/Max je Bereich (Fenster-Buttons) ---------- */
/* Zustand 'a' = Diagramm minimiert (Editor maximiert),
'b' = Editor minimiert (Diagramm maximiert), 'normal' = beide sichtbar.
Minimieren schrumpft ein Panel auf seine Titelzeile; Maximieren des einen
entspricht dem Minimieren des anderen. */
let splitState = 'normal';
const editorPanel = document.querySelector('.panel.editor');
const diagramPanel = document.querySelector('.panel.right');
function clearCollapse(){
app.classList.remove('collapse-editor','collapse-diagram');
editorPanel.classList.remove('collapsed');
diagramPanel.classList.remove('collapsed');
document.querySelectorAll('.winbtn').forEach(b => b.classList.remove('active'));
}
function applySplit(){
/* Preset-/Drag-Größen bei Preset-Wechsel zurücksetzen */
app.style.removeProperty('--col');
app.style.removeProperty('--drow');
app.classList.toggle('collapse-diagram', splitState==='a');
app.classList.toggle('collapse-editor', splitState==='b');
diagramPanel.classList.toggle('collapsed', splitState==='a');
editorPanel.classList.toggle('collapsed', splitState==='b');
document.querySelectorAll('.winbtn').forEach(b => b.classList.toggle('active', b.dataset.state===splitState));
}
/* Setzt einen Minimier-Zustand während des Ziehens nur bei Änderung. */
function snapTo(state){ if(splitState!==state){ splitState = state; applySplit(); } }
document.querySelectorAll('.winbtn').forEach(b => {
b.addEventListener('click', e => {
/* Fenster-Buttons sind maßgeblich: nicht zum Titelzeilen-Restore durchreichen */
e.stopPropagation();
splitState = b.dataset.state; applySplit();
});
});
/* Klick auf die Titelzeile eines minimierten Panels stellt es wieder her. */
[editorPanel, diagramPanel].forEach(panel => {
panel.querySelector('.panel-head').addEventListener('click', e => {
if(e.target.closest('button, label, input')) return; /* Bedienelemente behalten ihre Funktion */
if(panel.classList.contains('collapsed')){ splitState = 'normal'; applySplit(); }
});
});
/* ---------- Splitter: Bereiche per Drag verteilen ---------- */
const gutter = document.getElementById('gutter');
const diagramEl = document.querySelector('.diagram');
let dragging = false;
gutter.addEventListener('pointerdown', e => {
dragging = true;
gutter.classList.add('dragging');
gutter.setPointerCapture(e.pointerId);
document.body.style.userSelect = 'none';
/* Freies Ziehen hebt Preset und Minimierung auf */
splitState = 'custom';
clearCollapse();
e.preventDefault();
});
/* Näher als SNAP an einem Rand: in den minimierten Zustand einrasten,
sonst frei skalieren. So sind beide Extreme per Splitter erreichbar. */
const SNAP = 72;
gutter.addEventListener('pointermove', e => {
if(!dragging) return;
const rect = app.getBoundingClientRect();
if(app.classList.contains('side')){
const w = e.clientX - rect.left, maxw = rect.width - 14;
if(w <= SNAP) snapTo('b'); /* Editor auf Titelzeile */
else if(w >= maxw - SNAP) snapTo('a'); /* Diagramm auf Titelzeile */
else {
if(splitState!=='custom'){ splitState='custom'; clearCollapse(); }
app.style.setProperty('--col', w + 'px');
}
} else {
const h = e.clientY - rect.top, maxh = rect.height - 14;
if(h <= SNAP) snapTo('a'); /* Diagramm (oben) auf Titelzeile */
else if(h >= maxh - SNAP) snapTo('b'); /* Editor (unten) auf Titelzeile */
else {
if(splitState!=='custom'){ splitState='custom'; clearCollapse(); }
app.style.setProperty('--drow', h + 'px');
}
}
});
function endDrag(e){
if(!dragging) return;
dragging = false;
gutter.classList.remove('dragging');
document.body.style.userSelect = '';
try{ gutter.releasePointerCapture(e.pointerId); }catch(_){}
}
gutter.addEventListener('pointerup', endDrag);
gutter.addEventListener('pointercancel', endDrag);
/* Doppelklick auf den Splitter stellt die normale Aufteilung wieder her. */
gutter.addEventListener('dblclick', () => { splitState = 'normal'; applySplit(); });
/* ---------- Zoom für das Diagramm ---------- */
/* CSS-`zoom` skaliert die Layout-Box, dadurch greifen die Scrollbalken
des Diagramm-Containers korrekt (anders als transform: scale). */
const ZMIN = 0.3, ZMAX = 3, ZSTEP = 0.1;
let zoom = 1;
function applyZoom(){
zoom = Math.min(ZMAX, Math.max(ZMIN, Math.round(zoom * 100) / 100));
out.style.zoom = zoom;
document.getElementById('zoomReset').textContent = Math.round(zoom * 100) + ' %';
}
document.getElementById('zoomIn').addEventListener('click', () => { zoom += ZSTEP; applyZoom(); });
document.getElementById('zoomOut').addEventListener('click', () => { zoom -= ZSTEP; applyZoom(); });
document.getElementById('zoomReset').addEventListener('click', () => { zoom = 1; applyZoom(); });
diagramEl.addEventListener('wheel', e => {
if(!(e.ctrlKey || e.metaKey)) return; /* Strg/Cmd + Mausrad zoomt */
e.preventDefault();
zoom += (e.deltaY < 0 ? ZSTEP : -ZSTEP);
applyZoom();
}, {passive:false});
const showc = document.getElementById('showc');
showc.addEventListener('change', render);
/* ---------- In die Zwischenablage kopieren ---------- */
async function writeClipboard(text){
try{ await navigator.clipboard.writeText(text); return; }catch(_){}
const ta = document.createElement('textarea'); /* Fallback ohne Clipboard-API */
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try{ document.execCommand('copy'); }catch(_){}
document.body.removeChild(ta);
}
function flashCopied(btn, restoreTitleKey){
btn.classList.add('done');
btn.title = t('copyDone');
btn.setAttribute('aria-label', t('copyDone'));
setTimeout(() => {
btn.classList.remove('done');
btn.title = t(restoreTitleKey);
btn.setAttribute('aria-label', t(restoreTitleKey));
}, 1500);
}
document.getElementById('copy').addEventListener('click', async () => {
await writeClipboard(src.value);
flashCopied(document.getElementById('copy'), 'copyTitle');
});
document.getElementById('copyDiagram').addEventListener('click', async () => {
await writeClipboard(treeToText());
flashCopied(document.getElementById('copyDiagram'), 'copyDiagramTitle');
});
/* ---------- Internationalisierung (DE/EN/ES/FR) ---------- */
const I18N = {
de: {
subtitle:"Werkbaum Ein Text-basierter WBS/Projektstrukturplan/Feature-Tree Diagramm-Editor.",
editorTitle:"Struktur (Text)", diagramTitle:"Diagramm",
copy:"kopieren", copyDone:"kopiert ✓", copyTitle:"Text in die Zwischenablage kopieren",
copyDiagramTitle:"Diagramm als Textbaum kopieren",
minimize:"minimieren", normal:"normal", maximize:"maximieren",
agenda:"Agenda", discarded:"verworfene",
modeHorizontal:"Horizontal Organigramm, Diagramm über dem Editor",
modeKompakt:"Kompakt alles nach unten, platzsparend",
modeVertikal:"Vertikal Baum nach rechts, Diagramm neben dem Editor",
zoomOut:"verkleinern", zoomReset:"zurücksetzen", zoomIn:"vergrößern",
zoomAria:"Zoom (Strg/Cmd + Mausrad)", langMore:"weitere Sprachen",
empty:"Noch keine Struktur — einfach lostippen.", ghost:"Untergliederung fehlt",
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
st_fertig:"fertig", st_prod:"in Produktion", st_verworfen:"verworfen",
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
hint_root:"Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.",
hint_status:"Status als Kästchen nach dem Zeichen, z. B.",
hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:",
hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.",
hint_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.",
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten."
},
en: {
subtitle:"Werkbaum A text-based WBS / work breakdown structure / feature-tree diagram editor.",
editorTitle:"Structure (text)", diagramTitle:"Diagram",
copy:"copy", copyDone:"copied ✓", copyTitle:"Copy text to clipboard",
copyDiagramTitle:"Copy diagram as a text tree",
minimize:"minimize", normal:"normal", maximize:"maximize",
agenda:"Legend", discarded:"discarded",
modeHorizontal:"Horizontal org chart, diagram above the editor",
modeKompakt:"Compact everything downward, space-saving",
modeVertikal:"Vertical tree to the right, diagram beside the editor",
zoomOut:"zoom out", zoomReset:"reset", zoomIn:"zoom in",
zoomAria:"Zoom (Ctrl/Cmd + mouse wheel)", langMore:"more languages",
empty:"No structure yet — just start typing.", ghost:"Breakdown missing",
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
st_fertig:"done", st_prod:"in production", st_verworfen:"discarded",
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one",
hint_root:"Line without a marker = root node. Siblings should share the same marker.",
hint_status:"Status as a checkbox after the marker, e.g.",
hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:",
hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.",
hint_comment:"Comments with %% — whole line or at the end of a line.",
hint_people:"People with @name — shown at the bottom-right of the node."
},
es: {
subtitle:"Werkbaum Un editor de diagramas basado en texto para EDT / estructura de desglose del trabajo / árbol de características.",
editorTitle:"Estructura (texto)", diagramTitle:"Diagrama",
copy:"copiar", copyDone:"copiado ✓", copyTitle:"Copiar el texto al portapapeles",
copyDiagramTitle:"Copiar el diagrama como árbol de texto",
minimize:"minimizar", normal:"normal", maximize:"maximizar",
agenda:"Leyenda", discarded:"descartados",
modeHorizontal:"Horizontal organigrama, diagrama sobre el editor",
modeKompakt:"Compacto todo hacia abajo, ahorra espacio",
modeVertikal:"Vertical árbol hacia la derecha, diagrama junto al editor",
zoomOut:"alejar", zoomReset:"restablecer", zoomIn:"acercar",
zoomAria:"Zoom (Ctrl/Cmd + rueda del ratón)", langMore:"más idiomas",
empty:"Aún no hay estructura — simplemente empieza a escribir.", ghost:"Falta el desglose",
mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
st_fertig:"terminado", st_prod:"en producción", st_verworfen:"descartado",
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
hint_root:"Línea sin marcador = nodo raíz. Los hermanos deberían llevar el mismo marcador.",
hint_status:"Estado como casilla tras el marcador, p. ej.",
hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:",
hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.",
hint_comment:"Comentarios con %% — línea completa o al final de la línea.",
hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo."
},
fr: {
subtitle:"Werkbaum Un éditeur de diagrammes basé sur le texte pour WBS / organigramme des tâches / arbre de fonctionnalités.",
editorTitle:"Structure (texte)", diagramTitle:"Diagramme",
copy:"copier", copyDone:"copié ✓", copyTitle:"Copier le texte dans le presse-papiers",
copyDiagramTitle:"Copier le diagramme en arbre de texte",
minimize:"réduire", normal:"normal", maximize:"agrandir",
agenda:"Légende", discarded:"abandonnés",
modeHorizontal:"Horizontal organigramme, diagramme au-dessus de l'éditeur",
modeKompakt:"Compact tout vers le bas, gain de place",
modeVertikal:"Vertical arbre vers la droite, diagramme à côté de l'éditeur",
zoomOut:"dézoomer", zoomReset:"réinitialiser", zoomIn:"zoomer",
zoomAria:"Zoom (Ctrl/Cmd + molette)", langMore:"plus de langues",
empty:"Pas encore de structure — commencez à taper.", ghost:"Décomposition manquante",
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
st_fertig:"terminé", st_prod:"en production", st_verworfen:"abandonné",
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
hint_root:"Ligne sans marqueur = nœud racine. Les frères devraient porter le même marqueur.",
hint_status:"Statut sous forme de case après le marqueur, p. ex.",
hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :",
hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.",
hint_comment:"Commentaires avec %% — ligne entière ou en fin de ligne.",
hint_people:"Personnes avec @nom — affichées en bas à droite du nœud."
},
pl: {
subtitle:"Werkbaum Edytor diagramów oparty na tekście: WBS / struktura podziału pracy / drzewo funkcji.",
editorTitle:"Struktura (tekst)", diagramTitle:"Diagram",
copy:"kopiuj", copyDone:"skopiowano ✓", copyTitle:"Kopiuj tekst do schowka",
copyDiagramTitle:"Kopiuj diagram jako drzewo tekstowe",
minimize:"minimalizuj", normal:"normalny", maximize:"maksymalizuj",
agenda:"Legenda", discarded:"odrzucone",
modeHorizontal:"Poziomo schemat organizacyjny, diagram nad edytorem",
modeKompakt:"Kompaktowo wszystko w dół, oszczędza miejsce",
modeVertikal:"Pionowo drzewo w prawo, diagram obok edytora",
zoomOut:"pomniejsz", zoomReset:"resetuj", zoomIn:"powiększ",
zoomAria:"Powiększenie (Ctrl/Cmd + kółko myszy)", langMore:"więcej języków",
empty:"Brak struktury — zacznij pisać.", ghost:"Brak podziału",
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
st_fertig:"gotowe", st_prod:"w produkcji", st_verworfen:"odrzucone",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
hint_root:"Wiersz bez znacznika = węzeł główny. Rodzeństwo powinno mieć ten sam znacznik.",
hint_status:"Status jako pole wyboru po znaczniku, np.",
hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:",
hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.",
hint_comment:"Komentarze z %% — cały wiersz lub na końcu wiersza.",
hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła."
},
ru: {
subtitle:"Werkbaum – текстовый редактор диаграмм: СДР / структура декомпозиции работ / дерево функций.",
editorTitle:"Структура (текст)", diagramTitle:"Диаграмма",
copy:"копировать", copyDone:"скопировано ✓", copyTitle:"Скопировать текст в буфер обмена",
copyDiagramTitle:"Скопировать диаграмму как текстовое дерево",
minimize:"свернуть", normal:"обычный", maximize:"развернуть",
agenda:"Легенда", discarded:"отклонённые",
modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором",
modeKompakt:"Компактно – всё вниз, экономит место",
modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором",
zoomOut:"уменьшить", zoomReset:"сбросить", zoomIn:"увеличить",
zoomAria:"Масштаб (Ctrl/Cmd + колесо мыши)", langMore:"ещё языки",
empty:"Пока нет структуры — просто начните печатать.", ghost:"Нет декомпозиции",
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_verworfen:"отклонено",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_root:"Строка без маркера = корневой узел. У соседних узлов должен быть одинаковый маркер.",
hint_status:"Статус в виде флажка после маркера, напр.",
hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:",
hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.",
hint_comment:"Комментарии через %% — вся строка или в конце строки.",
hint_people:"Люди через @имя — показываются справа внизу узла."
},
hi: {
subtitle:"Werkbaum – टेक्स्ट-आधारित आरेख संपादक: WBS / कार्य विभाजन संरचना / फ़ीचर-ट्री।",
editorTitle:"संरचना (टेक्स्ट)", diagramTitle:"आरेख",
copy:"कॉपी करें", copyDone:"कॉपी हो गया ✓", copyTitle:"टेक्स्ट को क्लिपबोर्ड पर कॉपी करें",
copyDiagramTitle:"आरेख को टेक्स्ट-ट्री के रूप में कॉपी करें",
minimize:"छोटा करें", normal:"सामान्य", maximize:"बड़ा करें",
agenda:"लेजेंड", discarded:"अस्वीकृत",
modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख",
modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है",
modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख",
zoomOut:"ज़ूम आउट", zoomReset:"रीसेट करें", zoomIn:"ज़ूम इन",
zoomAria:"ज़ूम (Ctrl/Cmd + माउस-व्हील)", langMore:"और भाषाएँ",
empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"विभाजन अनुपस्थित",
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_verworfen:"अस्वीकृत",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। सहोदर नोड्स का मार्कर समान होना चाहिए।",
hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे",
hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:",
hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।",
hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।",
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।"
}
};
let lang = 'de';
function t(key, vars){
let s = (I18N[lang] && I18N[lang][key]) ?? I18N.de[key] ?? key;
if(vars) for(const k in vars) s = s.split('{'+k+'}').join(vars[k]);
return s;
}
function buildHint(){
const chip = (key, code) => `<span class="chip st-${key}">${code}&nbsp;${esc(t('st_'+key))}</span>`;
return `${esc(t('hint_indent'))}<br>
<code>-</code>&nbsp; ${esc(t('hint_all'))} <em>(all of)</em><br>
<code class="or-code">|</code>&nbsp; ${esc(t('hint_any'))} <em>(any of)</em><br>
${esc(t('hint_root'))}<br>
${esc(t('hint_status'))} <code>- [~] Frontend</code>:
<div class="chips">
${chip('idee','[?]')}
${chip('geplant','[&nbsp;]')}
${chip('arbeit','[~]')}
${chip('durchstich','[/]')}
${chip('fertig','[x]')}
${chip('prod','[^]')}
${chip('verworfen','[-]')}
</div>
${esc(t('hint_size'))}<br>
<code>- [ ] Backend (L) https://…</code><br>
${esc(t('hint_break'))}<br>
${esc(t('hint_comment'))}
${esc(t('hint_people'))}`;
}
function applyLang(l){
lang = l;
document.documentElement.lang = l;
document.querySelectorAll('[data-i18n]').forEach(el => el.textContent = t(el.dataset.i18n));
document.querySelectorAll('[data-i18n-title]').forEach(el => el.title = t(el.dataset.i18nTitle));
document.querySelectorAll('[data-i18n-aria]').forEach(el => el.setAttribute('aria-label', t(el.dataset.i18nAria)));
document.getElementById('hint').innerHTML = buildHint();
document.querySelectorAll('.langsel button[data-lang]').forEach(b => b.classList.toggle('active', b.dataset.lang===l));
/* Ist eine aufklappbare Sprache aktiv, den erweiterten Bereich offen halten. */
if(LANG_EXTRA.includes(l)){
langsel.classList.add('expanded');
langMore.setAttribute('aria-expanded', 'true');
}
render();
try{ localStorage.setItem('werkbaum-lang', l); }catch(_){}
}
const langsel = document.querySelector('.langsel');
const langMore = document.getElementById('langMore');
const LANG_EXTRA = ['pl','ru','hi'];
langMore.addEventListener('click', () => {
const exp = langsel.classList.toggle('expanded');
langMore.setAttribute('aria-expanded', String(exp));
});
document.querySelectorAll('.langsel button[data-lang]').forEach(b => {
b.addEventListener('click', () => applyLang(b.dataset.lang));
});
applySplit(); /* Startzustand: normal, markiert die normal-Buttons aktiv */
src.value = INITIAL;
let startLang = 'de';
try{ startLang = localStorage.getItem('werkbaum-lang') || 'de'; }catch(_){}
applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */
</script>
</body>
</html>