feat: Labels brechen balanciert um, ID-Zeile über dem Titel, Falt-Chip — D64

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 <text> 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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 17:45:11 +02:00
co-authored by Claude Fable 5
parent 1debb7d9da
commit 52232f2c20
10 changed files with 318 additions and 32 deletions
+7 -5
View File
@@ -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('<span class="nid" aria-hidden="true">#auth:</span> Backend');
.toContain('<span class="nid" aria-hidden="true">#auth</span>\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<b` ergibt die ID `a`, der Rest bleibt (escaped) im Label. */
it('endet an einem Zeichen außerhalb der ID-Zeichenmenge', () => {
const html = ids('#a<b: X', true);
expect(html).toContain('<span class="nid" aria-hidden="true">#a:</span>');
expect(html).toContain('<span class="nid" aria-hidden="true">#a</span>');
expect(html).toContain('&lt;b: X');
});
});
+74
View File
@@ -0,0 +1,74 @@
// Umbruch langer Labels (SPEC §9/D64): höchstens ~40 Zeichen je Zeile und
// gleichmäßig verteilt — nicht gierig (volle Zeile plus einsames Wort).
import { describe, it, expect } from 'vitest';
import { wrapLabel, renderTreeHtml } from '../src/render.js';
import { parse } from '../src/parser.js';
const t = key => key;
describe('wrapLabel: balanciert statt gierig', () => {
it('bis 40 Zeichen bleibt es eine Zeile, unverändert', () => {
const s = 'Ein gewöhnlicher Knotentitel (39 Zeich.)';
expect(s.length).toBe(40);
expect(wrapLabel(s)).toEqual([s]);
});
it('44 Zeichen werden zwei etwa gleiche Zeilen — nicht 40 plus Rest', () => {
const s = 'Update the agents version of the spec please';
expect(s.length).toBe(44);
const lines = wrapLabel(s);
expect(lines.length).toBe(2);
for(const l of lines){
expect(l.length).toBeGreaterThanOrEqual(15);
expect(l.length).toBeLessThanOrEqual(29);
}
});
it('verliert kein Zeichen: die Zeilen ergeben zusammengefügt das Label', () => {
const s = 'Offering the status codes, the sizes and the known tags while typing';
expect(wrapLabel(s).join(' ')).toBe(s);
});
it('keine Zeile über 40 Zeichen, solange die Wörter es hergeben', () => {
const s = 'A dependency needs its target id and nobody remembers a hundred and eighty of them by heart';
for(const l of wrapLabel(s)) expect(l.length).toBeLessThanOrEqual(40);
});
it('rund 100 Zeichen ergeben drei gleichmäßige Zeilen', () => {
const s = 'The list at the caret offers the document ids filtered by what is typed with the node title as ctx';
expect(s.length).toBe(98);
const lines = wrapLabel(s);
expect(lines.length).toBe(3);
for(const l of lines){
expect(l.length).toBeGreaterThanOrEqual(25);
expect(l.length).toBeLessThanOrEqual(40);
}
});
it('ein einzelnes Wort über der Grenze bleibt eine Zeile (CSS fängt es ab)', () => {
const w = 'x'.repeat(55);
expect(wrapLabel(w)).toEqual([w]);
expect(wrapLabel('kurz ' + w)).toEqual(['kurz', w]);
});
it('bricht nie mitten im Wort', () => {
const s = 'einige ziemlich lange zusammengesetzte Bandwurmwortgebilde nebeneinander aufgereiht';
const words = s.split(' ');
for(const l of wrapLabel(s)){
for(const w of l.split(' ')) expect(words).toContain(w);
}
});
});
describe('Renderer: die Umbrüche stehen als \\n im Knotentext', () => {
it('ein langer Titel bekommt sein \\n, ein kurzer nicht', () => {
const { roots } = parse(
'- Kurzer Titel\n' +
'- Ein wirklich sehr langer Titel, der die vierzig Zeichen deutlich reisst');
const { html } = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false,
cheapSet: new Set()});
expect(html).toContain('Kurzer Titel');
expect(html).not.toContain('Kurzer\nTitel');
expect(html).toMatch(/Ein wirklich sehr langer Titel,[^<]*\n[^<]*reisst/);
});
});