diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 477616a..9c64a34 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -19,6 +19,8 @@ reverse.
## 2026-08-24
+- 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
+- Fix: the exported SVG drew the assumed-M badge filled like a real size instead of inverted
- Dependency links now run behind every node, highlighted ones included — no more lines struck through node titles
- Fix: the line to an only child was torn where the child does not sit centred in its cell
- An optional node (`+`) joins the cheapest path while it is being worked on — started work is the open front
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 9b9aa96..4de3d42 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -4582,3 +4582,74 @@ heraus, fallen genau die fünf danach benannten Zusicherungen; nimmt man
`chosenPool` heraus, genau die zwei zu den Alternativen. Die Tests, die das
**unveränderte** Verhalten festhalten (unangetastete und erledigte Zugaben
bleiben draußen), bleiben in beiden Fällen grün.
+
+## D62 — Größen-Konflikt: Bereiche statt Punkt-Zahlen, XXL nach oben offen
+Gewünscht war eine Anzeige, „wenn die angegebenen T-Shirt-Größen mit den
+Unterknoten nicht zusammenpassen" — ohne automatische Korrektur. Die Skala
+dafür gab es schon (`SIZE_RANK`, D18 nutzt Rang+1 als Kosten); die eigentliche
+Entscheidung war, **wann** „passt nicht" gilt.
+
+**Eine Punkt-Zahl je Größe trägt nicht — egal wie steil die Skala steigt.**
+Der erste Vorschlag war eine additive Verdopplungsskala (XS=1 … XXL=32):
+Summe der Kinder > Elternwert ⇒ Konflikt. Der Nutzer wandte ein, dass damit
+bei jedem größeren Baum die oberen Knoten selbst mit XXL nicht auskommen —
+und die Prüfung der Steilheit führte auf einen härteren Befund: Schon das
+**kanonische Beispiel aus SPEC §10** warnte. `Website-Relaunch (XL)` mit
+Kindern `(M) + (XL) + (M)` ergibt bei Faktor 2 wie bei Faktor 3 eine Summe
+über dem Elternwert — sobald ein Kind die Elterngröße teilt, sprengt jedes
+Geschwister die Summe. Das widerspricht der gelebten Praxis, dass „XL =
+XL-Kind plus etwas Kleinkram" eine völlig normale Schätzung ist. Der Fehler
+liegt nicht im Faktor, sondern darin, dass eine Punkt-Zahl so tut, als wäre
+`(M)` exakt.
+
+**Entschieden: Größen sind Bereiche.** XS=[1,2), S=[2,4), M=[4,8), L=[8,16),
+XL=[16,32), **XXL=[32,∞)**. Konflikt erst, wenn die Summe der **Untergrenzen**
+der Kinder die **Obergrenze** des Elternknotens erreicht — also erst, wenn es
+unter *jeder* Lesart falsch ist. Das hält das kanonische Beispiel sauber
+(24 < 32), meldet vier `(S)` unter `(M)` (8 ≥ 8), enthält den Ordinal-Fall
+(ein strikt größeres Kind warnt immer) und beantwortet den Einwand direkt:
+**XXL hat keine Obergrenze**, ein XXL-Knoten warnt nie — für die großen
+Sammelknoten behauptet die Skala schlicht keine Schranke mehr. Das ist
+dieselbe Haltung wie bei `unknownStatus` und `descStray`: lieber laut, aber
+nur, wo es sicher ist. D46 („die Größen sind ordinal, nicht additiv") bleibt
+für alles andere unangetastet — die Bereichs-Lesart gilt genau dieser einen
+Prüfung.
+
+**Was zählt:** nur die direkten Kinder (je Ebene eine eigene Prüfung, die
+Warnung zeigt auf die Elternzeile), davon nur die mit **angegebener** Größe —
+fehlende Größe ist keine Autoren-Aussage (D44-Linie; anders als bei den
+Pfadkosten wird kein M angenommen). Verworfene und optionale (`+`) Kinder
+zählen nicht; in einer disjunktiven Gruppe (`|`/`=`) zählt die **kleinste**
+Alternative, denn nur eine wird realisiert. Die Gegenrichtung (Eltern größer
+als die Kindersumme) warnt nicht — sie heißt nur unvollständige Zerlegung,
+und dafür gibt es den Geister-Knoten (D8). Ein Elternknoten ohne Größe wird
+nie geprüft.
+
+**Darstellung: das Badge wechselt auf `--warn`, nicht auf Rot.** Der Nutzer
+hatte Rot vorgeschlagen und sich für die Warnfarbe entschieden: „Der Plan
+widerspricht sich" hat mit `#B45309` schon eine Farbe (Geister-Knoten,
+Warnzeilen), und Rot bliebe unvergeben (D34). Dazu die Warnung `sizeConflict`
+mit Zeilennummer (eine Stelle: `build()` in warnings.js), der Grund im
+Tooltip und im `aria-label`. **Nichts wird automatisch korrigiert.**
+
+**Nebenbefund: Der Grafikexport zeichnete das Größen-Badge mit festen
+Farben** (`drawBadge(sizeEl, '#0F766E', '#ffffff')`) — das invertierte
+implizite M (D18) stand damit seit jeher gefüllt im exportierten Bild. Jetzt
+liest der Export die gemessenen Farben wie bei Tags und Diskrepanz-Marke;
+damit folgt auch das Konflikt-Badge von selbst.
+
+**Der eigene Plan hatte acht solcher Konflikte** — alle berechtigt, die
+Größen stammten aus der Zeit ohne Prüfung. Nachgezogen (`#not` L→XL, `#ed`
+XL→XXL, `#ed.live`/`#ed.path`/`#ed.fold`/`#bld`/`#col.own` M→L, `#col.pad`
+S→L, und als Kaskade davon `#col` XL→XXL): 0 Warnungen. Dass die erste
+Anwendung der Regel den eigenen Plan korrigiert, ist kein schlechtes Zeichen —
+genau dafür ist sie da.
+
+**Nachgemessen:** 372 Tests (16 neue in `tests/sizes.test.js`); Gegenprobe
+per Mutation — XXL-Obergrenze wieder eingeführt: genau die zwei danach
+benannten Zusicherungen fallen; optionale Kinder mitgezählt: genau eine;
+disjunktive Gruppen summiert statt Minimum: genau zwei. Im Browser: Badge
+`rgb(180, 83, 9)`, Warnung „Zeile 1: …", Tooltip und `aria-label` benennen
+den Grund; im exportierten SVG das Konflikt-Badge bernstein und das implizite
+M weiß mit Petrol-Rand. Kanonisches Beispiel §10 und der mitgelieferte Plan:
+0 Warnungen.
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 2ffafc6..22a332f 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -325,6 +325,38 @@ effektiv nicht weiter sein als das, was er braucht.
- Anzeige: petrolfarbenes Badge (`--or`, `#0F766E`) mit weißer Schrift oben
rechts an der Knoten-Ecke.
+**Größen-Konflikt** — die angegebene Größe eines Elements muss zu seinen
+Teilpaketen passen. Die Skala ist ordinal; für diese eine Prüfung wird jede
+Größe als **Bereich** gelesen (Untergrenzen verdoppeln sich):
+
+| | XS | S | M | L | XL | XXL |
+|---|---|---|---|---|---|---|
+| Bereich | [1, 2) | [2, 4) | [4, 8) | [8, 16) | [16, 32) | [32, ∞) |
+
+- **Konflikt** ist, wenn selbst die **günstigste Lesart der Kinder** die
+ **großzügigste Lesart des Elternknotens** erreicht: Summe der Untergrenzen
+ der zählenden Kinder ≥ Obergrenze der Elterngröße. Gemeldet wird also nur,
+ was unter *jeder* Lesart falsch ist — `(XL) = XL-Kind + 2 × M` (16+4+4 = 24
+ < 32) ist deshalb kein Konflikt, vier `(S)` unter einem `(M)` (8 ≥ 8) schon.
+ Ein Kind, das **größer** ist als sein Elternknoten, ist immer ein Konflikt
+ (Teilmenge der Regel).
+- **XXL hat keine Obergrenze** — ein XXL-Element warnt nie: Für die großen
+ Sammelknoten eines Plans behauptet die Skala schlicht keine Schranke mehr.
+- **Es zählen nur die direkten Kinder**, und davon nur die mit **angegebener**
+ Größe (fehlende Größe ist keine Aussage; anders als beim günstigsten Pfad
+ wird hier kein `M` angenommen). Verworfene und optionale (`+`) Kinder zählen
+ nicht. In einer disjunktiven Gruppe (`|`/`=`) wird nur eine Alternative
+ realisiert — dort zählt die **kleinste**.
+- Ein Elternknoten **ohne** Größe macht keine Aussage und wird nie geprüft;
+ die Gegenrichtung (Eltern größer als die Kindersumme) warnt nicht — sie
+ heißt nur, dass die Zerlegung unvollständig ist (dafür gibt es den
+ Geister-Knoten).
+- **Meldung und Anzeige:** Warnung `sizeConflict` mit der Zeilennummer des
+ Elternknotens; sein Größen-Badge wechselt auf die Warnfarbe (`--warn`,
+ `#B45309`, weiße Schrift), Tooltip und `aria-label` benennen den Grund.
+ Badge-Färbung auch im Grafikexport und im Druck. **Nichts wird automatisch
+ korrigiert** — die Größen bleiben, wie sie geschrieben sind. Siehe D62.
+
## 6. Links
- Ein nacktes `https://…`-Token macht den ganzen Knoten klickbar
diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum
index 0c69c14..adc666e 100644
--- a/docs/examples/werkbaum.werkbaum
+++ b/docs/examples/werkbaum.werkbaum
@@ -3,7 +3,7 @@
%% Every node carries an id; the block behind --- explains it. Hover a node.
[~] #wb: Werkbaum (XXL) https://github.com/mhoennig/werkbaum
- - [^] #not: Notation (L)
+ - [^] #not: Notation (XL)
- [^] #not.line: Line format (M)
- [^] #not.status: Status box, eight states (S)
- [^] #not.size: T-shirt size and the "decompose from M" rule (S)
@@ -23,6 +23,7 @@
- [^] #not.desc: Node descriptions, shown as a tooltip (M)
- [^] #not.desc.short: A short text right at the node (S) %% a " line below the node
- [^] #not.desc.long: A long block at the end, addressed by its id (S) %% behind a --- separator
+ - [x] #not.sizes: Size conflict — sub-packages must fit the given size (S) %% ranges, XXL open-ended, see D62
- [-] #not.store: A separate storage format for the structure (L) %% the text is the format
- [ ] #not.ticket: Ticket references (#123,#US-123) (S) %% Taiga writes #US-123 for a user story
- [^] #not.llms: llms.md, the notation explained to AI agents (S)
@@ -30,8 +31,8 @@
- [?] #not.benefit: Benefit per node, not only cost (M)
| [?] #not.benefit.sigil: Another sigil next to the size (S)
| [?] #not.benefit.points: Story points behind the T-shirt sizes (M)
- - [~] #ed: Editor (XL) https://werkbaum.javagil.de
- - [^] #ed.live: Live diagram from text (M)
+ - [~] #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.horiz: Horizontal fan (S)
@@ -40,7 +41,7 @@
- [^] #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)
- - [^] #ed.path: Cheapest path (M)
+ - [^] #ed.path: Cheapest path (L)
- [^] #ed.path.cost: Cost model, missing size counts as M (S)
- [^] #ed.path.front: Only the open front: what is done costs nothing (S) %% see D46
- [^] #ed.path.started: Started work is on the path, extras included (S) %% see D61
@@ -73,7 +74,7 @@
- [^] #ed.shortid: Type #.kc under #prod-stage, get #prod-stage.kc (XS) %% an input aid, not notation
- [^] #ed.showids: A # button shows the ids before the titles (XS) %% written as in the text, with a colon
- [^] #ed.lineno.tip: The warning is the tooltip of its number (XS) %% no hunting in the list
- - [^] #ed.fold: Collapsible subtrees (M)
+ - [^] #ed.fold: Collapsible subtrees (L)
- [^] #ed.fold.marks: Initial state from the text: > collapses, < re-opens (S)
- [^] #ed.fold.click: Fold and unfold in the diagram (S)
- [^] #ed.fold.write: Folding writes the mark back into the text (S) %% one undo step
@@ -87,7 +88,7 @@
- [?] #ed.dates: Dates and milestones (M)
| [?] #ed.dates.attr: An attribute in the line (S)
| [?] #ed.dates.view: A separate timeline view (L)
- - [^] #bld: Build and delivery (M)
+ - [^] #bld: Build and delivery (L)
- [^] #bld.bundle: One self-contained file, bundled by Vite (S)
- [^] #bld.tests: Unit tests (S)
- [^] #bld.pages: Published on every push (S)
@@ -110,9 +111,9 @@
- [?] #be.auth: Accounts and permissions (L) :#be.docs
| [?] #be.auth.token: Single user, one token (S)
| [?] #be.auth.oidc: Log in with OIDC (L)
- - [~] #col: Collaborating on a Diagram (XL)
+ - [~] #col: Collaborating on a Diagram (XXL)
" The pad merges concurrent edits, Werkbaum renders the plan.
- - [^] #col.pad: Watch a shared Etherpad — it merges, we render (S)
+ - [^] #col.pad: Watch a shared Etherpad — it merges, we render (L)
- [^] #col.pad.export: Load the pad's plain-text export (XS)
- [^] #col.pad.reload: Reload on demand (XS) %% a timer only trips Etherpad's rate limit
- [^] #col.pad.embed: Embed the pad, keep the text mirror beside it (M)
@@ -123,7 +124,7 @@
| [ ] #col.pad.owntab: Live with it: edit in the pad's own tab (XS)
- [^] #col.pad.point: A shared pointer: !!! focuses a node for everyone (XS)
- [?] > #col.auto: Update by itself, within about 2 s (M)
- | [?] #col.own: Our own Etherpad beside Werkbaum (M) %% then every setting below is ours
+ | [?] #col.own: Our own Etherpad beside Werkbaum (L) %% then every setting below is ours
- [ ] #col.own.db: Node and a real database in userland (S) %% the flatfile store is not for production
- [ ] #col.own.ram: Book the RAM quota for an own daemon (XS)
- [ ] #col.own.proxy: Reverse proxy in .htaccess, websockets included (S) %% Mattermost does it, wiki shows how
@@ -295,6 +296,12 @@
are its text. There is no closing fence, so there is none to forget — this
section is its own example.
+#not.sizes
+ Every size is read as a range whose lower bounds double, XXL open-ended. A
+ warning appears only when even the most optimistic reading of the children
+ exceeds the most generous reading of the parent — nothing is corrected
+ automatically, the badge just turns amber.
+
#not.store
Rejected on purpose: the text is the format. A second representation of the
structure would need syncing, and the first disagreement between the two
diff --git a/frontend/public/llms.md b/frontend/public/llms.md
index b7963dc..15f1bc1 100644
--- a/frontend/public/llms.md
+++ b/frontend/public/llms.md
@@ -109,6 +109,15 @@ One node per line. Everything except the label is optional.
- From `(M)` upward a node **should be decomposed further**; a node ≥ M
without children gets a placeholder hint in the diagram.
- For cost estimation a missing size counts as `M`.
+- **Size conflict check:** a given size must fit the direct children. Each
+ size is read as a range (lower bounds double: XS=1, S=2, M=4, L=8, XL=16,
+ XXL=32; the upper bound is the next lower bound, XXL is open-ended). It is
+ a conflict — warning `sizeConflict` at the parent's line — only when the sum
+ of the children's lower bounds reaches the parent's upper bound, i.e. when
+ it is wrong under *every* reading. Only sized, non-optional, non-discarded
+ direct children count; in an `|`/`=` group only the smallest alternative.
+ An XXL parent never warns; nothing is corrected automatically. When you
+ write plans, pick parent sizes that pass this check.
### Token extraction order (why nothing collides)
diff --git a/frontend/src/app.js b/frontend/src/app.js
index 8f193e8..6254d28 100644
--- a/frontend/src/app.js
+++ b/frontend/src/app.js
@@ -608,8 +608,17 @@ function diagramToSvg(){
parts.push(``);
parts.push(`⚠︎`);
}
+ /* Gemessene Farben statt festem Petrol: Das invertierte implizite M (D18)
+ und das warnfarbene Konflikt-Badge (SPEC §5/D62) kämen sonst als
+ gewöhnliches gefülltes Badge ins Bild — beim impliziten M war genau das
+ seit jeher der Fall (Nebenbefund D62). */
const sizeEl = node.querySelector('.size');
- if(sizeEl) drawBadge(sizeEl, '#0F766E', '#ffffff');
+ if(sizeEl){
+ const s = getComputedStyle(sizeEl);
+ /* Der Rand gehört mit: beim invertierten Badge ist er die einzige Kontur
+ (weiße Füllung auf weißer Karte). */
+ drawBadge(sizeEl, s.backgroundColor, s.color, s.borderTopColor);
+ }
node.querySelectorAll('.tag').forEach(tg => {
const t = getComputedStyle(tg);
drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor);
@@ -1955,6 +1964,8 @@ const I18N = {
unknownDepWarn:"Zeile {line}: Abhängigkeit #{id} — es gibt keinen Knoten mit dieser ID.",
unknownDescWarn:"Zeile {line}: Beschreibung für #{id} — es gibt keinen Knoten mit dieser ID.",
descStrayWarn:"Zeile {line}: Beschreibungszeile ohne Bezug — ihr fehlt der Knoten bzw. der #id-Block davor.",
+ sizeConflictWarn:"Zeile {line}: Die Teilpakete übersteigen zusammen die angegebene Größe ({size}) — selbst in der günstigsten Lesart.",
+ sizeConflictTooltip:"Die Teilpakete übersteigen zusammen die angegebene Größe",
cheapApproxWarn:"Zu viele gekoppelte Alternativgruppen für die exakte Suche — der günstigste Pfad ist gierig geschätzt (je Gruppe lokal gewählt).",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
@@ -2056,6 +2067,8 @@ const I18N = {
unknownDepWarn:"Line {line}: dependency #{id} — no node has this ID.",
unknownDescWarn:"Line {line}: description for #{id} — no node has this ID.",
descStrayWarn:"Line {line}: description line with nothing to attach to — it needs a node or an #id block before it.",
+ sizeConflictWarn:"Line {line}: the sub-packages together exceed the given size ({size}) — even in the most optimistic reading.",
+ sizeConflictTooltip:"The sub-packages together exceed the given size",
cheapApproxWarn:"Too many coupled alternative groups for the exact search — the cheapest path is a greedy estimate (chosen locally per group).",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
@@ -2157,6 +2170,8 @@ const I18N = {
unknownDepWarn:"Línea {line}: dependencia #{id} — ningún nodo tiene esta ID.",
unknownDescWarn:"Línea {line}: descripción para #{id} — ningún nodo tiene esta ID.",
descStrayWarn:"Línea {line}: línea de descripción sin referencia — le falta un nodo o un bloque #id delante.",
+ sizeConflictWarn:"Línea {line}: los subpaquetes juntos superan el tamaño indicado ({size}), incluso en la lectura más optimista.",
+ sizeConflictTooltip:"Los subpaquetes juntos superan el tamaño indicado",
cheapApproxWarn:"Demasiados grupos de alternativas acoplados para la búsqueda exacta — el camino más barato es una estimación voraz (elección local por grupo).",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado",
@@ -2258,6 +2273,8 @@ const I18N = {
unknownDepWarn:"Ligne {line} : dépendance #{id} — aucun nœud ne porte cet ID.",
unknownDescWarn:"Ligne {line} : description pour #{id} — aucun nœud ne porte cet ID.",
descStrayWarn:"Ligne {line} : ligne de description sans rattachement — il lui manque un nœud ou un bloc #id avant.",
+ sizeConflictWarn:"Ligne {line} : les sous-lots dépassent ensemble la taille indiquée ({size}), même dans la lecture la plus optimiste.",
+ sizeConflictTooltip:"Les sous-lots dépassent ensemble la taille indiquée",
cheapApproxWarn:"Trop de groupes d’alternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné",
@@ -2359,6 +2376,8 @@ const I18N = {
unknownDepWarn:"Wiersz {line}: zależność #{id} — żaden węzeł nie ma tego ID.",
unknownDescWarn:"Wiersz {line}: opis dla #{id} — żaden węzeł nie ma tego ID.",
descStrayWarn:"Wiersz {line}: wiersz opisu bez odniesienia — brakuje węzła lub bloku #id przed nim.",
+ sizeConflictWarn:"Wiersz {line}: podzadania razem przekraczają podany rozmiar ({size}) — nawet w najkorzystniejszym odczycie.",
+ sizeConflictTooltip:"Podzadania razem przekraczają podany rozmiar",
cheapApproxWarn:"Zbyt wiele sprzężonych grup alternatyw dla dokładnego wyszukiwania — najtańsza ścieżka jest oszacowana zachłannie (wybór lokalny w każdej grupie).",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
@@ -2460,6 +2479,8 @@ const I18N = {
unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.",
unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.",
descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.",
+ sizeConflictWarn:"Строка {line}: подзадачи вместе превышают указанный размер ({size}) — даже при самой оптимистичной оценке.",
+ sizeConflictTooltip:"Подзадачи вместе превышают указанный размер",
cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
@@ -2561,6 +2582,8 @@ const I18N = {
unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।",
unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।",
descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।",
+ sizeConflictWarn:"पंक्ति {line}: उप-पैकेज मिलकर दिए गए आकार ({size}) से बड़े हैं — सबसे आशावादी आकलन में भी।",
+ sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं",
cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
@@ -2662,6 +2685,8 @@ const I18N = {
unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。",
unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。",
descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。",
+ sizeConflictWarn:"第 {line} 行:子项合计超出所标注的尺寸({size})——即使按最乐观的估算也是如此。",
+ sizeConflictTooltip:"子项合计超出所标注的尺寸",
cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
@@ -2763,6 +2788,8 @@ const I18N = {
unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。",
unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。",
descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。",
+ sizeConflictWarn:"{line} 行目:サブパッケージの合計が指定サイズ({size})を超えています — 最も楽観的な見積もりでも。",
+ sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています",
cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
diff --git a/frontend/src/parser.js b/frontend/src/parser.js
index 8c47ba3..f2a62bf 100644
--- a/frontend/src/parser.js
+++ b/frontend/src/parser.js
@@ -6,6 +6,13 @@
/* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */
export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 };
+/* Größen als BEREICHE für die Konflikt-Prüfung (SPEC §5/D62): Untergrenze
+ 2^Rang, Obergrenze die Untergrenze der nächsten Größe — XXL ist nach oben
+ offen, ein XXL-Element warnt also nie. Die Skala bleibt ansonsten ordinal
+ (D46: „S+S ≠ M" gilt weiter für alles außer dieser einen Prüfung). */
+export const sizeMin = size => 2 ** SIZE_RANK[size];
+export const sizeMax = size => size === 'XXL' ? Infinity : 2 ** (SIZE_RANK[size] + 1);
+
/* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}.
`name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische
Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */
@@ -384,5 +391,33 @@ export function parse(text){
kids.forEach(checkXor);
})(virtualRoot);
+ /* Größen-Konflikt (SPEC §5/D62): Die angegebene Größe muss zu den direkten
+ Kindern passen. Jede Größe zählt als BEREICH (sizeMin/sizeMax); Konflikt
+ erst, wenn selbst die günstigste Lesart der Kinder die großzügigste des
+ Elternknotens erreicht — gemeldet wird nur, was unter jeder Lesart falsch
+ ist. Es zählen nur Kinder MIT Größe (fehlende Größe ist keine Aussage —
+ anders als bei den Pfadkosten wird hier kein M angenommen, D44-Linie),
+ ohne verworfene und ohne optionale; in einer disjunktiven Gruppe wird nur
+ eine Alternative realisiert, dort zählt die kleinste. Nichts wird
+ korrigiert — Warnung an der Elternzeile plus Flag für das Badge. */
+ (function checkSizes(node){
+ const kids = node.children;
+ if(node.size && kids.length && sizeMax(node.size) !== Infinity){
+ const counted = kids.filter(k =>
+ k.size && !k.optional && !(k.status && k.status.key === 'verworfen'));
+ if(counted.length){
+ const mins = counted.map(k => sizeMin(k.size));
+ const need = kids[0].type !== 'and'
+ ? Math.min(...mins)
+ : mins.reduce((a, b) => a + b, 0);
+ if(need >= sizeMax(node.size)){
+ node.sizeConflict = true;
+ warnings.push({type:'sizeConflict', line: node.line, size: node.size});
+ }
+ }
+ }
+ kids.forEach(checkSizes);
+ })(virtualRoot);
+
return {roots: virtualRoot.children, warnings};
}
diff --git a/frontend/src/render.js b/frontend/src/render.js
index 78728eb..be0ee92 100644
--- a/frontend/src/render.js
+++ b/frontend/src/render.js
@@ -72,6 +72,9 @@ function nodeAria(n, opts, fold){
if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)}));
if(n.size) parts.push(t('a11ySize', {size: n.size}));
else if(cheapPath && !isDone(n)) parts.push(t('a11ySizeImplicit'));
+ /* Größen-Konflikt (SPEC §5/D62): die Warnfärbung des Badges kommt beim
+ Screenreader sonst nicht an. */
+ if(n.sizeConflict) parts.push(t('sizeConflictTooltip'));
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. */
@@ -138,6 +141,9 @@ function nodeHtml(n, extra, opts, fold){
eigenen `title` am Badge — der erschiene neben dem Fenster
ein zweites Mal. Sie gehört ohnehin zu den Kurz-Fakten. */
(!n.size && cheapPath && !isDone(n)) ? t('implicitSizeTooltip') : '',
+ /* Größen-Konflikt (SPEC §5/D62): das warnfarbene Badge braucht
+ seine Begründung dort, wo man nachsieht. */
+ n.sizeConflict ? t('sizeConflictTooltip') : '',
t('jumpHint')]
.filter(Boolean).join(' · ');
const tip = n.desc && facts ? n.desc + '\n\n' + TIP_RULE + '\n' + facts
@@ -154,8 +160,10 @@ function nodeHtml(n, extra, opts, fold){
erledigten Knoten wird keine getroffen — er kostet nichts mehr (D46) —,
dort bleibt es deshalb weg. */
const implicitSize = !n.size && cheapPath && !isDone(n);
+ /* Größen-Konflikt (SPEC §5/D62): das Badge wechselt auf die Warnfarbe —
+ die Größe selbst bleibt stehen, korrigiert wird nichts. */
const sizeBadge = n.size
- ? `${n.size}`
+ ? `${n.size}`
: (implicitSize ? `M` : '');
/* High-Risk: Warndreieck (⚠, Textpräsentation via VS15) an der oberen linken
Ecke. aria-hidden — die Information steckt bereits im Status des aria-label. */
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0579077..1dea072 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -910,6 +910,10 @@
.cheap-on .node:not(.cheap).done{opacity:1;filter:none}
/* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */
.size.implicit{background:var(--card);color:var(--or);border-color:var(--or)}
+ /* Größen-Konflikt (SPEC §5/D62): die Teilpakete übersteigen zusammen die
+ angegebene Größe — das Badge wechselt auf die Warnfarbe, der weiße
+ Trennrand zur Knotenkante bleibt. */
+ .size.conflict{background:var(--warn)}
/* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch
die Endknoten (Blätter) des Pfads. Zwei Overlays in #out (erben dessen
zoom): die kräftige Linie liegt HINTER den Knoten (nur in den Lücken
diff --git a/frontend/src/warnings.js b/frontend/src/warnings.js
index 999c211..78e20be 100644
--- a/frontend/src/warnings.js
+++ b/frontend/src/warnings.js
@@ -25,6 +25,10 @@
Beschreibungsteil ohne #id-Block; meldet
auch die von einem versehentlichen
`---` verschluckten Knotenzeilen
+ - sizeConflict { line, size } — Teilpakete übersteigen zusammen die
+ angegebene Größe des Elternknotens, selbst
+ in der günstigsten Bereichs-Lesart
+ (SPEC §5/D62); Zeile = Elternknoten
- cheapApprox { } — günstigster Pfad nur gierig geschätzt:
zu viele gekoppelte Gruppen für die
exakte Suche (D42); zeilenlos
@@ -72,6 +76,8 @@ function build(w, t, esc){
return t('unknownDescWarn', {line: w.line, id: esc(w.id)});
case 'descStray':
return t('descStrayWarn', {line: w.line});
+ case 'sizeConflict':
+ return t('sizeConflictWarn', {line: w.line, size: esc(w.size)});
case 'cheapApprox':
return t('cheapApproxWarn');
case 'sourceLoad':
diff --git a/frontend/tests/sizes.test.js b/frontend/tests/sizes.test.js
new file mode 100644
index 0000000..a2013ed
--- /dev/null
+++ b/frontend/tests/sizes.test.js
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest';
+import { parse, sizeMin, sizeMax } from '../src/parser.js';
+
+/* Größen-Konflikt (SPEC §5/D62): Die angegebene Größe muss zu den direkten
+ Kindern passen. Jede Größe zählt als Bereich (Untergrenze 2^Rang, Obergrenze
+ die nächste Untergrenze, XXL offen); Konflikt erst, wenn selbst die
+ günstigste Lesart der Kinder die großzügigste des Elternknotens erreicht.
+ Nichts wird automatisch korrigiert. */
+
+const conflicts = txt => parse(txt).warnings.filter(w => w.type === 'sizeConflict');
+const node = (txt, label) => {
+ const find = ns => {
+ for(const n of ns){
+ if(n.label === label) return n;
+ const hit = find(n.children);
+ if(hit) return hit;
+ }
+ return null;
+ };
+ return find(parse(txt).roots);
+};
+
+describe('Bereichs-Skala', () => {
+ it('Untergrenzen verdoppeln sich, Obergrenze ist die nächste Untergrenze', () => {
+ expect(['XS','S','M','L','XL'].map(sizeMin)).toEqual([1,2,4,8,16]);
+ expect(['XS','S','M','L','XL'].map(sizeMax)).toEqual([2,4,8,16,32]);
+ });
+ it('XXL ist nach oben offen', () => {
+ expect(sizeMin('XXL')).toBe(32);
+ expect(sizeMax('XXL')).toBe(Infinity);
+ });
+});
+
+describe('Konflikt-Regel (konjunktiv: Summe der Untergrenzen)', () => {
+ it('vier S unter einem M sind ein Konflikt (8 >= 8), drei nicht', () => {
+ const vier = 'A (M)\n - B (S)\n - C (S)\n - D (S)\n - E (S)';
+ expect(conflicts(vier)).toEqual([{type:'sizeConflict', line:1, size:'M'}]);
+ expect(node(vier, 'A').sizeConflict).toBe(true);
+ expect(conflicts('A (M)\n - B (S)\n - C (S)\n - D (S)')).toEqual([]);
+ });
+ it('ein einzelnes Kind derselben Größe passt, ein größeres nie', () => {
+ expect(conflicts('A (M)\n - B (M)')).toEqual([]);
+ expect(conflicts('A (M)\n - B (L)'))
+ .toEqual([{type:'sizeConflict', line:1, size:'M'}]);
+ });
+ it('das kanonische Beispiel bleibt sauber: XL = XL-Kind + 2×M', () => {
+ expect(conflicts('A (XL)\n - B (M)\n - C (XL)\n - D (M)')).toEqual([]);
+ });
+ it('zwei M unter einem M sind ein Konflikt (4+4 >= 8)', () => {
+ expect(conflicts('A (M)\n - B (M)\n - C (M)').length).toBe(1);
+ });
+ it('ein XXL-Elternknoten warnt nie', () => {
+ const txt = 'A (XXL)\n' + Array.from({length: 40}, (_, i) => ` - K${i} (XL)`).join('\n');
+ expect(conflicts(txt)).toEqual([]);
+ });
+ it('geprüft wird je Ebene, die Warnung zeigt auf die Elternzeile', () => {
+ const txt = 'A (XXL)\n - B (S)\n - C (S)\n - D (S)';
+ expect(conflicts(txt)).toEqual([{type:'sizeConflict', line:2, size:'S'}]);
+ });
+});
+
+describe('Was nicht zählt', () => {
+ it('ein Elternknoten ohne Größe macht keine Aussage', () => {
+ expect(conflicts('A\n - B (XXL)\n - C (XXL)')).toEqual([]);
+ });
+ it('Kinder ohne Größe zählen nicht — hier wird kein M angenommen', () => {
+ expect(conflicts('A (XS)\n - B\n - C\n - D')).toEqual([]);
+ /* die größenlosen bleiben auch neben gezählten außen vor */
+ expect(conflicts('A (M)\n - B (S)\n - C\n - D')).toEqual([]);
+ });
+ it('verworfene Kinder zählen nicht', () => {
+ expect(conflicts('A (M)\n - [-] B (XL)\n - C (S)')).toEqual([]);
+ });
+ it('optionale Kinder (`+`) zählen nicht', () => {
+ expect(conflicts('A (M)\n - B (S)\n + C (XL)')).toEqual([]);
+ });
+ it('die Gegenrichtung warnt nicht: Eltern größer als die Kindersumme', () => {
+ expect(conflicts('A (XXL)\n - B (XS)')).toEqual([]);
+ });
+});
+
+describe('Disjunktive Gruppen: nur die kleinste Alternative zählt', () => {
+ it('| mit einer passenden Alternative ist kein Konflikt', () => {
+ expect(conflicts('A (M)\n | B (L)\n | C (S)')).toEqual([]);
+ });
+ it('| warnt erst, wenn selbst die kleinste Alternative sprengt', () => {
+ expect(conflicts('A (M)\n | B (L)\n | C (L)').length).toBe(1);
+ });
+ it('= verhält sich wie |', () => {
+ expect(conflicts('A (M)\n = B (L)\n = C (S)')).toEqual([]);
+ expect(conflicts('A (M)\n = B (L)\n = C (L)').length).toBe(1);
+ });
+});