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
+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.