frontend: Ein-Bereich-Ansicht + kompakte Legende/Sprachwahl auf Mobil

Auf kleinem Bildschirm (body.mobile per matchMedia, <=640px):
- Immer nur ein Bereich gross (Diagramm ODER Editor); der andere schrumpft
  auf seine Titelzeile und dient als Umschalter (Antippen holt ihn nach
  vorn). Splitter und Min/Normal/Max-Buttons entfallen.
- Legende von der Bereichs-Umschaltung entkoppelt: eigener Umschalter
  (#legendBtn) im Editor-Kopf statt der dauerhaften "AGENDA"-Zeile, damit
  sie zugeklappt keinen Platz kostet (neuer i18n-Key legendTooltip, 9 Spr.).
- Sprachwahl schlank: nur EN + aktive Sprache + "…" (klappt alle auf); die
  aktive Sprache wird auch als "extra" eingeblendet, Auto-Aufklappen entfaellt.
- Default Vollbild auf Mobil (nur ohne gespeicherte Nutzerwahl).

Verifiziert (headless, 375px): body.mobile gesetzt, Diagramm-Default +
Editor als Titelzeile, Umschalten per Tap, Legenden-Toggle, Sprachleiste
DE+EN+…/Aufklappen zeigt alle 9/aktives Extra bleibt sichtbar, Vollbild an,
keine Konsolenfehler; Desktop unveraendert (beide Panels, 4 Sprachen,
Legendenbutton verborgen). i18n konsistent (47 Keys x 9).

SPEC §9 (Kleiner Bildschirm) + DECISIONS D17 + frontend/CLAUDE.md ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-21 14:14:31 +02:00
co-authored by Claude Opus 4.8
parent ac81f0a209
commit 506bdccd2e
4 changed files with 111 additions and 3 deletions
+13
View File
@@ -134,3 +134,16 @@ zählt der flache CI-Klon nur einen Commit. `1.0` bleibt die Version beim lokale
Öffnen (Platzhalter im Quelltext).
(Nummerierung: D15 war bereits für den kompakten Modus vergeben, daher D16.)
## D17 — Kleiner Bildschirm: ein Bereich, kompakte Legende & Sprachwahl
Auf schmalen Viewports (≤ 640 px) ist für beide Bereiche kein Platz. Daher ist
immer nur **ein** Bereich groß (Diagramm oder Editor); der andere schrumpft auf
seine Titelzeile und dient als Umschalter (Antippen bringt ihn nach vorn),
Splitter und Fenster-Buttons entfallen. Die Legende wird von der Bereichs-
Umschaltung **entkoppelt** und über einen eigenen Umschalter im Editor-Kopf
gesteuert — das dauerhafte „AGENDA“-Band kostet sonst zu viel Höhe. Die
Sprachleiste zeigt nur Englisch + aktive Sprache + „…“. Voreinstellung ist
**Vollbild** (maximale Fläche). Umgesetzt über die Klasse `body.mobile` (per
`matchMedia`), damit CSS und JS denselben Schwellwert teilen; die Vollbild-
Voreinstellung greift nur, wenn noch **keine** gespeicherte Nutzerwahl vorliegt
(eine bewusste Abschaltung bleibt so erhalten, siehe localStorage-Persistenz).
+10
View File
@@ -141,6 +141,16 @@ ist grau (`#6B7A8C`) — kein Petrol mehr im Diagramm. Der Modus ändert nur die
all-of-Zwischenknoten bekommen ihn **symmetrisch**, damit ihr Abzweig
(50 %-Höhe) weiterhin auf der Knotenmitte liegt.
### Kleiner Bildschirm (mobil)
Ab schmaler Breite (≤ 640 px, z. B. Smartphone) ist immer nur **ein** Bereich
groß sichtbar — Diagramm **oder** Texteditor; der andere schrumpft auf seine
Titelzeile und dient als Umschalter (Antippen bringt ihn nach vorn). Splitter
und Min/Normal/Max-Buttons entfallen. Die **Legende** bekommt hier einen eigenen
Umschalter im Editor-Kopf (statt der dauerhaften „AGENDA“-Zeile), damit sie
zugeklappt keinen Platz kostet. Die **Sprachwahl** zeigt nur Englisch und die
aktive Sprache plus „…“ (das alle Sprachen aufklappt). Voreinstellung auf
kleinem Bildschirm ist **Vollbild** (siehe D17).
### Grafikexport des Diagramms
Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen
+ Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen:
+6
View File
@@ -31,3 +31,9 @@ Diagramm rechts, Toggles für transponierte Ansicht und verworfene Elemente).
während `restoring===true` nichts, damit das Wiederherstellen nicht sofort
zurückschreibt. Fehlender `werkbaum-src` fällt auf `INITIAL` zurück, ein
leerer String bleibt jedoch leer.
- Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) schaltet auf
Ein-Bereich-Ansicht (Diagramm **oder** Editor, Umschalten per Titelzeile),
eigenen Legenden-Umschalter (`#legendBtn`) und schlanke Sprachwahl; Default
Vollbild. Layout-CSS hängt an `body.mobile`, nicht an einer eigenen
`@media`-Regel — beide Seiten müssen denselben 640-px-Schwellwert nutzen
(SPEC §9, D17).
+82 -3
View File
@@ -250,6 +250,7 @@
.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}
.legendbtn{display:none} /* nur auf kleinem Bildschirm sichtbar */
textarea{
display:block;width:100%;min-height:0;
border:0;outline:none;resize:none;
@@ -484,6 +485,36 @@
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): nur ein Panel ----------
body.mobile wird per matchMedia (<=640px) gesetzt. Es ist immer genau
EIN Panel groß, das andere schrumpft auf seine Titelzeile und dient als
Umschalter (Tippen bringt es nach vorn). Splitter und Fenster-Buttons
entfallen; die Legende bekommt einen eigenen Umschalter statt der
dauerhaften „AGENDA“-Zeile. */
body.mobile .gutter{display:none}
body.mobile .winbtns{display:none}
body.mobile .app,
body.mobile .app.side{
grid-template-columns:minmax(0,1fr);
grid-template-areas:"diagram" "editor";
}
body.mobile .app.collapse-diagram,
body.mobile .app.side.collapse-diagram{grid-template-rows:auto minmax(0,1fr)}
body.mobile .app.collapse-editor,
body.mobile .app.side.collapse-editor{grid-template-rows:minmax(0,1fr) auto}
/* 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: nur EN + aktuelle Sprache + „…“; „…“ klappt alle auf */
body.mobile .langsel button[data-lang]{display:none}
body.mobile .langsel.expanded button[data-lang]{display:inline-block}
body.mobile .langsel button[data-lang="en"],
body.mobile .langsel button[data-lang].active{display:inline-block}
</style>
</head>
<body>
@@ -530,6 +561,9 @@
<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>
<button type="button" class="copybtn legendbtn" id="legendBtn" data-i18n-title="legendTooltip" data-i18n-aria="legendTooltip" title="Legende ein-/ausblenden" aria-label="Legende ein-/ausblenden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></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>
@@ -973,7 +1007,12 @@ document.querySelectorAll('.winbtn').forEach(b => {
[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(); }
if(!panel.classList.contains('collapsed')) return;
/* Klein: das getippte Panel nach vorn holen (das andere schrumpft);
sonst: zurück in die normale Zweiteilung. */
if(isMobile()) splitState = (panel === editorPanel) ? 'a' : 'b';
else splitState = 'normal';
applySplit();
});
});
@@ -1098,6 +1137,7 @@ const I18N = {
de: {
subtitle:"Werkbaum Ein Text-basierter WBS/Projektstrukturplan/Feature-Tree Diagramm-Editor.",
imprint:"Impressum",
legendTooltip:"Legende ein-/ausblenden",
ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.",
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
fullscreenTooltip:"Vollbild Panels nutzen die ganze Fensterbreite",
@@ -1130,6 +1170,7 @@ const I18N = {
en: {
subtitle:"Werkbaum A text-based WBS / work breakdown structure / feature-tree diagram editor.",
imprint:"Imprint (Impressum)",
legendTooltip:"Show/hide legend",
ghostTooltip:"From size M upward, an item should be broken down further.",
discardedTooltip:"Show/hide discarded nodes and their subtree",
fullscreenTooltip:"Full screen panels use the full window width",
@@ -1162,6 +1203,7 @@ const I18N = {
es: {
subtitle:"Werkbaum Un editor de diagramas basado en texto para EDT / estructura de desglose del trabajo / árbol de características.",
imprint:"Aviso legal (Impressum)",
legendTooltip:"Mostrar u ocultar la leyenda",
ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.",
discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol",
fullscreenTooltip:"Pantalla completa los paneles usan todo el ancho de la ventana",
@@ -1194,6 +1236,7 @@ const I18N = {
fr: {
subtitle:"Werkbaum Un éditeur de diagrammes basé sur le texte pour WBS / organigramme des tâches / arbre de fonctionnalités.",
imprint:"Mentions légales (Impressum)",
legendTooltip:"Afficher/masquer la légende",
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre",
fullscreenTooltip:"Plein écran les panneaux occupent toute la largeur de la fenêtre",
@@ -1226,6 +1269,7 @@ const I18N = {
pl: {
subtitle:"Werkbaum Edytor diagramów oparty na tekście: WBS / struktura podziału pracy / drzewo funkcji.",
imprint:"Nota prawna (Impressum)",
legendTooltip:"Pokaż/ukryj legendę",
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
fullscreenTooltip:"Pełny ekran panele wykorzystują całą szerokość okna",
@@ -1258,6 +1302,7 @@ const I18N = {
ru: {
subtitle:"Werkbaum – текстовый редактор диаграмм: СДР / структура декомпозиции работ / дерево функций.",
imprint:"Выходные данные (Impressum)",
legendTooltip:"Показать/скрыть легенду",
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
fullscreenTooltip:"Полный экран – панели занимают всю ширину окна",
@@ -1290,6 +1335,7 @@ const I18N = {
hi: {
subtitle:"Werkbaum – टेक्स्ट-आधारित आरेख संपादक: WBS / कार्य विभाजन संरचना / फ़ीचर-ट्री।",
imprint:"प्रकाशन विवरण (Impressum)",
legendTooltip:"लेजेंड दिखाएँ/छिपाएँ",
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
fullscreenTooltip:"पूर्ण स्क्रीन – पैनल पूरी विंडो चौड़ाई का उपयोग करते हैं",
@@ -1322,6 +1368,7 @@ const I18N = {
zh: {
subtitle:"Werkbaum 基于文本的图表编辑器:WBS / 工作分解结构 / 功能树。",
imprint:"法律声明(Impressum",
legendTooltip:"显示/隐藏图例",
brandTooltip:"「Werkbaum」大致意为‘工作之树’——即工作分解结构(WBS)之树。",
fullscreenTooltip:"全屏——面板占据整个窗口宽度",
discardedTooltip:"显示/隐藏已放弃的节点及其子树",
@@ -1354,6 +1401,7 @@ const I18N = {
ja: {
subtitle:"Werkbaum – テキストベースの図エディター:WBS / 作業分解構成図 / フィーチャーツリー。",
imprint:"運営者情報(Impressum",
legendTooltip:"凡例を表示/非表示",
brandTooltip:"「Werkbaum」はおおよそ『作業の木』の意味 — 作業分解構成図(WBS)のツリーです。",
fullscreenTooltip:"全画面 — パネルがウィンドウ幅いっぱいを使用",
discardedTooltip:"破棄したノードとその下位ツリーを表示/非表示",
@@ -1420,8 +1468,10 @@ function applyLang(l){
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)){
/* Ist eine aufklappbare Sprache aktiv, den erweiterten Bereich offen halten.
Auf kleinem Bildschirm nicht — dort bleibt die Leiste schlank (EN + aktive
Sprache reichen; die aktive wird per CSS auch als „extra“ eingeblendet). */
if(LANG_EXTRA.includes(l) && !isMobile()){
langsel.classList.add('expanded');
langMore.setAttribute('aria-expanded', 'true');
}
@@ -1454,6 +1504,7 @@ fsBtn.addEventListener('click', () => {
erhalten. Sprache liegt weiterhin in 'werkbaum-lang'. */
const LS_UI = 'werkbaum-ui', LS_SRC = 'werkbaum-src';
let restoring = false; /* unterdrückt Speichern während des Wiederherstellens */
let hadStoredUI = false; /* gab es beim Laden schon gespeicherte GUI-Einstellungen? */
function saveSrc(){
if(restoring) return;
try{ localStorage.setItem(LS_SRC, src.value); }catch(_){}
@@ -1478,6 +1529,7 @@ function restoreState(){
let savedSrc = null, ui = null;
try{ savedSrc = localStorage.getItem(LS_SRC); }catch(_){}
try{ ui = JSON.parse(localStorage.getItem(LS_UI) || 'null'); }catch(_){}
hadStoredUI = !!ui;
src.value = (savedSrc !== null) ? savedSrc : INITIAL; /* leerer String bleibt leer */
const mode = (ui && ui.mode) || 'horizontal';
const modeEl = document.querySelector('input[name="layout"][value="' + mode + '"]');
@@ -1501,10 +1553,37 @@ function restoreState(){
restoring = false;
}
/* ---------- Kleiner Bildschirm: nur ein Panel, schlanke Sprachwahl, Vollbild ---------- */
const mqMobile = window.matchMedia('(max-width:640px)');
function isMobile(){ return mqMobile.matches; }
const legendBtn = document.getElementById('legendBtn');
const agenda = document.querySelector('.agenda');
legendBtn.addEventListener('click', () => {
agenda.open = !agenda.open;
legendBtn.classList.toggle('active', agenda.open);
});
agenda.addEventListener('toggle', () => legendBtn.classList.toggle('active', agenda.open));
function applyMobile(){
const m = isMobile();
document.body.classList.toggle('mobile', m);
if(!m) return;
/* Nur ein Panel sichtbar: normal/custom passen nicht — auf Diagramm (b). */
if(splitState !== 'a' && splitState !== 'b'){ splitState = 'b'; applySplit(); }
/* Default Vollbild auf kleinem Bildschirm — nur ohne gespeicherte Wahl. */
if(!hadStoredUI && !document.body.classList.contains('fullscreen')){
document.body.classList.add('fullscreen');
fsBtn.classList.add('active');
fsBtn.setAttribute('aria-pressed', 'true');
saveUI();
}
}
mqMobile.addEventListener('change', applyMobile);
restoreState(); /* Editortext + GUI-Zustand aus dem Browser wiederherstellen */
let startLang = 'de';
try{ startLang = localStorage.getItem('werkbaum-lang') || 'de'; }catch(_){}
applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */
applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */
</script>
</body>