Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d3e7bff
test(arch): EYT-80 packages/ui bekommt eine ausfuehrbare Importgrenze
BenPerro Aug 27, 2026
f8f2c05
test(arch): EYT-80 Geltungsbereich der Importgrenze festnageln und Au…
BenPerro Aug 27, 2026
4a6429e
feat(ui): EYT-80 domaenenfreier AppShell mit einem Anker statt zwei L…
BenPerro Aug 27, 2026
c361d59
fix(ui): EYT-80 AppShell-Slots fail-closed und die Zusagen auf das Ge…
BenPerro Aug 27, 2026
50df17d
test(ui): EYT-80 die Slotgrenze und die Markenhuelle festnageln
BenPerro Aug 27, 2026
5e3314a
test(web): EYT-80 Shellverhalten vor der Extraktion festschreiben
BenPerro Aug 27, 2026
1265573
refactor(web): EYT-80 die Shell komponiert den geteilten Rahmen
BenPerro Aug 27, 2026
7199357
docs(ui): EYT-80 die Klassennamen und Messwerte in den Kommentaren na…
BenPerro Aug 27, 2026
66de873
test(web): EYT-80 Sichtbarkeit und Geometrie des Rahmens im Browser m…
BenPerro Aug 27, 2026
c4b8245
docs(arch): EYT-80 die Dateizahl im Regelkommentar als Momentaufnahme…
BenPerro Aug 27, 2026
3e74d43
docs(arch): EYT-80 die Dateizahl aus der Allowlist-Begruendung entfernen
BenPerro Aug 27, 2026
aeec68f
docs(arch): EYT-80 den Bezug im Regelkommentar richtigstellen
BenPerro Aug 27, 2026
4cf9cad
test(web): EYT-80 die Waechter des Rahmens schliessen die gemessenen …
BenPerro Aug 27, 2026
91af601
feat(ui): EYT-80 domaenenfreier DateRangeControl mit injiziertem Link…
BenPerro Aug 27, 2026
15de359
refactor(web): EYT-80 die Wochennavigation komponiert DateRangeControl
BenPerro Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/api/test/architecture-red-case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
22 changes: 22 additions & 0 deletions apps/api/test/architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
77 changes: 77 additions & 0 deletions apps/api/test/architecture/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 /// <reference types="${ref.specifier}" /> 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[]): {
Expand Down
38 changes: 23 additions & 15 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -144,15 +144,15 @@ body {
font-weight: 700;
}

.app-main {
.eyt-app-shell__main {
flex: 1;
width: 100%;
max-width: 60rem;
margin: 0 auto;
padding: var(--space);
}

.app-footer {
.eyt-app-shell__footer {
padding: var(--space);
border-top: 1px solid var(--color-border);
font-size: 0.875rem;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -615,15 +623,15 @@ 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,
auch wenn die Wege in die naechste Zeile rutschen. */
flex-basis: 100%;
}

.wochennavigation__zeitraum {
.eyt-date-range__detail {
margin: 0;
flex-basis: 100%;
color: var(--eyt-text-secondary);
Expand All @@ -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;
Expand Down
Loading
Loading