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 /// fuehren — sie zieht eine ganze Ambient-Oberflaeche in den Scope.`; + } + if (!ref.specifier.startsWith(".")) { + if (UI_ALLOWED_PACKAGES.some((allowed) => allowed.test(ref.specifier))) return null; + return `"${ref.specifier}" steht nicht auf der Importliste von packages/ui/src. Erlaubt sind ausschliesslich react und paketinterne relative Pfade — Fachbegriffe, Vertraege, Router und HTTP-Clients gehoeren in die Anwendung. Der Ausweg ist NICHT, diese Liste zu erweitern: ein Primitive bekommt sein Link-Element, seinen Formatierer oder seine Daten als Prop injiziert, damit es auch die Feld-App bedienen kann (EYT-80).`; + } + if (ref.resolved === null) { + return `Relativer Import "${ref.specifier}" ist nicht aufloesbar — der Scanner koennte ihn sonst stillschweigend uebergehen.`; + } + if (!ref.resolved.startsWith(UI_PACKAGE)) { + return `packages/ui darf nicht nach "${ref.resolved}" hinausgreifen (EYT-80).`; + } + return null; + }, + }, ]; export function evaluate(refs: readonly ImportRef[]): { diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 51e9882..7c712ea 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -94,7 +94,7 @@ body { } /* Skip-Link: erstes fokussierbares Element, sichtbar bei Fokus. */ -.skip-link { +.eyt-app-shell__skip-link { position: absolute; left: var(--space); top: -100%; @@ -106,12 +106,12 @@ body { text-decoration: none; } -.skip-link:focus-visible, -.skip-link:focus { +.eyt-app-shell__skip-link:focus-visible, +.eyt-app-shell__skip-link:focus { top: 0; } -.app-header { +.eyt-app-shell__header { display: flex; flex-wrap: wrap; align-items: center; @@ -120,7 +120,7 @@ body { border-bottom: 1px solid var(--color-border); } -.app-brand { +.eyt-app-shell__brand { font-weight: 700; font-size: 1.25rem; color: var(--color-brand); @@ -144,7 +144,7 @@ body { font-weight: 700; } -.app-main { +.eyt-app-shell__main { flex: 1; width: 100%; max-width: 60rem; @@ -152,7 +152,7 @@ body { padding: var(--space); } -.app-footer { +.eyt-app-shell__footer { padding: var(--space); border-top: 1px solid var(--color-border); font-size: 0.875rem; @@ -255,11 +255,11 @@ body { * Basisdesign v2.0 — Werkbank-Bausteine des Kosten-Slices * ============================================================ */ -.app-header { +.eyt-app-shell__header { background: var(--eyt-bg-surface); } -.app-session { +.eyt-app-shell__session { margin-left: auto; display: flex; align-items: center; @@ -580,8 +580,16 @@ body { * Nur Anordnung — Farben kommen ausschliesslich aus den bestehenden Tokens * oben in dieser Datei, es kommt kein einziges neues Farbliteral dazu. Die * Bedienleiste bekommt hier erstmals ueberhaupt eine Darstellung: bis M6 trug - * `.wochennavigation` eine Klasse ohne jede Regel, die Bedienelemente standen + * `.eyt-date-range` eine Klasse ohne jede Regel, die Bedienelemente standen * also als nackte Absaetze untereinander. + * + * Seit EYT-80 traegt diese Leiste das Primitive `DateRangeControl` aus + * `@easytree/ui`; die Namen unten sind deshalb `eyt-date-range*` und werden + * DORT vergeben. Beide Haelften der Kopplung sind bewacht: dass es hier zu + * jedem Namen eine Regel gibt, haelt `apps/web/test/app-shell-styles.test.ts`, + * und dass das Paket die Namen ueberhaupt ausgibt, der Fall „gibt alle sieben + * Klassennamen des Bedienbereichs aus" in + * `packages/ui/test/date-range-control.test.tsx`. */ .werkbank { display: flex; @@ -604,7 +612,7 @@ body { * zuerst, die Wege dahinter — auf einer schmalen Flaeche ist „welche Woche" * die erste Frage, nicht „wohin als naechstes". */ -.wochennavigation { +.eyt-date-range { display: flex; flex-wrap: wrap; align-items: baseline; @@ -615,7 +623,7 @@ body { background: var(--eyt-bg-surface); } -.wochennavigation__woche { +.eyt-date-range__range { margin: 0; font-weight: 700; /* Woche und Zeitraum gehoeren zusammen und bleiben deshalb beieinander, @@ -623,7 +631,7 @@ body { flex-basis: 100%; } -.wochennavigation__zeitraum { +.eyt-date-range__detail { margin: 0; flex-basis: 100%; color: var(--eyt-text-secondary); @@ -633,13 +641,13 @@ body { * Die laufende Woche ist ueber TEXT kenntlich (`AC-018`) — diese Regel gibt * ihr nur Platz, sie traegt die Aussage nicht. */ -.wochennavigation__marke { +.eyt-date-range__marker { margin: 0; color: var(--eyt-text-secondary); font-size: 0.875rem; } -.wochennavigation__ziel { +.eyt-date-range__action { /* 40 px Mindesthoehe fuer Zeigegeraete (Basisdesign v2.0 §2.3). */ display: inline-flex; align-items: center; diff --git a/apps/web/components/app-shell.tsx b/apps/web/components/app-shell.tsx index d4b1386..d1fa48f 100644 --- a/apps/web/components/app-shell.tsx +++ b/apps/web/components/app-shell.tsx @@ -4,6 +4,8 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import type { ReactNode } from "react"; +import { AppShell as UiAppShell } from "@easytree/ui"; + import { useSession } from "../lib/session-provider"; /** @@ -14,6 +16,13 @@ import { useSession } from "../lib/session-provider"; * Der Navigationspunkt „Kosten" erscheint AUSSCHLIESSLICH mit `costs.read` * aus der realen Session — und ersetzt trotzdem keine API-Autorisierung: * dieselbe Rechteliste steuert hier nur die Anzeige. + * + * Seit EYT-80 kommt das GERUEST aus `@easytree/ui`: Sprunganker, Kopfleiste, + * Hauptbereich und Fusszeile stellt der Rahmen, nicht mehr diese Datei. Was + * die Slots FUELLT, wird weiterhin hier gerendert — `nav`, `ul`, `label`, + * `select`, `button`, `Link` und `p` stehen unveraendert unten. Was hier + * bleibt, ist genau das, was dort nicht hingehoert: Sitzung, Rechtefilter, + * Organisationswahl, Abmelden und der Router. */ export function AppShell({ children }: { children: ReactNode }) { const pfad = usePathname(); @@ -35,13 +44,53 @@ export function AppShell({ children }: { children: ReactNode }) { ...(hatRecht("costs.read") ? [{ href: "/kosten", label: "Kosten" }] : []), ]; - return ( + // Der Sitzungsbereich steht als benannter Wert vor dem `return` und nicht + // als 38-zeiliger Ausdruck an der Prop: eine Fallunterscheidung mit einer + // zweiten darin, zwei `.map` und ein Klick-Handler lesen sich auf + // Prop-Einrueckung nicht mehr als Struktur. + const sitzungsbereich = angemeldet ? ( <> - - Zum Hauptinhalt springen - -
- easyTree + {organisationen.length > 1 ? ( + + ) : ( + {organisation?.name ?? ""} + )} + + + ) : ( + + Anmelden + + ); + + return ( +
    {navPunkte.map((punkt) => ( @@ -60,52 +109,11 @@ export function AppShell({ children }: { children: ReactNode }) { ))}
-
- {angemeldet ? ( - <> - {organisationen.length > 1 ? ( - - ) : ( - {organisation?.name ?? ""} - )} - - - ) : ( - - Anmelden - - )} -
-
-
- {children} -
- - + } + sessionArea={sitzungsbereich} + footer={

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 ( - + ); } - return ( - + return ( + ); } diff --git a/apps/web/e2e/shell-smoke.spec.ts b/apps/web/e2e/shell-smoke.spec.ts index 0793f19..cd2a29f 100644 --- a/apps/web/e2e/shell-smoke.spec.ts +++ b/apps/web/e2e/shell-smoke.spec.ts @@ -1,5 +1,5 @@ import AxeBuilder from "@axe-core/playwright"; -import { expect, test } from "@playwright/test"; +import { expect, test, type Locator } from "@playwright/test"; const VIEWPORTS = [ { name: "320px (200%-Zoom-Aequivalent)", width: 320, height: 720 }, @@ -148,6 +148,203 @@ test("Tab-Zyklus: alle interaktiven Elemente erreichbar, sichtbarer Fokus, keine expect(after).not.toBe(visited[visited.length - 1]); }); +/** + * ## Das AUSSEHEN des Rahmens, nicht sein Text (EYT-80) + * + * Die vier Faelle unten messen Lage und Groesse im echten Browser. Gemessen + * 27.08.2026 gab es dafuer im ganzen Repository keine Zusicherung: + * `apps/web/test/app-shell-styles.test.ts` vergleicht Zeichenketten IM + * Stylesheet, und ein Klassenname im Stylesheet beweist weder Lage noch + * Groesse; `grep -rnE 'boundingBox|getComputedStyle|toHaveCSS' apps/web/test + * apps/web/e2e` fand VIER Fokussonden und null Layoutaussagen + * (`shell-smoke.spec.ts:76` und `:121`, `read-through.spec.ts:745`, + * `auth-journey/journey.pwtest.ts:130`). + * + * Das `-E` gehoert zwingend dazu: ohne den Schalter ist `|` auf macOS ein + * gewoehnliches Zeichen und das Muster sucht die Zeichenkette + * „boundingBox|getComputedStyle|toHaveCSS" am Stueck. Am Stand vor diesem + * Umbau fand die BRE-Fassung 0 Zeilen; heute findet sie genau eine — diesen + * Kommentar hier. + * + * Ohne diese Faelle waeren gruen durchgegangen: ein dauerhaft sichtbarer + * Sprunganker, eine untereinander gestapelte Kopfleiste, ein nicht mehr rechts + * stehender Sitzungsbereich, eine randlos volle Hauptspalte und ein + * Bedienelement unter dem 40-px-Ziel. + * + * Warum hier und nicht in jsdom: jsdom hat kein Layout und liefert fuer jedes + * Rechteck Nullen. Nur `web-smoke` laeuft gegen den Produktions-Build. + */ + +/** Rechteck eines Elements — explizit als Zahlen, eine DOMRect ist nicht serialisierbar. */ +function rechteck(ziel: Locator) { + return ziel.evaluate((el) => { + const r = el.getBoundingClientRect(); + return { + top: r.top, + right: r.right, + bottom: r.bottom, + left: r.left, + width: r.width, + height: r.height, + }; + }); +} + +test("Sprunganker liegt ausserhalb des Bildes, bis er den Fokus hat", async ({ page }) => { + await page.goto("/"); + const anker = page.locator("a[href='#hauptinhalt']"); + + // Versteckt ist er ALLEIN durch `position: absolute` plus `top: -100%` in + // `.eyt-app-shell__skip-link`; ein `display: none` gibt es nicht, und alle + // bisherigen Zusicherungen zum Sprunganker pruefen Existenz, Reihenfolge und + // Fokus — nie Sichtbarkeit. Gegenmutation (ausgefuehrt 27.08.2026): beide + // Deklarationen loeschen und neu bauen; er steht dann im Fluss und diese + // Zeile wird rot mit `bottom=40`. + const versteckt = await rechteck(anker); + expect( + versteckt.bottom, + `Sprunganker steht ohne Fokus im Bild (bottom=${versteckt.bottom})`, + ).toBeLessThanOrEqual(0); + + await page.keyboard.press("Tab"); + await expect(anker).toBeFocused(); + // Zweite Haelfte, eigene Gegenmutation: `top: 0` aus der :focus-Regel + // entfernen — dann bleibt er auch mit Fokus oberhalb des Bildes. + const sichtbar = await rechteck(anker); + expect( + sichtbar.top, + `Sprunganker kommt mit Fokus nicht ins Bild (top=${sichtbar.top})`, + ).toBeGreaterThanOrEqual(0); +}); + +test("Kopfleiste ist EINE Zeile mit dem Sitzungsbereich rechts", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/"); + const kopf = page.getByRole("banner"); + const anmelden = kopf.getByRole("link", { name: "Anmelden" }); + await expect(anmelden).toBeVisible(); + + const kopfBox = await rechteck(kopf); + const markeBox = await rechteck(page.locator(".eyt-app-shell__brand")); + const anmeldenBox = await rechteck(anmelden); + const abstandRechts = kopfBox.right - anmeldenBox.right; + const abstandLinks = anmeldenBox.left - kopfBox.left; + + // ERSTE Haelfte des Titels: EINE Zeile. + // + // Ohne diese Zusicherung versprach der Titel mehr, als der Fall pruefte. Das + // Verhaeltnis darunter ist ein LINKS/RECHTS-Vergleich und ueberlebt einen + // Umbruch: mit `.app-nav-list { min-width: 1200px }` bei 1280 px Breite wurde + // die Kopfleiste dreizeilig (Hoehe 159 px, Marke `top=16`, Anmelden + // `top=102`) und der Fall blieb GRUEN (gemessen 27.08.2026). `flex-wrap: + // wrap` steht absichtlich auf `.eyt-app-shell__header` — ein Umbruch ist hier + // konfiguriertes Verhalten, kein exotischer Zufall. + // + // Mit dieser Zusicherung wird dieselbe Mutation rot: gemessen `Marke=31, + // Anmelden=122, Kopfhoehe=159`, Abstand der Mitten 91 gegen erlaubte 2. + // Ungestoert liegen beide Mitten aufeinander. + // + // Verglichen werden die vertikalen MITTEN und nicht die Oberkanten: die + // Kopfleiste richtet ueber `align-items: center` aus, und Marke (Schriftgrad + // 1.25rem) und Anmelden-Zugang (`min-height: 40px`) sind verschieden hoch. + // In einer Zeile fallen ihre Mitten zusammen, in getrennten Flex-Zeilen + // nicht. + const markeMitte = markeBox.top + markeBox.height / 2; + const anmeldenMitte = anmeldenBox.top + anmeldenBox.height / 2; + expect( + Math.abs(markeMitte - anmeldenMitte), + `Kopfleiste umbricht: Marke=${markeMitte}, Anmelden=${anmeldenMitte}, Kopfhoehe=${kopfBox.height}`, + ).toBeLessThanOrEqual(2); + + // ZWEITE Haelfte: der Sitzungsbereich steht rechts. + // EINE Zusicherung, ZWEI Regeln — beide Gegenmutationen sind ausgefuehrt und + // wurden rot (27.08.2026): ohne `margin-left: auto` auf + // `.eyt-app-shell__session` steht der Zugang direkt hinter der Navigation + // (rechts=1008.9, links=172.0); ohne `display: flex` auf + // `.eyt-app-shell__header` stapeln sich Marke, Navigation und + // Sitzungsbereich untereinander und er rutscht an den linken Rand + // (rechts=1165.0, links=16). In beiden Faellen kippt dasselbe Verhaeltnis. + expect( + abstandRechts, + `Anmelden steht nicht rechts: rechts=${abstandRechts}, links=${abstandLinks}`, + ).toBeLessThan(abstandLinks); +}); + +test("Hauptspalte ist begrenzt und mittig", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + const haupt = await rechteck(page.getByRole("main")); + const buehne = await page.evaluate(() => document.documentElement.clientWidth); + + // `max-width: 60rem` auf `.eyt-app-shell__main`; ohne die Regel gemessen + // `width=1440` (Gegenmutation ausgefuehrt 27.08.2026). Zusaetzlich gegen die + // tatsaechliche Buehnenbreite verglichen: ein Rollbalken unterschreitet die + // 1440 von selbst, die reine Zahlengrenze bliebe dann still gruen. + expect(haupt.width, `Hauptspalte fuellt die Breite (width=${haupt.width})`).toBeLessThan(1440); + expect(haupt.width, `Hauptspalte fuellt die Buehne (${haupt.width} von ${buehne})`).toBeLessThan( + buehne, + ); + + // `margin: 0 auto`. Ohne die Regel steht die Spalte am linken Rand — + // gemessen `links=0, rechts=480` (Gegenmutation ausgefuehrt 27.08.2026). + const randLinks = haupt.left; + const randRechts = buehne - haupt.right; + expect( + Math.abs(randLinks - randRechts), + `Hauptspalte nicht mittig: links=${randLinks}, rechts=${randRechts}`, + ).toBeLessThanOrEqual(2); +}); + +test("Anmelden-Zugang erreicht das 40-px-Beruehrziel", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/"); + const anmelden = page.getByRole("banner").getByRole("link", { name: "Anmelden" }); + await expect(anmelden).toBeVisible(); + const box = await rechteck(anmelden); + + // Basisdesign v2.0 §2.3, und ausdruecklich nur fuer DIESES eine Bedienelement: + // den `Anmelden`-Link, also `.app-login-link`. Getragen wird seine Hoehe + // allein von `min-height: 40px`; Polsterung, Zeilenhoehe und Rahmen ergeben + // von sich aus weniger. Gegenmutation (ausgefuehrt 27.08.2026): die Zeile + // entfernen und neu bauen — gemessen bleiben dann 38 px. + // + // NICHT gemessen ist `.app-logout`. Die Deklaration steht heute im + // gemeinsamen Block `.app-logout, .app-login-link`, aber daraus folgt nichts: + // den Block so aufzuteilen, dass nur `.app-login-link` die `min-height` + // behaelt, liess diesen Fall gruen (gemessen 27.08.2026). Der Grund ist + // aelter als der Block — `web-smoke` laeuft ABGEMELDET, der Abmelden-Knopf + // wird in diesem Job ueberhaupt nicht gerendert. Der einzige Job, der ihn + // ueberhaupt zu sehen bekommt, ist `auth-journey`; auch dort wird er nur + // GEKLICKT, nie vermessen. Wer das schliessen will, braucht eine + // Groessenmessung im angemeldeten Zustand — und damit eine laufende API. + expect(box.height, `Anmelden ist ${box.height} px hoch`).toBeGreaterThanOrEqual(40); +}); + +test("Wochenwechsel erreicht das 40-px-Beruehrziel", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + // Dieselbe Route, die unten schon serverseitig gerendert wird: die + // Wochennavigation steht dort OHNE laufende API, weil ihr Modell in + // `lib/wochennavigation.ts` allein aus dem Adressparameter entsteht. + await page.goto("/planung?weekKey=2026-W32"); + const naechste = page.getByTestId("wochennavigation-naechste"); + await expect(naechste).toBeVisible(); + const box = await rechteck(naechste); + + // Basisdesign v2.0 §2.3, und bis EYT-80 von NICHTS gemessen: die vier + // Geometriefaelle oben besuchen alle `/`, wo es keine Wochenleiste gibt. + // Getragen wird die Hoehe allein von `min-height: 2.5rem` in + // `.eyt-date-range__action`; Schriftgrad und Zeilenhoehe ergeben von sich aus + // weniger. Gegenmutation (ausgefuehrt 27.08.2026): die Zeile aus + // `globals.css` entfernen und neu bauen — gemessen bleiben dann 24 px, und + // diese Zusicherung wird rot. + // + // Warum „Nächste Woche" und nicht „Heute": alle drei Wege tragen dieselbe + // Klasse, aber „Heute" ist im Fehlerfall das einzige verbleibende Element und + // damit der Weg, der ohnehin am haeufigsten angefasst wird. Ein Blaetterweg + // faellt weg, sobald jemand die Klasse nur noch auf den Rueckweg legt. + expect(box.height, `Nächste Woche ist ${box.height} px hoch`).toBeGreaterThanOrEqual(40); +}); + /** * Jede Route rendert serverseitig ueberhaupt (EYT-107). * diff --git a/apps/web/test/a11y.test.tsx b/apps/web/test/a11y.test.tsx index 8896cf9..24f33d4 100644 --- a/apps/web/test/a11y.test.tsx +++ b/apps/web/test/a11y.test.tsx @@ -98,6 +98,57 @@ describe("Accessibility-Baseline der Shell (EYT-41)", () => { }); }); +/** + * EYT-80 — die Shell komponiert den geteilten Rahmen, ohne Politik abzugeben. + * + * Diese Zusicherungen sind der Gegenpol zur Extraktion: sie halten fest, dass + * NACH dem Umbau immer noch `apps/web` entscheidet, wer welche Punkte sieht. + * Ohne sie waere „AppShell benutzt jetzt @easytree/ui" eine Behauptung ueber + * den Import und keine ueber das Verhalten. + * + * Sie sind bewusst VOR dem Umbau geschrieben und muessen schon auf dem alten + * Stand gruen sein — eine Charakterisierung, kein roter TDD-Schritt. Das + * entbindet sie nicht von der Hausregel: jede benannte Gegenmutation steht + * unten am Fall und ist ausgefuehrt worden. + * + * ## Ein dritter Fall stand hier und ist ersatzlos entfallen + * + * Er verglich `anker.getAttribute("href")` mit `#${getByRole("main").id}`. + * Beide Seiten rendert das Primitive aus EINER Variablen (`mainId`) — die + * Gleichung gilt darum bauartbedingt, und keine Aenderung in `apps/web` kann + * sie brechen. Auch nicht die naheliegendste: `mainId="inhalt-feld"` an + * `` durchgereicht bewegt Anker UND Landmark gemeinsam. Gemessen + * 27.08.2026 blieb der Fall dabei gruen, waehrend `:80` (`href === + * "#hauptinhalt"`) und `:90` (`main.id === "hauptinhalt"`) beide rot wurden — + * die zwei Zusicherungen also, die den Wert unabhaengig voneinander auf das + * Literal festnageln. Dass die Kopplung fuer JEDE id haelt, misst + * `packages/ui/test/app-shell.test.tsx` am Primitive selbst. + */ +describe("AppShell — Komposition ohne Politikverlust (EYT-80)", () => { + it("stellt Marke und Navigation weiterhin in die banner-Landmark", () => { + // Gegenmutation (ausgefuehrt 27.08.2026): in + // `apps/web/components/app-shell.tsx` das Prop `navigation={…}` samt + // seinem `