diff --git a/apps/api/test/architecture-red-case.test.ts b/apps/api/test/architecture-red-case.test.ts
index c0f0e04..3dd9a9f 100644
--- a/apps/api/test/architecture-red-case.test.ts
+++ b/apps/api/test/architecture-red-case.test.ts
@@ -209,7 +209,68 @@ describe("Rot-Fall", () => {
rmSync(join(root, "apps/api/src/modules/workforce/services"), { recursive: true, force: true });
});
+ it("faengt Domain-, Vertrags- und Routerimporte in packages/ui", () => {
+ // Die drei realistischen Einbruchstellen einer geteilten UI-Bibliothek:
+ // ein Domaintyp „nur fuer die Typisierung", ein Vertrags-DTO „nur fuer die
+ // Props", und der Router, weil ein Link ja irgendwohin muss. Alle drei
+ // wuerden `packages/ui` an easyTree binden und die Wiederverwendung in der
+ // Feld-App unmoeglich machen.
+ write(
+ "packages/ui/src/bad.tsx",
+ [
+ 'import type { PlanningWeek } from "@easytree/domain";',
+ 'import type { SessionDto } from "@easytree/contracts";',
+ 'import Link from "next/link";',
+ 'import { useRouter } from "next/navigation";',
+ "export const bad = [Link, useRouter] as const;",
+ "export type Props = { week: PlanningWeek; session: SessionDto };",
+ ].join("\n") + "\n",
+ );
+
+ const found = violationsFor().violations.filter((v) => v.rule === "ui-dependency-allowlist");
+ const messages = found.map((v) => v.message);
+ for (const needle of [
+ "@easytree/domain",
+ "@easytree/contracts",
+ "next/link",
+ "next/navigation",
+ ]) {
+ expect(
+ messages.some((m) => m.includes(needle)),
+ `nicht gemeldet: ${needle}`,
+ ).toBe(true);
+ }
+ rmSync(join(root, "packages/ui"), { recursive: true, force: true });
+ });
+
+ it("laesst react und paketinterne Pfade in packages/ui zu", () => {
+ // Ohne diese Gegenprobe waere eine Regel, die ALLES meldet, ebenso gruen.
+ write("packages/ui/src/ok-nachbar.tsx", "export const nachbar = 1;\n");
+ write(
+ "packages/ui/src/ok.tsx",
+ [
+ 'import type { ReactNode } from "react";',
+ 'import { nachbar } from "./ok-nachbar.js";',
+ "export const ok = (kind: ReactNode) => [kind, nachbar];",
+ ].join("\n") + "\n",
+ );
+
+ const alle = violationsFor();
+ // Erst die Sichtbarkeit, dann die Sauberkeit: waere `ok.tsx` gar nicht
+ // gescannt worden — Pfadtippfehler, geaenderte SOURCE_EXTENSIONS, ein neuer
+ // SKIP_DIRS-Eintrag —, waere die leere Liste unten ohne jede Messung gruen.
+ expect(alle.scopeCounts.get("ui-dependency-allowlist")).toContain("packages/ui/src/ok.tsx");
+ const gemeldet = alle.violations
+ .filter((v) => v.rule === "ui-dependency-allowlist")
+ .filter((v) => v.file === "packages/ui/src/ok.tsx");
+ expect(gemeldet).toEqual([]);
+ rmSync(join(root, "packages/ui"), { recursive: true, force: true });
+ });
+
it("ist am sauberen Baum gruen — der Rot-Fall kommt von den Verstoessen, nicht vom Aufbau", () => {
+ // Steht bewusst am ENDE des Blocks: als Schlussbilanz faengt sie eine
+ // Fixture, die ein frueherer Fall liegen gelassen hat. Weiter oben wuerde
+ // sie das nicht mehr tun.
expect(violationsFor().violations).toEqual([]);
});
});
diff --git a/apps/api/test/architecture.test.ts b/apps/api/test/architecture.test.ts
index c4bf840..b46b8d6 100644
--- a/apps/api/test/architecture.test.ts
+++ b/apps/api/test/architecture.test.ts
@@ -103,6 +103,28 @@ describe("Architekturgrenzen", () => {
expect(scopeCounts.get(ruleId)?.size ?? 0).toBeGreaterThan(0);
});
+ it("Regel ui-dependency-allowlist ueberwacht JEDE Datei unter packages/ui/src", () => {
+ // `scopeCounts >= 1` unterscheidet nicht zwischen „bewacht das Paket" und
+ // „bewacht eine Datei". Gemessen 27.08.2026: eine Verengung auf
+ // `packages/ui/src/b` bewacht 1 von 11 Dateien, und VOR dieser Zusicherung
+ // blieben dabei beide Suiten vollstaendig gruen — der Waechter waere still
+ // abgeruestet gewesen. Diese Zeile nennt die Verengung jetzt beim Namen
+ // (1 statt 11); die Sichtbarkeitsprobe im Rot-Fall („laesst react und
+ // paketinterne Pfade in packages/ui zu") faellt unabhaengig davon ebenfalls.
+ //
+ // Die linke Seite kommt aus dem Praefix ueber die eingesammelten Importe,
+ // die rechte aus `inScope` der Regel. Beide Wege muessen dieselbe Menge
+ // ergeben. Importfreie Dateien tauchen auf keiner Seite auf, deshalb kann
+ // ein neues Primitive ohne Import diese Zusicherung nicht falsch rot machen.
+ const ausDemPraefix = new Set(
+ refs.filter((ref) => ref.from.startsWith("packages/ui/src/")).map((ref) => ref.from),
+ );
+ expect(ausDemPraefix.size).toBeGreaterThan(5);
+ expect([...(scopeCounts.get("ui-dependency-allowlist") ?? [])].sort()).toEqual(
+ [...ausDemPraefix].sort(),
+ );
+ });
+
it.each([...SCAFFOLDED_MODULES])(
"Modul %s besitzt echten Inhalt und eine oeffentliche API",
(slug) => {
diff --git a/apps/api/test/architecture/rules.ts b/apps/api/test/architecture/rules.ts
index 3f7e7d0..cb298ec 100644
--- a/apps/api/test/architecture/rules.ts
+++ b/apps/api/test/architecture/rules.ts
@@ -107,6 +107,38 @@ const API_ALLOWED_PACKAGES: readonly RegExp[] = [
/^jose$/,
];
+/**
+ * Was `packages/ui/src` importieren darf (EYT-80).
+ *
+ * Absichtlich eine ALLOWLIST — dieselbe Lehre wie bei `domain-allowlist` im
+ * Dateikopf: „domaenenfrei" ist ein universelles Verbot, und eine endliche
+ * Liste verbotener Pakete kann es nie ausdruecken. Eine Sperrliste
+ * `["@easytree/domain", "next"]` liesse `next/link`, `@easytree/domain/dist/…`
+ * und jedes kuenftige Fachpaket durch.
+ *
+ * `react` steht hier, weil ein React-Primitive ohne React keins ist — und ist
+ * der EINZIGE Eintrag. Ein Routerpaket steht bewusst NICHT hier:
+ * `DateRangeControl` bekommt sein Link-Element injiziert, damit dasselbe
+ * Primitive spaeter die Feld-App bedienen kann (EYT-80,
+ * Zwei-Client-Architektur, Confluence 8486960).
+ *
+ * `react/jsx-runtime` steht ebenfalls nicht hier, und das ist kein Versehen:
+ * `tsconfig.build.json` setzt `jsx: "react-jsx"`, der Laufzeitimport entsteht
+ * also beim EMIT und nie im Quelltext. `ts.preProcessFile` liest Quelltext,
+ * der Spezifizierer kann diese Regel damit gar nicht erreichen. Ein Eintrag,
+ * der nie feuern kann, verbraucht die Reviewsichtbarkeit dieser Liste fuer
+ * nichts.
+ *
+ * Dass `"react"` der einzige nicht-relative Spezifizierer unter
+ * `packages/ui/src` ist, steht hier bewusst OHNE Dateizahl: die Regel selbst
+ * prueft es bei jedem Lauf, denn jeder weitere Spezifizierer waere eine
+ * Verletzung. Eine mitgeschriebene Zahl waere dagegen sofort veraltet — genau
+ * das ist in diesem Slice passiert, als `4a6429e` eine zwoelfte Datei anlegte.
+ */
+const UI_ALLOWED_PACKAGES: readonly RegExp[] = [/^react$/];
+
+const UI_PACKAGE = "packages/ui/src/";
+
export const RULES: readonly Rule[] = [
{
// ADR-001 Z. 74 — als Allowlist formuliert, siehe Dateikopf.
@@ -279,6 +311,51 @@ export const RULES: readonly Rule[] = [
? `Produktionscode darf "${ref.specifier}" nicht importieren — @easytree/contracts/testing/* ist ausschliesslich Testinfrastruktur (EYT-103).`
: null,
},
+ {
+ // EYT-80 — `packages/ui` ist domaenenfrei, API-frei, auth-frei und
+ // routerunabhaengig (ADR-001 Z. 77, Basisdesign v2.0, Zwei-Client-
+ // Architektur). Bis EYT-80 stand das als Zusage im Dateikopf von
+ // `packages/ui/src/index.ts` und sonst nirgends. Vier Regeln sahen
+ // `packages/ui` zwar im Geltungsbereich (`no-app-to-app`,
+ // `no-generic-shared-package`, `no-fixtures-in-production-code`,
+ // `no-contract-testing-in-production-code`), aber alle vier sind enge
+ // Verbote: KEINE beschraenkte die Abhaengigkeitsoberflaeche, und
+ // `@easytree/domain` wie `next/link` waeren durch alle vier gelaufen
+ // (gemessen 27.08.2026 am Stand vor dieser Regel: 11 Dateien unter
+ // packages/ui/src, und 0 Regeln mit Importliste).
+ //
+ // Die Dateizahl ist eine Momentaufnahme und waechst mit dem Paket — sie
+ // traegt hier nichts und darf nicht nachgepflegt werden. Verbindlich ist
+ // die Zusicherung „Regel ui-dependency-allowlist ueberwacht JEDE Datei
+ // unter packages/ui/src" in `architecture.test.ts`: sie vergleicht den
+ // Geltungsbereich bei JEDEM Lauf gegen die tatsaechlich eingesammelten
+ // Dateien und geht rot, wenn die Regel eine davon nicht sieht. Genau
+ // diese Zeile war schon einmal falsch: `4a6429e` legte `app-shell.tsx`
+ // an und machte aus 11 zwoelf, ohne dass irgendetwas rot wurde.
+ //
+ // Geltungsbereich ist `src/` und NICHT das ganze Paket: `test/` importiert
+ // zu Recht vitest und @testing-library, die eine Paket-Allowlist ablehnen
+ // muesste. `src/` ist zugleich genau die ausgelieferte Oberflaeche
+ // (tsconfig.build.json `include: ["src"]`, package.json `files: ["dist"]`).
+ id: "ui-dependency-allowlist",
+ inScope: (file): boolean => file.startsWith(UI_PACKAGE),
+ check: (ref): string | null => {
+ if (ref.isTypeReference) {
+ return `packages/ui darf keine ///
{navPunkte.map((punkt) => (
@@ -60,52 +109,11 @@ export function AppShell({ children }: { children: ReactNode }) {
))}
-
easyTree — Arboscus Teamplaner
} + > + {children} + ); } diff --git a/apps/web/components/wochen-navigation.tsx b/apps/web/components/wochen-navigation.tsx index 56d12ed..a58bdb0 100644 --- a/apps/web/components/wochen-navigation.tsx +++ b/apps/web/components/wochen-navigation.tsx @@ -23,6 +23,27 @@ * drei Namen nicht schreiben, weil der Waechter seine eigene Beschreibung * mitgezaehlt haette. Diese Einschraenkung ist damit aufgehoben. * + * ## Was hier noch steht, ist die UEBERSETZUNG (EYT-80) + * + * Die Darstellung selbst liegt seit EYT-80 in `@easytree/ui` als + * `DateRangeControl` — ein domaenenfreier Bedienbereich fuer einen Zeitraum, + * der keinen Wochenbegriff kennt. Was in dieser Datei bleibt, ist genau die + * Uebersetzung des Wochenmodells in dieses Primitive: die Beschriftungen + * („Vorherige Woche", „Heute", „Nächste Woche", „Aktuelle Woche"), die sechs + * `data-testid`-Werte und der Link-Adapter. Es kommt keine Rechnung dazu und + * es faellt keine weg — die Adressen und Texte werden unveraendert + * durchgereicht. + * + * Die Zusicherung „rechnet nicht" wird dadurch ZWEIFACH getragen. Erstens + * weiterhin von den Sentinels in `test/wochen-navigation.test.tsx`: steht dort + * `SENTINEL-NACH` im DOM, wurde durchgereicht. Zweitens jetzt auch + * STRUKTURELL, denn die Regel `ui-dependency-allowlist` in + * `apps/api/test/architecture.test.ts` laesst unter `packages/ui/src/` nur + * `react` und paketinterne relative Pfade zu — das Primitive kann `@easytree/ + * domain`, `@easytree/contracts` und `next/navigation` gar nicht importieren + * und hat damit keinen Zugang zu einer Kalenderrechnung. Die zweite Haelfte + * kostet nichts und faellt bei jeder Umgehung sofort auf. + * * ## Warum `Link` und nicht `router.push` * * Ein `` ist mit der Tastatur bedienbar, im Kontextmenue teilbar und @@ -39,7 +60,8 @@ * wird. Sichtbar hingestellt widerspraeche er dem Inkrement selbst: „die * Planerin blaettert OHNE technischen Parameter". `VisuallyHidden` loest beides * zugleich — der Schluessel bleibt im Accessibility-Tree und im `textContent`, - * die Flaeche zeigt „KW 34 · 2026". + * die Flaeche zeigt „KW 34 · 2026". Das Verstecken macht das Primitive, sobald + * `rangeKey` gesetzt ist. * * ## Der Fehlerfall ist keine Sackgasse * @@ -48,78 +70,85 @@ * darstellbare Woche, und eine ersatzweise angezeigte waere die schlimmere * Variante (`E2`): die Planerin saehe eine plausible Woche, aber nicht die, die * sie angefordert hat. Welchen Text die Seite zum Grund schreibt, entscheidet - * `app/planung/page.tsx`; diese Komponente kennt den Grund nicht. + * `app/planung/page.tsx`; diese Komponente kennt den Grund nicht. Im Primitive + * ist `reset` genau deshalb PFLICHT und alles andere optional. * * ## Die laufende Woche ist ueber TEXT kenntlich * * `AC-018` verlangt, dass ein Zustand nie nur ueber Farbe getragen wird. * „Aktuelle Woche" steht deshalb als Wort da; `aria-current="page"` auf dem - * Rueckweg ist die maschinenlesbare Zugabe, nicht der Traeger. + * Rueckweg ist die maschinenlesbare Zugabe, nicht der Traeger. Im Primitive + * sind das ZWEI getrennte Eingaenge — `currentMarker` und `reset.current` —, + * hier speist beide dasselbe `modell.istAktuelleWoche`. Auseinanderlaufen + * koennen sie trotzdem, deshalb sichert der Abnahmevertrag beide Haelften in + * derselben Zusicherung. */ -import { VisuallyHidden } from "@easytree/ui"; +import { DateRangeControl, type DateRangeLinkProps } from "@easytree/ui"; import Link from "next/link"; +import type { ReactNode } from "react"; import type { Wochenmodell } from "../lib/wochennavigation"; +/** + * Der Adapter auf `next/link`. `@easytree/ui` darf keinen Router importieren + * (Regel `ui-dependency-allowlist`), also reicht die Anwendung ihr Link- + * Element herein. + * + * Bewusst ein Adapter und nicht `linkComponent={Link}` direkt: `next/link` + * nimmt `href: Url` — also auch ein Objekt — und ist damit WEITER als der + * Vertrag `DateRangeLinkProps` mit `href: string`. Ob TypeScript die direkte + * Zuweisung akzeptiert, haengt an der Varianz der Prop-Typen und zeigte sich + * erst im `typecheck`. Fuenf Zeilen sind billiger als diese Wette. + */ +function WochenLink({ children, ...rest }: DateRangeLinkProps): ReactNode { + return {children}; +} + export function WochenNavigation({ modell }: { modell: Wochenmodell }) { if (modell.art === "fehlerhaft") { return ( - +