From 52232f2c201949d0cd0d113672e93672f41e531c Mon Sep 17 00:00:00 2001 From: mhoennig Date: Mon, 24 Aug 2026 17:45:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Labels=20brechen=20balanciert=20um,=20I?= =?UTF-8?q?D-Zeile=20=C3=BCber=20dem=20Titel,=20Falt-Chip=20=E2=80=94=20D6?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lange Knotentitel brechen bei ~40 Zeichen um, gleichmäßig auf die Zeilen verteilt — vom Renderer (wrapLabel, headless getestet) statt text-wrap:balance, damit der Kasten auf die längste balancierte Zeile schrumpft. Der 23-px-Abzweig ankert an der ersten Zeile; der Optional-Kreis sitzt fest auf der Abzweighöhe. Der Grafikexport misst die gerenderten Zeilen (labelLines) und gibt je Zeile ein aus. Dazu: die eingeblendete ID (D56) steht in einer eigenen Zeile über dem Titel, und das Falt-Zeichen ist ein gerahmter 19×16-px-Chip mit gedeckelter Höhe. Plan: #ed.render.wrap, #ed.render M→L (D62-Prüfung schlug korrekt an). Co-Authored-By: Claude Fable 5 --- docs/CHANGELOG.md | 3 ++ docs/DECISIONS.md | 73 ++++++++++++++++++++++++++++++++ docs/SPEC.md | 26 ++++++++---- docs/examples/werkbaum.werkbaum | 8 +++- frontend/CLAUDE.md | 9 ++++ frontend/src/app.js | 46 ++++++++++++++++++-- frontend/src/render.js | 58 ++++++++++++++++++++++---- frontend/src/style.css | 41 ++++++++++++++---- frontend/tests/render.test.js | 12 +++--- frontend/tests/wrap.test.js | 74 +++++++++++++++++++++++++++++++++ 10 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 frontend/tests/wrap.test.js diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 76bd8aa..c7b3691 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,9 @@ reverse. ## 2026-08-24 +- Long node titles wrap into evenly balanced lines of at most forty characters +- With ids shown, the id sits on its own line above the title +- The fold sign is a framed chip now — a click target you can actually hit - Typing `:#` suggests the document's ids — arrows choose, Enter inserts - When the caret's node is folded away, the nearest visible ancestor takes the highlight — also on Alt+click from the text - A size that cannot hold its sub-packages turns its badge amber and gets a warning — sizes are read as ranges, so only sure conflicts are reported diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a1d5261..45917ea 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4769,3 +4769,76 @@ Tests in `tests/autocomplete.test.js` (397 gesamt): Kontext-Erkennung D59, Ersetzen über die Schreibmarke hinaus), Sammeln (Dokumentreihenfolge, verworfene, D60-Knoten ohne Titel) und Sortierung (Präfix vor Teilstring, case-insensitiv, exclude). + +## D64 — Lange Labels brechen um: balanciert, ~40 Zeichen, erste Zeile als Anker +Breite Knotenkästen trieben den Baum in die Breite — ein einziger langer Titel +kostete eine ganze Spalte davon. Jetzt brechen Labels um: höchstens ~40 +Zeichen je Zeile (Nutzer-Vorgabe), zentriert, und die Zeichen **gleichmäßig +auf die Zeilen verteilt** (ausdrücklicher Nutzer-Nachtrag): Der gierige +Umbruch machte aus 44 Zeichen eine volle Zeile plus ein einsames Wort. + +**Die Umbrüche setzt der Renderer, nicht CSS.** `text-wrap:balance` wäre eine +Zeile gewesen und ist verworfen: Es balanciert nur **innerhalb** der einmal +bestimmten Kastenbreite — der Kasten bliebe auf `max-width` stehen, mit +Leerraum um zwei kurze Zeilen. `wrapLabel()` in render.js bricht selbst +(Zeilenzahl = ⌈Länge/40⌉, Ziel = gleichmäßig, gebrochen wird an der Stelle, +die dem Ziel am nächsten kommt, nie mitten im Wort) und schreibt echte `\n` +ins Markup; `white-space:pre-line` macht sie sichtbar, und der Kasten +schrumpft auf die längste **balancierte** Zeile. Nebengewinn: Die Regel ist +headless testbar (Hausregel D54-Nachtrag 3) und in jedem Browser gleich. +`max-width:40ch` + `overflow-wrap` bleiben als Rückhalt für das einzelne Wort +über der Grenze. Bewusst `\n` statt `
`: Der `textContent` behielte sonst +keine Wortgrenze, und alles, was den Knotentext liest (Export, +Fokusmarken-Schlüssel), bekäme zusammengeklebte Wörter. + +**Die Geometrie ankert an der ersten Zeile.** Der 23-px-Abzweig der +gestapelten Anordnungen (§9: 5 px Listenabstand + halbe einzeilige +Knotenhöhe) bleibt fest — bei mehrzeiligen Knoten trifft er damit die Mitte +der **ersten Zeile**, das gewohnte Idiom jeder Baumansicht. Eine gemessene +„wahre Mitte" hätte die 23-px-Konstante an einem Dutzend CSS-Stellen dynamisch +gemacht — für einen Unterschied, den das Auge als falsch gar nicht liest. Zwei +Stellen mussten mitziehen: + +- Der **Optional-Kreis** (D29) saß bei `top:50%` der Knotenhöhe — für + einzeilige Knoten dasselbe wie die Abzweighöhe, für mehrzeilige nicht mehr. + Er sitzt jetzt fest bei 18 px (= Abzweighöhe), **auf dem Abzweig statt auf + der Knotenmitte**; die eine Ausnahme sind die vertikal zentrierten + all-of-Zwischenknoten, deren Abzweig wirklich die Mitte trifft (dort bleibt + 50 %). Nachgemessen am dreizeiligen Optional-Knoten im vertikalen Modus: + Kreismitte = Abzweighöhe, 0 px Abweichung. +- Der **Grafikexport** zeichnete das Label als ein `` — ein SVG-`` + bricht nicht von selbst. `labelLines()` misst die gerenderten Zeilen am + Live-Knoten (zeichenweise per Range: neue Zeilen-Oberkante = neue Zeile — + das deckt auch den `overflow-wrap`-Bruch mitten im langen Wort ab, den eine + Nachbildung der Wortlogik verfehlte) und gibt je Zeile ein `` an der + gemessenen Position aus. Die Linienführung des Exports zielte schon immer + auf die gemessene Knotenmitte und blieb unberührt. + +**Zwei Nachträge aus derselben Runde, beide Nutzer-Wünsche:** + +- **Die eingeblendete ID steht in einer eigenen Zeile ÜBER dem Titel** + (ändert D56). Vorher stand sie inline davor (`#some.id: Titel`) und machte + gerade die Knoten am breitesten, die ohnehin lange Titel tragen. Der + Trenn-Doppelpunkt entfällt: Er trennte ID und Titel in **derselben** Zeile — + hier trennt der Umbruch, und `#auth:` allein auf einer Zeile läse sich wie + ein Block-Kopf (§1). Der Export folgt von selbst (er misst Zeilen). +- **Das Falt-Zeichen ist jetzt ein gerahmter Chip** (ändert die D38-Optik): + Das nackte ▾ war ~10 px klein und schwer zu treffen. Der Chip (19×16 px, + Rahmen, dezente Füllung) ist das Klickziel, das er immer sein sollte. Seine + Höhe ist gedeckelt (`line-height:14px`, kein vertikales Padding), denn ein + Inline-Block, der höher ist als die Zeilenbox, höbe die feste Zeilenhöhe an, + an der die 23-px-Geometrie hängt — mit 15 px Zeilenhöhe wuchs der Knoten + nachgemessen von 34,3 auf 35,0 px, mit 14 px bleibt er exakt bei 34,3. + +**Nachgemessen** am mitgelieferten Plan (horizontal, Pfad an): 166 Knoten, +0 Warnungen, 27 Stationen (unverändert), `--stem-x` weiterhin punktgenau auf +der Knotenmitte (130,8 = 130,8 px) — und am Testbaum: 79 Zeichen ergeben zwei +Zeilen zu ~40/39 statt 40+Rest, drei Zeilen bei ~100; im exportierten SVG +stehen die zwei Zeilen des langen Titels 18,3 px auseinander vollständig im +52,6-px-Kasten, die ID-Zeile als eigene Textzeile. Falten am Chip klappt +weiter um (166 → 140 → 166 Knoten). 405 Tests, davon 8 neue in +`tests/wrap.test.js` (Balance, keine Zeile über 40, kein Zeichenverlust, nie +mitten im Wort, `\n` im Knotentext); die showIds-Tests sind auf die eigene +Zeile umgeschrieben. Der neue Plan-Knoten `#ed.render.wrap (S)` kippte prompt +die Größenprüfung des eigenen Plans (`#ed.render (M)` mit nun 4×S, D62) — +ehrlich nachgezogen auf `(L)`, danach wieder 0 Warnungen. diff --git a/docs/SPEC.md b/docs/SPEC.md index 8eea7d8..0796213 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -499,6 +499,15 @@ Siehe D29. deterministisch auf Knotenmitte liegen (Offset 23 px = 5 px Listenabstand + halbe Knotenhöhe). Abzweige zielen auf den **Knoten**, nie auf die Mitte des Teilbaums. +- **Lange Labels brechen um** (D64): höchstens ~40 Zeichen je Zeile, die + Zeichen **gleichmäßig** auf die Zeilen verteilt (nicht gierig — sonst stünde + unter einer vollen Zeile ein einsames Wort). Die Umbrüche setzt der + **Renderer**, deterministisch und nur an Leerzeichen; der Kasten schrumpft + auf die längste Zeile, der Text steht zentriert. Bei **mehrzeiligen** Knoten + trifft der 23-px-Abzweig die Mitte der **ersten Zeile** (die Zeilenhöhe + bleibt fest); der Optional-Kreis (§9) sitzt auf dem Abzweig, nicht auf der + Knotenmitte. Der Grafikexport misst die gerenderten Zeilen und gibt sie als + einzelne Textzeilen aus. - Dasselbe gilt **waagerecht**: Im horizontalen Fächer trifft der Stiel die **Knotenmitte**, nicht die Mitte der Zelle. Beides fällt nur zusammen, solange der Knoten in seiner Zelle zentriert steht — ein Knoten mit any-of-Kindern @@ -837,10 +846,11 @@ verknüpft (siehe D25): Druck. ### Knoten-IDs im Diagramm einblenden (`#`) -Ein Umschalter im Diagramm-Kopf stellt die Knoten-ID **vor den Titel**, in -derselben Schreibweise wie im Text (§1/D36): `#some.id: Titel`. Dargestellt in -der Mono-Schrift des Textfelds und zurückgenommen gefärbt — die ID ist die -Adresse, der Titel führt die Zeile weiter an. Der Zustand wird gemerkt. +Ein Umschalter im Diagramm-Kopf stellt die Knoten-ID in eine **eigene Zeile +über den Titel** (`#some.id`, ohne den Trenn-Doppelpunkt — der trennt ID und +Titel in derselben Zeile, hier trennt der Umbruch; D56, geändert mit D64). +Dargestellt in der Mono-Schrift des Textfelds und zurückgenommen gefärbt — die +ID ist die Adresse, der Titel bleibt die Hauptzeile. Der Zustand wird gemerkt. **Grafikexport und Druck folgen dem Umschalter** (wie beim „verworfene einblenden"-Filter und der Faltung): Ist er an, stehen die IDs auch im @@ -942,9 +952,11 @@ gehört und lesbar bleibt. Kein Blinken. Grafikexport: Sie sagt „schau jetzt hierhin", nicht „so ist der Plan". ### Ein- und ausklappbare Teilbäume (`>` / `<`, §1) -Jeder Knoten mit sichtbaren Kindern trägt ein kleines **Falt-Zeichen** vor dem -Label: **▾** offen, **„▸ n“** eingeklappt (n = Zahl der verborgenen Knoten). -Klick auf das Zeichen klappt um — der einfache Klick auf den Knoten selbst +Jeder Knoten mit sichtbaren Kindern trägt ein **Falt-Zeichen** vor dem +Label: **▾** offen, **„▸ n“** eingeklappt (n = Zahl der verborgenen Knoten) — +als kleiner **gerahmter Chip**, damit das Klickziel zu treffen ist (D64; das +nackte Glyph war zu klein). Klick auf das Zeichen klappt um — der einfache +Klick auf den Knoten selbst bleibt der Link (§6); Tastatur: **←** klappt zu, **→** klappt auf am fokussierten Knoten (WAI-ARIA-Baum-Idiom). diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 72483ac..492d15d 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -34,10 +34,11 @@ - [~] #ed: Editor (XXL) https://werkbaum.javagil.de - [^] #ed.live: Live diagram from text (L) - [^] #ed.parser: Parser (S) - - [^] #ed.render: Renderer, three layout modes (M) + - [^] #ed.render: Renderer, three layout modes (L) - [^] #ed.render.horiz: Horizontal fan (S) - [^] #ed.render.vert: Vertical, transposed (S) - [^] #ed.render.comp: Compact (S) + - [x] #ed.render.wrap: Long titles wrap into balanced lines (S) %% max ~40 chars, see D64 - [^] #ed.deplink: Cross links for dependencies (M) - [^] #ed.deplink.calm: Visually secondary — the tree carries the message (S) - [^] #ed.deplink.focus: Highlight what a selected node needs and feeds (S) @@ -368,6 +369,11 @@ Everything runs downward, so the tree stays narrow. Here the line style alone carries the gate: solid for and, dashed for or. +#ed.render.wrap + A single long title used to cost a whole column of width. Titles beyond + forty characters now break into evenly balanced lines, set by the renderer + so the box shrinks to the longest line; connectors anchor on the first line. + #ed.deplink Dependencies drawn as curved dotted edges on their own layer behind the nodes — the first line type that does not follow the decomposition. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index abe5b0a..2b4ca26 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -41,6 +41,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der - Abzweig-Linien zielen auf die **Knotenmitte** (fester 23-px-Offset, `line-height: 1.3`), nicht auf die Mitte des Teilbaums — bei Layout- Änderungen alle drei Modi (horizontal/vertikal/kompakt) prüfen. + **Seit D64 brechen lange Labels um** (`wrapLabel()` in render.js setzt `\n`, + `white-space:pre-line` macht sie sichtbar): Bei mehrzeiligen Knoten trifft + der 23-px-Abzweig die Mitte der **ersten Zeile**; der Optional-Kreis sitzt + deshalb fest bei `top:18px` (= Abzweighöhe), nicht bei 50 % (Ausnahme: + vertikal zentrierte all-of-Zwischenknoten). Nichts in den Knoten darf die + Zeilenbox über die festen ~18,3 px heben — der Falt-Chip hat darum + `line-height:14px` ohne vertikales Padding (mit 15 px wuchs der Knoten + gemessen um 0,7 px). Der Export zeichnet je gerenderter Zeile ein `` + (`labelLines()`, misst zeichenweise per Range am Live-Knoten). Vertikal + kompakt teilen die transponierte Basis-CSS; nur vertikal bekommt den Rechts-Ausgang für „all of“, kompakt führt auch „all of“ nach unten. Any-of ist in allen Modi grau: Linien gestrichelt grau und diff --git a/frontend/src/app.js b/frontend/src/app.js index 8d9ec20..511cb8f 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -470,6 +470,42 @@ out.addEventListener('focusout', () => setTimeout(drawDepLinks, 0)); der Geister-Knoten werden übernommen; Verbindungslinien werden je Gate (und = durchgezogen Tinte, oder = gestrichelt Grau) neu gezogen und treffen so garantiert die Knoten — unabhängig vom Darstellungsmodus. */ +/* Die gerenderten Textzeilen eines Knotens, gemessen am LIVE-Element: je + Zeile der (whitespace-normalisierte) Text und die Box in Viewport-Pixeln. + Zeichenweise per Range — eine neue Zeilen-Oberkante beginnt eine neue Zeile; + das deckt auch den Umbruch INNERHALB eines langen Worts (overflow-wrap) ab, + den eine Nachbildung der Wort-Umbruchlogik verfehlte. Kollabierter Leerraum + (etwa das Leerzeichen, an dem umbrochen wurde) hat eine Null-Box und fällt + heraus. `excludeSel` sind die Elemente, die der Export nicht ins Label + nimmt (Badges, ↗, ”-Marke, je nach Faltzustand das Falt-Zeichen). */ +function labelLines(node, excludeSel){ + const range = document.createRange(); + const lines = []; + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { + acceptNode: tn => tn.parentElement.closest(excludeSel) + ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, + }); + for(let tn = walker.nextNode(); tn; tn = walker.nextNode()){ + const s = tn.nodeValue; + for(let i = 0; i < s.length; i++){ + range.setStart(tn, i); range.setEnd(tn, i + 1); + const r = range.getBoundingClientRect(); + if(!r.width && !r.height) continue; + const last = lines[lines.length - 1]; + if(last && Math.abs(r.top - last.top) < 2){ + last.text += s[i]; + last.left = Math.min(last.left, r.left); + last.right = Math.max(last.right, r.right); + last.bottom = Math.max(last.bottom, r.bottom); + } else { + lines.push({text: s[i], top: r.top, bottom: r.bottom, left: r.left, right: r.right}); + } + } + } + for(const l of lines) l.text = l.text.replace(/\s+/g, ' ').trim(); + return lines.filter(l => l.text); +} + function diagramToSvg(){ /* Der Export misst die **Live-Geometrie**. Den Ring der Cursor-Zeile liest er nie aus (`box-shadow` steht nicht in der Liste), ihre Erhebung @@ -608,15 +644,17 @@ function diagramToSvg(){ const b = R(node), cs = getComputedStyle(node); const dashed = cs.borderTopStyle === 'dashed'; parts.push(``); - const clone = node.cloneNode(true); /* Das „▸ n"-Kennzeichen eingeklappter Knoten gehört in den Export (SPEC §9/D38 — das Bild darf keine Vollständigkeit behaupten); das ▾ offener Knoten ist Bedienelement und fällt weg. */ const stripFold = node.classList.contains('folded') ? '' : ',.fold'; - clone.querySelectorAll('.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold).forEach(e => e.remove()); - const label = clone.textContent.replace(/\s+/g,' ').trim(); const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : ''; - parts.push(`${esc(label)}`); + /* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen — ein + SVG- bricht nicht von selbst; je gemessene Zeile ein Element. */ + for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold)){ + const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy; + parts.push(`${esc(ln.text)}`); + } const riskEl = node.querySelector('.risk'); if(riskEl){ const rb = R(riskEl); diff --git a/frontend/src/render.js b/frontend/src/render.js index be0ee92..2efeff1 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -55,6 +55,40 @@ function attr(s){ return esc(String(s)).replace(/"/g,'"'); } Beschreibung als data-Attribut wäre der einzige andere Weg gewesen. */ export const TIP_RULE = '─'.repeat(24); +/* Lange Labels umbrechen (SPEC §9/D64): höchstens ~40 Zeichen je Zeile, und + die Zeichen GLEICHMÄSSIG auf die Zeilen verteilt — der gierige CSS-Umbruch + machte aus 44 Zeichen eine volle Zeile plus ein einsames Wort. Bewusst im + Renderer statt per `text-wrap:balance`: So schrumpft der Knotenkasten auf + die längste BALANCIERTE Zeile (CSS balanciert nur innerhalb der einmal + bestimmten Kastenbreite und ließe ihn auf `max-width` stehen), und die + Regel ist headless testbar. Gebrochen wird nur an Leerzeichen; ein + einzelnes Wort über der Grenze bleibt stehen (das CSS fängt es mit + `max-width` + `overflow-wrap` ab). */ +export function wrapLabel(label, max = 40){ + const text = String(label); + if(text.length <= max) return [text]; + const words = text.split(' '); + const n = Math.ceil(text.length / max); /* so viele Zeilen braucht es */ + const target = Math.ceil(text.length / n); /* … und so voll wird jede */ + const lines = []; + let cur = ''; + for(const w of words){ + if(!cur){ cur = w; continue; } + const withW = cur.length + 1 + w.length; + /* Gebrochen wird an der Stelle, die dem Ziel am nächsten kommt: wenn das + Wort die Zeile weiter über das Ziel höbe, als sie jetzt darunter liegt — + oder wenn es die harte Grenze risse. */ + if(withW > max || (withW > target && withW - target >= target - cur.length)){ + lines.push(cur); + cur = w; + } else { + cur = cur + ' ' + w; + } + } + lines.push(cur); + return lines; +} + /* Barrierefreier Name eines Knotens: Label + Status + Aufwand + Zuständige + Link. Die visuellen Badges (Größe, Tags, ↗) sind aria-hidden — ihre Information steckt hier, sonst würde der Screenreader Kryptisches („M", @@ -184,19 +218,27 @@ function nodeHtml(n, extra, opts, fold){ const ownChip = effKey ? `` : ''; - /* ID vor dem Titel, mit `: ` abgetrennt — dieselbe Schreibweise wie im Text - (SPEC §1/D36), damit man beides nebeneinander lesen kann. Umschaltbar im - Diagramm-Kopf; als Renderer-Option und nicht per CSS versteckt, damit der - Grafikexport (er liest den Knotentext) von selbst folgt. `aria-hidden`: - Der Screenreader bekommt die ID schon über `a11yId` (D56). */ + /* ID in einer eigenen Zeile ÜBER dem Titel (D56, geändert mit D64): Das + `\n` trennt sie, `white-space:pre-line` macht es sichtbar. Ohne den + Trenn-Doppelpunkt — der trennte ID und Titel in DERSELBEN Zeile, hier + trennt der Umbruch; mit Doppelpunkt läse sich die Zeile wie ein + Block-Kopf (`#auth:`). Umschaltbar im Diagramm-Kopf; als Renderer-Option + und nicht per CSS versteckt, damit der Grafikexport (er misst die + gerenderten Zeilen) von selbst folgt. `aria-hidden`: Der Screenreader + bekommt die ID schon über `a11yId` (D56). */ /* Nicht bei einem Knoten, dessen Label die ID selbst IST (SPEC §1) — sonst - stünde dort `#US-123: #US-123`. */ + stünde dort `#US-123` zweimal untereinander. */ const idHtml = showIds && n.id && !n.labelFromId - ? ` ` + ? `\n` : ''; const inner = foldHtml + idHtml + - esc(n.label) + + /* Die Umbrüche kommen als echte `\n` ins Markup, das CSS macht + sie mit `white-space:pre-line` sichtbar (D64). Kein `
`: + Der `textContent` behielte sonst keine Wortgrenze, und alles, + was den Knotentext liest (Export, Fokusmarken-Schlüssel), + bekäme zusammengeklebte Wörter. */ + esc(wrapLabel(n.label).join('\n')) + /* ”-Marke (D40): macht die sonst unsichtbare Beschreibung auffindbar (Lehre aus D25) — spiegelt das "-Zeichen der Notation. Nicht im Export: Der Text selbst kann dort nicht diff --git a/frontend/src/style.css b/frontend/src/style.css index 6f62b94..faf0f4c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -684,7 +684,18 @@ border-radius:var(--r); padding:7px 14px; font-size:.88rem;font-weight:500;line-height:1.3; - white-space:nowrap; + /* Lange Labels brechen um, statt den Baum in die Breite zu treiben (D64). + Die Umbrüche setzt der RENDERER (wrapLabel: ~40 Zeichen je Zeile, + gleichmäßig verteilt) als `\n` — pre-line macht sie sichtbar, und der + Kasten schrumpft auf die längste balancierte Zeile. `max-width` + + `overflow-wrap` sind nur der Rückhalt für ein einzelnes Wort über der + Grenze. Zentriert (Organigramm-Konvention). Die feste Zeilenhöhe + bleibt — der 23-px-Abzweig (§9) trifft bei mehrzeiligen Knoten + deterministisch die Mitte der ERSTEN Zeile. */ + white-space:pre-line; + max-width:40ch; + text-align:center; + overflow-wrap:break-word; box-shadow:0 1px 2px rgba(36,52,71,.08); } a.node{text-decoration:none;cursor:pointer} @@ -692,15 +703,23 @@ /* Falt-Zeichen (SPEC §9, D38): ▾ offen / „▸ n" eingeklappt, Klickziel fürs Umklappen. Gedeckt in Grau — es ist Bedienelement, keine Statusaussage; beim Zeigen färbt es sich zur Tinte. */ + /* Als gerahmter Chip statt bloßem Glyph (D64): das nackte ▾ war ~10px klein + und kaum zu treffen. Die Höhe ist bewusst gedeckelt (feste line-height + + kein vertikales Padding): Der Chip darf die Zeilenbox des Knotens NICHT + über die festen 18,3px heben, sonst wandert die 23-px-Abzweig-Geometrie + (§9) um Halbpixel. */ .node .fold{ cursor:pointer;color:var(--muted); - font-size:.72em;margin-right:6px; + font-size:.75em;margin-right:6px; + display:inline-block;padding:0 6px;line-height:14px; + border:1px solid rgba(36,52,71,.28);border-radius:5px; + background:rgba(36,52,71,.04); user-select:none;-webkit-user-select:none; } - .node .fold:hover{color:var(--line)} + .node .fold:hover{color:var(--line);border-color:var(--line);background:rgba(36,52,71,.09)} /* Der dunkle Wurzelknoten braucht ein helleres Zeichen als das Grau. */ - .root-node .fold{color:rgba(255,255,255,.75)} - .root-node .fold:hover{color:#fff} + .root-node .fold{color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.45);background:rgba(255,255,255,.08)} + .root-node .fold:hover{color:#fff;border-color:#fff} /* ”-Marke (SPEC §9, D40): der Knoten trägt eine Beschreibung — im Tooltip nachlesbar. Spiegelt das "-Zeichen der Notation; gedeckt, kein Klickziel. */ .node .desc-mark{color:var(--muted);margin-left:4px;font-weight:600} @@ -1165,7 +1184,12 @@ Abzweig kommt von links auf halber Knotenhöhe. */ .node.opt::before{ content:'';position:absolute; - left:-5px;top:50%;transform:translateY(-50%); + /* 18px statt 50%: Der gestapelte Abzweig sitzt fest bei 23px im
  • + (= 5px Listenabstand + halbe EINZEILIGE Knotenhöhe). Seit Knoten + umbrechen (D64), ist 50% bei mehrzeiligen nicht mehr diese Höhe — + der Kreis muss auf dem Abzweig sitzen, nicht auf der Knotenmitte. + Für einzeilige Knoten sind 18px und 50% dasselbe. */ + left:-5px;top:18px;transform:translateY(-50%); width:10px;height:10px;border-radius:50%; background:var(--card);border:2px solid var(--line); box-sizing:border-box; @@ -1177,8 +1201,11 @@ /* …ausgenommen der gestapelte all-of-Teilbaum unter einer any-of-Gruppe (siehe oben): dort kommt der Abzweig wieder von links. */ .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li>.node.opt::before{ - left:-5px;top:50%;transform:translateY(-50%); + left:-5px;top:18px;transform:translateY(-50%); } + /* Vertikal zentrierte all-of-Zwischenknoten (unten): dort trifft der Abzweig + wirklich die Knotenmitte (50%) — der Kreis geht mit. */ + .tree.vertical li.has-and>ul.and>li.has-and>.node.opt::before{top:50%} /* --- TREPPE: aufeinanderfolgende optionale Endknoten (D29, Nachtrag 3) --- Nur im horizontalen Fächer, wo jedes Geschwister sonst eine eigene Spalte diff --git a/frontend/tests/render.test.js b/frontend/tests/render.test.js index 5032513..c555999 100644 --- a/frontend/tests/render.test.js +++ b/frontend/tests/render.test.js @@ -143,8 +143,10 @@ describe('renderTreeHtml — data-line je Knoten (D25)', () => { }); /* Knoten-IDs im Diagramm (D56). Als Renderer-Option gebaut und nicht per CSS - versteckt — nur so folgt der Grafikexport, der den Knotentext ausliest. */ -describe('showIds — die ID vor dem Titel, geschrieben wie im Text', () => { + versteckt — nur so folgt der Grafikexport, der den Knotentext ausliest. + Seit D64 steht die ID in einer eigenen Zeile ÜBER dem Titel (Trenner ist + der Umbruch, nicht mehr der Doppelpunkt). */ +describe('showIds — die ID in einer eigenen Zeile über dem Titel', () => { const ids = (text, showIds) => renderTreeHtml(parse(text).roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set(), showIds}).html; @@ -154,9 +156,9 @@ describe('showIds — die ID vor dem Titel, geschrieben wie im Text', () => { expect(html).not.toContain('nid'); }); - it('stellt die ID mit Doppelpunkt und Leerzeichen davor', () => { + it('stellt die ID in eine eigene Zeile über den Titel', () => { expect(ids('#auth: Backend', true)) - .toContain(' Backend'); + .toContain('\nBackend'); }); it('lässt Knoten ohne ID unangetastet', () => { @@ -176,7 +178,7 @@ describe('showIds — die ID vor dem Titel, geschrieben wie im Text', () => { genau das: `#a { const html = ids('#a