notation: ohne Titel vertritt die Knoten-ID ihn (SPEC §1, D60)

`- #US-123` ergab bisher nichts — leeres Label, Zeile ignoriert, ID nicht
vergeben. Wo die Kennung schon der Name ist (Ticket-Referenzen, §11), war das
verkehrt herum: Wer den Titel nicht danebenschrieb, verlor den ganzen Knoten.
Jetzt wird `#id` das Label, mit Doppelpunkt geschrieben wie ohne.

Das `#` bleibt im Label — es sagt „hier steht die Adresse, weil es keinen
Titel gibt". Der `#`-Umschalter setzt bei so einem Knoten nichts davor
(sonst `#US-123: #US-123`), und Tooltip wie aria-label lassen die ID weg, die
schon im Titel steht.

Verhaltensänderung: Die ID ist damit vergeben — `- #auth` gefolgt von
`- [ ] Echt #auth` gibt jetzt zwei Knoten und eine duplicateId-Warnung.

SPEC §1, llms.md, D60; 6 neue Tests, der alte auf die neue Regel umgeschrieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 13:31:39 +02:00
co-authored by Claude Opus 5
parent 63c9498192
commit 3018db9f61
7 changed files with 109 additions and 12 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-24
- A node written as an id alone (`- #US-123`) is now titled by that id, instead of being ignored
- A line ending in a space and `\` continues on the next line, so a long node no longer has to fit into one
- A star in the header opens What's new: the changes of the last few days, and a link per day that shows the nodes it touched in the diagram
- A warning triangle replaces the question mark as the pointer over faulty line numbers
+44
View File
@@ -4275,3 +4275,47 @@ Titel (L) @anna` ergibt einen Knoten mit `data-line="2"` und
0 Warnungen; der Cursor auf Zeile 3 hebt denselben Knoten hervor wie auf Zeile
2. 20 neue Tests; die Gegenprobe (Leerraum-Pflicht aus dem Regex entfernt)
lässt genau die zwei danach benannten Zusicherungen fallen.
## D60 — Ohne Titel vertritt die Knoten-ID ihn
D36 hielt fest: „Eine Zeile, die **nur** aus einer ID besteht, wird wie jede
leere Zeile ignoriert und belegt die ID nicht." Das ist jetzt umgekehrt — eine
solche Zeile ist ein Knoten, und sein Label ist `#id`.
**Der Anlass ist die Ticket-Referenz.** §11 hält fest, dass die Kennung eines
Trackers oft die natürliche Knoten-ID ist (`#US-123`, `#ABC-123`). Wo das
zutrifft, **ist die Kennung schon der Name**`- #US-123: US-123` daneben zu
schreiben wäre eine Verdopplung, und wer sie wegließe, verlor bisher den
ganzen Knoten. Die alte Regel war für den Fall gedacht, dass jemand eine ID
ohne Absicht stehen lässt; sie hat dabei den häufigeren Fall miterschlagen.
**Das Label ist `#id`, mit Doppelkreuz.** Erwogen war die ID ohne `#`
(`US-123`) oder eine eigene, zurückgenommene Darstellung wie beim
`#`-Umschalter (mono, grau, D56). Beides verworfen: Ohne `#` liest sich der
Knoten wie ein gewöhnlicher Titel, der zufällig nach einer Kennung aussieht —
das `#` sagt „hier steht die Adresse, weil es keinen Titel gibt". Und eine
durchgehend graue Beschriftung ließe den Knoten wie zurückgetreten aussehen,
was er nicht ist.
**Der `#`-Umschalter setzt bei so einem Knoten nichts davor.** Sonst stünde
dort `#US-123: #US-123`. Erkannt wird das an `labelFromId` am Knoten und nicht
am Vergleich `label === '#' + id`: Wer `#a: #a` bewusst schreibt, hat einen
Titel, und der soll auch mit Umschalter so erscheinen. Aus demselben Grund
entfallen für solche Knoten die ID-Zeile im Tooltip und `a11yId` im
`aria-label` — ein Screenreader läse die Kennung sonst zweimal hintereinander.
**Die ID ist damit vergeben.** Das ist die eigentliche Verhaltensänderung und
die einzige, die jemandem auffallen kann: `- #auth` gefolgt von
`- [ ] Echt #auth` gibt jetzt zwei Knoten und eine `duplicateId`-Warnung, wo
vorher einer und keine Warnung stand. Das ist richtig herum — die erste Zeile
ist jetzt ein Knoten, und zwei Knoten mit derselben ID sind genau der Fall,
für den es die Warnung gibt.
Eine Zeile **ohne** ID und ohne Label bleibt, was sie war: keine Zeile. Auch
`- (L) @anna` ergibt weiterhin nichts — Größe und Zuständige allein sind kein
Knoten.
**Nachgemessen:** `- [x] #US-123 (L) @anna` ergibt einen Knoten mit Label
`#US-123`, Größe `L`, Tag `anna`, Status fertig; mit eingeschaltetem
`#`-Umschalter bekommt er **keine** `nid`-Spanne, der Nachbar `#auth: Backend`
schon. 337 Tests, davon 6 neue; der eine alte, der die frühere Regel festhielt,
ist umgeschrieben und benennt jetzt diese.
+12 -3
View File
@@ -25,7 +25,9 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit):
6. Knoten-ID: das **erste** alleinstehend angesetzte `#name`-Token (siehe unten).
7. Abhängigkeiten: alle alleinstehend angesetzten `:#a,#b`-Token (siehe unten).
8. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten).
9. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren.
9. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren
**außer** die Zeile trägt eine Knoten-ID; dann wird `#id` das Label
(siehe unten).
**Fortsetzungszeile `\`** — eine lange Zeile darf auf mehrere Textzeilen
verteilt werden, ohne dass ein neuer Knoten entsteht:
@@ -102,8 +104,15 @@ sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11).
**Leerraum oder Zeilenende** folgt; ein Doppelpunkt im Label (`#auth: Regel:
nur mit Token`) bleibt also stehen, und `#auth:#db` bleibt ID plus
Abhängigkeit. Die Stellung ist frei — `Backend #auth` bedeutet dasselbe.
- Die ID gehört **nicht** zum Label. Eine eigene Darstellung im Diagramm hat
sie (noch) nicht; sichtbar ist sie im Knoten-Tooltip und im `aria-label`.
- Die ID gehört **nicht** zum Label. Sichtbar ist sie im Knoten-Tooltip, im
`aria-label` und — auf Wunsch — vor dem Titel (§9, `#`-Umschalter).
- **Ohne Titel vertritt die ID ihn:** Bleibt nach der Extraktion kein Label
übrig, die Zeile trägt aber eine ID, dann ist `#id` das Label — mit
Doppelpunkt geschrieben (`#US-123:`) wie ohne. Die Zeile wird also **nicht**
ignoriert, sondern ein gewöhnlicher Knoten, und die ID ist vergeben. Gedacht
für den Fall, dass die Kennung schon der Name ist (Ticket-Referenzen, §11) —
den Titel danebenzuschreiben wäre eine Verdopplung. Der `#`-Umschalter (§9)
setzt bei so einem Knoten **nichts** davor: Die ID steht bereits da.
- **Doppelte ID:** Warnung `duplicateId` mit beiden Zeilennummern; die spätere
ID gilt trotzdem am Knoten (fehlertolerant wie §4 — die Zeile geht nicht
verloren).
+6 -1
View File
@@ -138,7 +138,12 @@ One node per line. Everything except the label is optional.
shared pointer for collaborative editing. Independent of status and
necessity; `Wow!!!` stays a label.
8. Whatever remains, whitespace-normalized, is the **label**. An empty label
means the line is ignored.
means the line is ignored**unless the line carries a node ID**: then
`#id` becomes the label, with or without the trailing colon. `- #US-123`
and `- #US-123:` both render a node titled `#US-123`. Use this when the
identifier already is the name (ticket references); writing the title next
to it would just repeat it. The `#` toggle in the diagram adds no prefix to
such a node — the ID is already there.
### Dependency semantics
+8 -2
View File
@@ -288,7 +288,13 @@ export function parse(text){
der führende Leerraum wird mitgefangen und wieder eingesetzt. */
let focus = false;
rest = rest.replace(/(^|\s)!!!(?=\s|$)/g, (s, pre) => { focus = true; return pre; });
const label = rest.replace(/\s+/g, ' ').trim();
/* Bleibt kein Titel übrig, vertritt die ID ihn (SPEC §1): `- #US-123`
ergibt einen Knoten mit dem Label `#US-123`. Ohne ID bleibt es dabei,
dass eine labellose Zeile keine ist. `labelFromId` merkt den Fall der
`#`-Umschalter (§9) darf die ID dann nicht ein zweites Mal davorsetzen. */
let label = rest.replace(/\s+/g, ' ').trim();
const labelFromId = !label && id != null;
if(labelFromId) label = '#' + id;
if(!label) return;
let status = null;
@@ -308,7 +314,7 @@ export function parse(text){
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, optional, fold, status, url, size, tags, id, deps, desc:null, descLines:null, focus, children:[], line:i+1};
const node = {label, labelFromId, type, optional, fold, status, url, size, tags, id, deps, desc:null, descLines:null, focus, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
lastNode = node;
+7 -3
View File
@@ -75,7 +75,9 @@ function nodeAria(n, opts, fold){
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
/* Knoten-ID und Abhängigkeiten (SPEC §1, D36/D37): keine eigene Darstellung
im Diagramm sichtbar nur im Tooltip und hier. */
if(n.id) parts.push(t('a11yId', {id: n.id}));
/* Nicht, wenn das Label die ID selbst ist (SPEC §1) sonst hört ein
Screenreader sie zweimal hintereinander. */
if(n.id && !n.labelFromId) parts.push(t('a11yId', {id: n.id}));
if(n.deps && n.deps.length)
parts.push(t('a11yDeps', {ids: n.deps.map(d => '#' + d).join(', ')}));
if(n.optional) parts.push(t('a11yOptional'));
@@ -126,7 +128,7 @@ function nodeHtml(n, extra, opts, fold){
bleibt schmaler als die Fakten-Zeile (die den Sprung-Hinweis enthält),
verbreitert den Tooltip also nicht. Der `aria-label` bekommt ihn NICHT:
ein Screenreader läse die Striche einzeln vor (nodeAria oben). */
const facts = [n.id ? '#' + n.id : '',
const facts = [(n.id && !n.labelFromId) ? '#' + n.id : '',
n.deps && n.deps.length ? '→ ' + n.deps.map(d => '#' + d).join(', ') : '',
effKey
? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)})
@@ -179,7 +181,9 @@ function nodeHtml(n, extra, opts, fold){
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). */
const idHtml = showIds && n.id
/* Nicht bei einem Knoten, dessen Label die ID selbst IST (SPEC §1) sonst
stünde dort `#US-123: #US-123`. */
const idHtml = showIds && n.id && !n.labelFromId
? `<span class="nid" aria-hidden="true">#${esc(n.id)}:</span> `
: '';
const inner = foldHtml +
+31 -3
View File
@@ -42,11 +42,39 @@ describe('Parser — `#name` als Knoten-ID', () => {
expect(nodes.map(n => n.id)).toEqual(['123', 'größe-1']);
});
it('ignoriert eine Zeile, die nur aus einer ID besteht — die ID bleibt frei', () => {
const {roots: r, warnings} = parse(`- #auth\n- [ ] Echt #auth`);
expect(r.map(n => [n.label, n.id])).toEqual([['Echt', 'auth']]);
/* Bis D60 war so eine Zeile keine: Sie wurde ignoriert und die ID blieb
frei. Jetzt vertritt die ID den fehlenden Titel gedacht für den Fall,
dass die Kennung schon der Name ist (Ticket-Referenzen). */
it('macht aus einer Zeile mit NUR einer ID einen Knoten mit `#id` als Titel', () => {
const {roots: r, warnings} = parse('- #auth');
expect(r.map(n => [n.label, n.id, n.labelFromId])).toEqual([['#auth', 'auth', true]]);
expect(warnings).toEqual([]);
});
it('nimmt den Doppelpunkt dabei mit weg', () => {
expect(parse('- #US-123:').roots.map(n => n.label)).toEqual(['#US-123']);
});
it('vergibt die ID dabei wirklich — eine zweite meldet sich', () => {
const {roots: r, warnings} = parse('- #auth\n- [ ] Echt #auth');
expect(r.map(n => n.label)).toEqual(['#auth', 'Echt']);
expect(warnings).toEqual([{type: 'duplicateId', line: 2, id: 'auth', firstLine: 1}]);
});
it('setzt `labelFromId` NICHT, wenn ein Titel dasteht', () => {
expect(parse('- #auth: Backend').roots[0].labelFromId).toBe(false);
});
/* Die übrigen Bestandteile werden ganz normal gelesen übrig bleibt nur
kein Titel. */
it('liest Größe, Status und Tag auch ohne Titel', () => {
const n = parse('- [x] #US-123 (L) @anna').roots[0];
expect([n.label, n.size, n.tags, n.status.key]).toEqual(['#US-123', 'L', ['anna'], 'fertig']);
});
it('lässt eine Zeile ohne ID und ohne Label weiterhin weg', () => {
expect(parse('- (L) @anna').roots).toEqual([]);
});
});
/* Übliche Schreibweise: ID vor dem Titel, abgetrennt durch einen Doppelpunkt.