Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34498c415a | ||
|
|
5a0d07444c |
@@ -0,0 +1,20 @@
|
|||||||
|
name: Core-Pflichttest-Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pflichttest-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.22"
|
||||||
|
- name: Gate bauen
|
||||||
|
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
|
||||||
|
- name: Geänderte Dateien gegen Pflichttest-Regel pruefen
|
||||||
|
run: |
|
||||||
|
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
web/*/node_modules/
|
|
||||||
web/*/.next/
|
|
||||||
|
|||||||
@@ -44,41 +44,20 @@ Keine Commits in dieser Session.
|
|||||||
Keine Änderungen ermittelbar.
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
## 2026-08-28 21:44 – 21:44 (0m)
|
## 2026-08-28 22:37 – 22:41 (3m)
|
||||||
**Beschreibung:** Claude Code Session
|
**Beschreibung:** Claude Code Session
|
||||||
**Projekt:** nexarch
|
**Projekt:** nexarch
|
||||||
|
|
||||||
### Commits
|
### Commits
|
||||||
Keine Commits in dieser Session.
|
- 5a0d074 QA-01: teststrategie-core (pflichttest-gate, testdb-isolationsnachweis, ci-workflow)
|
||||||
|
|
||||||
### Geänderte Dateien
|
### Geänderte Dateien
|
||||||
- .gitignore | 2 ++
|
- .gitea/workflows/pflichttest-gate.yml | 20 ++++++++++++++++++++
|
||||||
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
- cmd/pflichttestgate/main.go | 41 +++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
- docs/TESTSTRATEGIE-CORE.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
- internal/pflichttestgate/gate.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- internal/pflichttestgate/gate_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- internal/testdbisolation/isolation.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- internal/testdbisolation/isolation_test.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/index.ts | 25 +++++++++++++++++++++++++
|
|
||||||
- web/shl/package.json | 23 +++++++++++++++++++++++
|
|
||||||
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/tsconfig.json | 18 ++++++++++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-28 21:51 – 21:57 (5m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** nexarch
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- web/shl/package.json | 2 ++
|
|
||||||
- web/shl/vitest.config.ts | 8 ++++++++
|
|
||||||
- web/shl/vitest.setup.ts | 1 +
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-CORE.md Abschnitt 5.
|
||||||
|
// Aufruf: pflichttestgate <geänderte-Dateien-getrennt-durch-Zeilenumbruch-via-stdin>
|
||||||
|
// Beendet sich mit Exit-Code 1 und einer Liste der Verstöße, wenn ein sicherheitskritisches
|
||||||
|
// Package ohne begleitende Testdatei geändert wurde.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/pflichttestgate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var changedFiles []string
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line != "" {
|
||||||
|
changedFiles = append(changedFiles, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "pflichttestgate: Eingabe konnte nicht gelesen werden: %v\n", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
violations := pflichttestgate.CheckDiff(changedFiles)
|
||||||
|
if len(violations) == 0 {
|
||||||
|
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
|
||||||
|
for _, v := range violations {
|
||||||
|
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-CORE.md Abschnitt 3.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# NEXARCH Core – Teststrategie
|
||||||
|
|
||||||
|
Stand: 2026-08-28. Ticket: QA-01.
|
||||||
|
|
||||||
|
## 1. Warum dieses Dokument existiert
|
||||||
|
|
||||||
|
archivdms hatte zur Zeit der Bestandsaufnahme nur 2 `_test.go`-Dateien in ganz `internal/`, davon 0 für
|
||||||
|
`auth`/`storage`/`permissions.go`. archivmail testete 2 von 18 Modulen. Beide Lücken wurden erst im
|
||||||
|
Betrieb entdeckt, nicht vor dem Merge. NEXARCH Core übernimmt daraus einen Grundsatz: **Testpflicht für
|
||||||
|
Auth, Tenant-Scoping und Policy-Enforcement ist ein Merge-Gate, keine Nachrüstung.**
|
||||||
|
|
||||||
|
## 2. Testpyramide
|
||||||
|
|
||||||
|
| Ebene | Werkzeug | Umfang |
|
||||||
|
|---|---|---|
|
||||||
|
| Unit | `go test` (Standardbibliothek) | Einzelne Funktionen/Typen, keine externe Abhängigkeit (DB, Netzwerk) |
|
||||||
|
| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) | Repository-/Handler-Schicht, Tenant-Scoping, Policy-Enforcement |
|
||||||
|
| Vertragstests | `internal/contracttest` (siehe QA-07) | Öffentliche API-Verträge zwischen Core und Modulen |
|
||||||
|
| Last-/Leistungstests | `internal/loadtest` (siehe QA-08) | Mehrmodul-Last, JWT-Verifikation, Connection-Pooling |
|
||||||
|
| Extern | Core `QA-06` Penetrationstest | Vor Produktivbetrieb, außerhalb dieses CI-Gates |
|
||||||
|
|
||||||
|
Diese Kachel (QA-01) legt die Pflichtebenen fest und erzwingt sie technisch für die drei sicherheitskritischsten Bereiche; sie ersetzt nicht QA-07/QA-08, die eigene, bereits umgesetzte CI-Gates haben.
|
||||||
|
|
||||||
|
## 3. Pflichttests als Merge-Gate
|
||||||
|
|
||||||
|
Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert:
|
||||||
|
|
||||||
|
- **Auth** (`internal/auth/`, `internal/iam/`, Login/Session/2FA/SSO-Pakete)
|
||||||
|
- **Tenant-Scoping** (`internal/tenant/`, jede Repository-Schicht mit `tenant_id`-Filterung)
|
||||||
|
- **Policy-Enforcement** (`internal/rbac/`, `internal/policy/`, jede Autorisierungsprüfung)
|
||||||
|
|
||||||
|
Regel: **jede geänderte `.go`-Datei in einem dieser Bereiche muss von einer geänderten oder neuen
|
||||||
|
`_test.go`-Datei im selben Package begleitet sein.** Das CI-Gate (Abschnitt 5) prüft das automatisiert
|
||||||
|
und blockiert den Merge, wenn die Regel verletzt ist — analog zum bereits etablierten Sprintf-Verbot für
|
||||||
|
SQL (siehe `SICHERHEITSKONZEPT.md`), nur als technisch erzwungene statt nur dokumentierte Regel.
|
||||||
|
|
||||||
|
Diese Regel gilt projektweit für alle sieben Boards, nicht nur Core — siehe die entsprechenden
|
||||||
|
Akzeptanzkriterien in den `QA-01`-Tickets von DMS, Mail, Archive, Workflow, AI, Connect
|
||||||
|
(`SICHERHEITSKONZEPT.md` Abschnitt 11, Punkt 8, 2026-08-28 geklärt).
|
||||||
|
|
||||||
|
## 4. Testdatenbank-Strategie
|
||||||
|
|
||||||
|
Isolation zwischen parallelen Testläufen ist die zentrale Lehre aus dem bisherigen Testbetrieb
|
||||||
|
(siehe Projekt-Testinfrastruktur): mehrere Go-Testpakete teilen sich dieselbe physische PostgreSQL-Instanz
|
||||||
|
auf dem Testhost, aber jedes Paket braucht einen isolierten Datenbestand.
|
||||||
|
|
||||||
|
- **Rolle `nexarch_test`**: `CREATEDB`, kein Superuser, einmalig eingerichtet über `scripts/setup-test-env.sh`.
|
||||||
|
- **Reset vor jedem Testlauf**: `scripts/reset-test-env.sh` droppt die geteilte `tenants`-Registry-Tabelle
|
||||||
|
und alle `tenant_*`-Datenbanken in der `postgres`-Wartungs-DB. Nötig, weil verschiedene Branches
|
||||||
|
unterschiedliche Registry-Schemata erwarten, aber dieselbe physische Instanz teilen.
|
||||||
|
- **`-p 1` ist Pflicht** für `go test ./...`, sobald mehrere Pakete gegen die geteilte Registry-Tabelle
|
||||||
|
testen (z. B. `internal/tenant` + `internal/migrate`). Ohne `-p 1` laufen Paket-Testbinaries parallel
|
||||||
|
gegen dieselbe physische PostgreSQL-Instanz, ihre Registry-Einträge/DBs kollidieren
|
||||||
|
(falsche Tenant-Zählungen, „database already exists"-Fehler).
|
||||||
|
- **Isolation innerhalb eines Testlaufs**: jeder Test, der eine Tenant-Datenbank braucht, provisioniert
|
||||||
|
seine eigene, eindeutig benannte `tenant_*`-DB über dieselbe Provisionierungs-Logik wie die
|
||||||
|
Anwendung selbst (`TEN-01`) und räumt sie in einem `t.Cleanup()` wieder ab — keine geteilten
|
||||||
|
Fixture-Datenbanken zwischen Testfällen.
|
||||||
|
|
||||||
|
Siehe `internal/testdbisolation/isolation_test.go` (dieses Ticket) für den automatisierten Nachweis,
|
||||||
|
dass zwei parallel laufende Tenant-Provisionierungen sich nicht gegenseitig sehen.
|
||||||
|
|
||||||
|
## 5. CI-Gate
|
||||||
|
|
||||||
|
`.gitea/workflows/pflichttest-gate.yml` führt `cmd/pflichttestgate` gegen den PR-Diff aus
|
||||||
|
(`git diff --name-only origin/<base>...HEAD`). Das Programm:
|
||||||
|
|
||||||
|
1. Filtert die geänderten Dateien auf die in Abschnitt 3 genannten Pfad-Muster.
|
||||||
|
2. Prüft je betroffenem Go-Package, ob mindestens eine `_test.go`-Datei desselben Packages ebenfalls
|
||||||
|
im Diff enthalten ist.
|
||||||
|
3. Beendet sich mit Exit-Code 1 und einer Liste der betroffenen Packages ohne Teständerung, wenn die
|
||||||
|
Regel verletzt ist — der CI-Job schlägt dann fehl, der Merge ist blockiert.
|
||||||
|
|
||||||
|
Negativtest des Gates selbst: `internal/pflichttestgate/gate_test.go` enthält einen Testfall, der einen
|
||||||
|
Diff mit geänderter `internal/auth/login.go` ohne begleitende Testdatei simuliert und erwartet, dass das
|
||||||
|
Gate das als Verstoß erkennt (Prüfung 1 dieses Tickets).
|
||||||
|
|
||||||
|
## 6. Dokumentation & Gegenlesen
|
||||||
|
|
||||||
|
Dieses Dokument ist von einer zweiten Person gegenzulesen, bevor die Kachel als abgeschlossen gilt
|
||||||
|
(Akzeptanzkriterium 3 / Prüfung 3). Fund/Freigabe wird im Pull Request vermerkt.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
|
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-CORE.md Abschnitt 3 festgelegte Regel:
|
||||||
|
// jede geänderte Go-Datei in einem sicherheitskritischen Bereich (Auth, Tenant-Scoping, Policy)
|
||||||
|
// muss von einer geänderten oder neuen _test.go-Datei im selben Package begleitet sein.
|
||||||
|
package pflichttestgate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sensitivePathPatterns beschreibt die Bereiche aus TESTSTRATEGIE-CORE.md Abschnitt 3.
|
||||||
|
var sensitivePathPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(^|/)internal/auth/`),
|
||||||
|
regexp.MustCompile(`(^|/)internal/iam/`),
|
||||||
|
regexp.MustCompile(`(^|/)internal/tenant/`),
|
||||||
|
regexp.MustCompile(`(^|/)internal/rbac/`),
|
||||||
|
regexp.MustCompile(`(^|/)internal/policy/`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Violation beschreibt ein Package mit sicherheitskritischer Änderung ohne begleitende Testdatei.
|
||||||
|
type Violation struct {
|
||||||
|
Package string
|
||||||
|
ChangedFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSensitive meldet, ob eine geänderte Datei in einen der Pflichttest-Bereiche fällt.
|
||||||
|
func isSensitive(file string) bool {
|
||||||
|
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, re := range sensitivePathPatterns {
|
||||||
|
if re.MatchString(file) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDiff prüft eine Liste geänderter Dateipfade (z. B. aus `git diff --name-only`) gegen die
|
||||||
|
// Pflichttest-Regel und gibt für jedes betroffene Package ohne begleitende Testdatei eine Violation
|
||||||
|
// zurück. Ein leeres Ergebnis bedeutet: Gate besteht.
|
||||||
|
func CheckDiff(changedFiles []string) []Violation {
|
||||||
|
sensitiveByPkg := map[string]string{} // package -> erste sensible Datei (fürs Reporting)
|
||||||
|
testTouchedPkgs := map[string]bool{}
|
||||||
|
|
||||||
|
for _, f := range changedFiles {
|
||||||
|
pkg := path.Dir(f)
|
||||||
|
if strings.HasSuffix(f, "_test.go") {
|
||||||
|
testTouchedPkgs[pkg] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isSensitive(f) {
|
||||||
|
if _, seen := sensitiveByPkg[pkg]; !seen {
|
||||||
|
sensitiveByPkg[pkg] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var violations []Violation
|
||||||
|
for pkg, file := range sensitiveByPkg {
|
||||||
|
if !testTouchedPkgs[pkg] {
|
||||||
|
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package pflichttestgate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Prüfung 1 (QA-01): CI-Lauf mit absichtlich fehlendem Pflichttest schlägt kontrolliert fehl —
|
||||||
|
// Negativtest des Gates selbst.
|
||||||
|
func TestCheckDiff_BlocksAuthChangeWithoutTest(t *testing.T) {
|
||||||
|
changed := []string{
|
||||||
|
"internal/auth/login.go",
|
||||||
|
"internal/apiserver/handler.go", // unkritisch, keine Testpflicht
|
||||||
|
}
|
||||||
|
|
||||||
|
violations := CheckDiff(changed)
|
||||||
|
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 Verstoß, bekommen %d: %+v", len(violations), violations)
|
||||||
|
}
|
||||||
|
if violations[0].Package != "internal/auth" {
|
||||||
|
t.Errorf("erwartetes Package internal/auth, bekommen %q", violations[0].Package)
|
||||||
|
}
|
||||||
|
if violations[0].ChangedFile != "internal/auth/login.go" {
|
||||||
|
t.Errorf("erwartete Datei internal/auth/login.go, bekommen %q", violations[0].ChangedFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckDiff_PassesWhenTestFileAccompaniesChange(t *testing.T) {
|
||||||
|
changed := []string{
|
||||||
|
"internal/auth/login.go",
|
||||||
|
"internal/auth/login_test.go",
|
||||||
|
}
|
||||||
|
|
||||||
|
violations := CheckDiff(changed)
|
||||||
|
|
||||||
|
if len(violations) != 0 {
|
||||||
|
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckDiff_PassesForNewTestFileEvenWithoutSourceChange(t *testing.T) {
|
||||||
|
// Nachtraeglich ergaenzter Test fuer bestehenden Code ist erlaubt/erwuenscht.
|
||||||
|
changed := []string{"internal/tenant/registry_test.go"}
|
||||||
|
|
||||||
|
violations := CheckDiff(changed)
|
||||||
|
|
||||||
|
if len(violations) != 0 {
|
||||||
|
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckDiff_IgnoresUnrelatedPackages(t *testing.T) {
|
||||||
|
changed := []string{"internal/config/config.go", "internal/db/db.go"}
|
||||||
|
|
||||||
|
violations := CheckDiff(changed)
|
||||||
|
|
||||||
|
if len(violations) != 0 {
|
||||||
|
t.Fatalf("erwartet keinen Verstoß fuer unkritische Pakete, bekommen %+v", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckDiff_CoversAllSensitiveAreas(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"internal/auth/session.go",
|
||||||
|
"internal/iam/user.go",
|
||||||
|
"internal/tenant/provisioner.go",
|
||||||
|
"internal/rbac/enforcer.go",
|
||||||
|
"internal/policy/decision.go",
|
||||||
|
}
|
||||||
|
for _, f := range cases {
|
||||||
|
violations := CheckDiff([]string{f})
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Errorf("Datei %q sollte als sensibel erkannt werden, Verstöße: %+v", f, violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckDiff_IgnoresNonGoFiles(t *testing.T) {
|
||||||
|
changed := []string{"internal/auth/README.md"}
|
||||||
|
|
||||||
|
violations := CheckDiff(changed)
|
||||||
|
|
||||||
|
if len(violations) != 0 {
|
||||||
|
t.Fatalf("Nicht-Go-Dateien duerfen keine Testpflicht ausloesen, bekommen %+v", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Package testdbisolation demonstriert und prüft die in docs/TESTSTRATEGIE-CORE.md Abschnitt 4
|
||||||
|
// beschriebene Testdatenbank-Isolation: jeder Test, der eine Tenant-Datenbank braucht,
|
||||||
|
// provisioniert seine eigene, eindeutig benannte DB und räumt sie über Cleanup wieder ab —
|
||||||
|
// keine geteilten Fixture-Datenbanken zwischen parallelen Testläufen.
|
||||||
|
package testdbisolation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProvisionEphemeralDB legt unter dem gegebenen Admin-DSN eine neue, eindeutig benannte Datenbank an
|
||||||
|
// und liefert eine Cleanup-Funktion, die sie wieder droppt. adminDSN muss auf eine Wartungs-DB
|
||||||
|
// (z. B. "postgres") zeigen, name muss ein gültiger, bereits eindeutiger Bezeichner sein.
|
||||||
|
func ProvisionEphemeralDB(ctx context.Context, adminDSN, name string) (cleanup func(context.Context) error, err error) {
|
||||||
|
conn, err := pgx.Connect(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verbindung zur wartungs-db fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", pgx.Identifier{name}.Sanitize())); err != nil {
|
||||||
|
return nil, fmt.Errorf("datenbank %q konnte nicht angelegt werden: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = func(cleanupCtx context.Context) error {
|
||||||
|
adminConn, err := pgx.Connect(cleanupCtx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cleanup: verbindung fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer adminConn.Close(cleanupCtx)
|
||||||
|
_, err = adminConn.Exec(cleanupCtx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", pgx.Identifier{name}.Sanitize()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteMarker schreibt einen eindeutigen Beleg-Datensatz in die angegebene Datenbank (verbindet
|
||||||
|
// per dsnForDB), um im Test nachzuweisen, dass zwei parallele Provisionierungen sich nicht sehen.
|
||||||
|
func WriteMarker(ctx context.Context, dsn, table, value string) error {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, fmt.Sprintf(
|
||||||
|
"CREATE TABLE IF NOT EXISTS %s (value text primary key)", pgx.Identifier{table}.Sanitize(),
|
||||||
|
)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = pool.Exec(ctx, fmt.Sprintf("INSERT INTO %s (value) VALUES ($1)", pgx.Identifier{table}.Sanitize()), value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountMarkers zählt die Belegzeilen in der angegebenen Datenbank/Tabelle.
|
||||||
|
func CountMarkers(ctx context.Context, dsn, table string) (int, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, fmt.Sprintf(
|
||||||
|
"CREATE TABLE IF NOT EXISTS %s (value text primary key)", pgx.Identifier{table}.Sanitize(),
|
||||||
|
)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
err = pool.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s", pgx.Identifier{table}.Sanitize())).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package testdbisolation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParallelProvisioning_AreIsolated ist der automatisierte Nachweis für QA-01 Prüfung 2:
|
||||||
|
// Testdatenbank-Isolation zwischen parallelen Testläufen. Zwei Provisionierungen laufen
|
||||||
|
// gleichzeitig, jede schreibt einen Beleg-Datensatz in ihre eigene DB — keine darf den
|
||||||
|
// Beleg der jeweils anderen sehen.
|
||||||
|
//
|
||||||
|
// Braucht TEST_ADMIN_DSN (siehe Projekt-Testinfrastruktur), läuft auf dem Testhost, nicht lokal
|
||||||
|
// auf der Entwicklungsmaschine (siehe "no installs"-Regel) — wird ohne gesetzte Variable übersprungen.
|
||||||
|
func TestParallelProvisioning_AreIsolated(t *testing.T) {
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt — Test läuft nur auf dem Testhost, siehe docs/TESTSTRATEGIE-CORE.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
const numParallel = 2
|
||||||
|
dbNames := make([]string, numParallel)
|
||||||
|
for i := range dbNames {
|
||||||
|
dbNames[i] = fmt.Sprintf("qa01_isolation_%d_%d", time.Now().UnixNano(), i)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make([]error, numParallel)
|
||||||
|
cleanups := make([]func(context.Context) error, numParallel)
|
||||||
|
|
||||||
|
for i, name := range dbNames {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, dbName string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
cleanup, err := ProvisionEphemeralDB(ctx, adminDSN, dbName)
|
||||||
|
if err != nil {
|
||||||
|
errs[idx] = fmt.Errorf("provisionierung %d fehlgeschlagen: %w", idx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanups[idx] = cleanup
|
||||||
|
|
||||||
|
dbDSN := dsnForDB(adminDSN, dbName)
|
||||||
|
// Jede Goroutine schreibt einen für sie eindeutigen Beleg.
|
||||||
|
if err := WriteMarker(ctx, dbDSN, "isolation_marker", fmt.Sprintf("marker-von-goroutine-%d", idx)); err != nil {
|
||||||
|
errs[idx] = fmt.Errorf("markierung %d fehlgeschlagen: %w", idx, err)
|
||||||
|
}
|
||||||
|
}(i, name)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
for _, cleanup := range cleanups {
|
||||||
|
if cleanup != nil {
|
||||||
|
_ = cleanup(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("goroutine %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolationsnachweis: jede DB enthält genau ihren eigenen Beleg (1), keinen fremden.
|
||||||
|
for i, name := range dbNames {
|
||||||
|
dbDSN := dsnForDB(adminDSN, name)
|
||||||
|
count, err := CountMarkers(ctx, dbDSN, "isolation_marker")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zaehlung in db %d fehlgeschlagen: %v", i, err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("db %d (%s): erwartet genau 1 eigenen Beleg, gefunden %d — Isolation verletzt", i, name, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsnForDB ersetzt den Datenbanknamen im Wartungs-DSN durch den gegebenen Namen.
|
||||||
|
func dsnForDB(adminDSN, dbName string) string {
|
||||||
|
idx := strings.LastIndex(adminDSN, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
return adminDSN
|
||||||
|
}
|
||||||
|
base := adminDSN[:idx+1]
|
||||||
|
rest := adminDSN[idx+1:]
|
||||||
|
if q := strings.Index(rest, "?"); q != -1 {
|
||||||
|
return base + dbName + rest[q:]
|
||||||
|
}
|
||||||
|
return base + dbName
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
|
|
||||||
|
|
||||||
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
|
|
||||||
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
|
|
||||||
|
|
||||||
## Enthält
|
|
||||||
|
|
||||||
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
|
|
||||||
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
|
|
||||||
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
|
|
||||||
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
|
|
||||||
|
|
||||||
## Verwendung in einem Modul-Frontend
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<I18nProvider initialLocale="de">
|
|
||||||
<ToastProvider>
|
|
||||||
<Shell modules={[]} tenantLabel="Mandant XY">
|
|
||||||
{children}
|
|
||||||
</Shell>
|
|
||||||
</ToastProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bekannter offener Punkt
|
|
||||||
|
|
||||||
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm test
|
|
||||||
npm run typecheck
|
|
||||||
```
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { Dialog } from "../components/Dialog";
|
|
||||||
import { I18nProvider } from "../i18n/i18n";
|
|
||||||
|
|
||||||
function renderDialog(onClose: () => void) {
|
|
||||||
return render(
|
|
||||||
<I18nProvider>
|
|
||||||
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
|
|
||||||
<button type="button">Erste Aktion</button>
|
|
||||||
<button type="button">Zweite Aktion</button>
|
|
||||||
</Dialog>
|
|
||||||
</I18nProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Dialog: Tastaturbedienung", () => {
|
|
||||||
it("schließt sich bei ESC", () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
renderDialog(onClose);
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
|
|
||||||
renderDialog(vi.fn());
|
|
||||||
const closeButton = screen.getByRole("button", { name: /schließen/i });
|
|
||||||
expect(document.activeElement).toBe(closeButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
|
|
||||||
renderDialog(vi.fn());
|
|
||||||
const dialog = screen.getByRole("dialog");
|
|
||||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
|
||||||
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { colorTokens } from "../tokens/tokens";
|
|
||||||
|
|
||||||
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
|
|
||||||
function relLuminance(hex: string): number {
|
|
||||||
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
||||||
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contrastRatio(a: string, b: string): number {
|
|
||||||
const l1 = relLuminance(a);
|
|
||||||
const l2 = relLuminance(b);
|
|
||||||
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
|
||||||
return (lighter + 0.05) / (darker + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
|
|
||||||
for (const scheme of ["light", "dark"] as const) {
|
|
||||||
const c = colorTokens[scheme];
|
|
||||||
|
|
||||||
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR =
|
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
||||||
|
|
||||||
export interface DialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
titleId: string;
|
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
|
||||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
||||||
|
|
||||||
const node = dialogRef.current;
|
|
||||||
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
||||||
focusables?.[0]?.focus();
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "Tab" || !node) return;
|
|
||||||
|
|
||||||
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
|
||||||
if (items.length === 0) return;
|
|
||||||
const first = items[0];
|
|
||||||
const last = items[items.length - 1];
|
|
||||||
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
previouslyFocused.current?.focus();
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="shl-dialog-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.target === event.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={dialogRef}
|
|
||||||
className="shl-dialog"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby={titleId}
|
|
||||||
>
|
|
||||||
<div className="shl-dialog-header">
|
|
||||||
<h2 id={titleId}>{title}</h2>
|
|
||||||
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="shl-dialog-body">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
|
|
||||||
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
|
|
||||||
|
|
||||||
import { useId } from "react";
|
|
||||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
|
||||||
|
|
||||||
interface FieldWrapperProps {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
|
|
||||||
const inputId = useId();
|
|
||||||
const hintId = hint ? `${inputId}-hint` : undefined;
|
|
||||||
const errorId = error ? `${inputId}-error` : undefined;
|
|
||||||
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shl-field">
|
|
||||||
<label htmlFor={inputId}>{label}</label>
|
|
||||||
{children({ inputId, describedBy })}
|
|
||||||
{hint && (
|
|
||||||
<p id={hintId} className="shl-field-hint">
|
|
||||||
{hint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p id={errorId} className="shl-field-error" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextFieldProps
|
|
||||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
|
|
||||||
return (
|
|
||||||
<FieldWrapper label={label} error={error} hint={hint}>
|
|
||||||
{({ inputId, describedBy }) => (
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
{...inputProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FieldWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectFieldProps
|
|
||||||
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
|
|
||||||
return (
|
|
||||||
<FieldWrapper label={label} error={error} hint={hint}>
|
|
||||||
{({ inputId, describedBy }) => (
|
|
||||||
<select
|
|
||||||
id={inputId}
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
{...selectProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</FieldWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckboxFieldProps
|
|
||||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
|
|
||||||
const inputId = useId();
|
|
||||||
return (
|
|
||||||
<div className="shl-field shl-field-checkbox">
|
|
||||||
<input id={inputId} type="checkbox" {...inputProps} />
|
|
||||||
<label htmlFor={inputId}>{label}</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
|
|
||||||
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
import { useTheme } from "../theme/ThemeProvider";
|
|
||||||
|
|
||||||
export interface ModuleLink {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShellProps {
|
|
||||||
modules: ModuleLink[];
|
|
||||||
tenantLabel: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Shell({ modules, tenantLabel, children }: ShellProps) {
|
|
||||||
const { scheme, toggle } = useTheme();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shl-shell">
|
|
||||||
<a className="shl-skip-link" href="#shl-main-content">
|
|
||||||
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
|
|
||||||
</a>
|
|
||||||
<header className="shl-shell-header">
|
|
||||||
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
|
|
||||||
<ul>
|
|
||||||
{modules.map((mod) => (
|
|
||||||
<li key={mod.key}>
|
|
||||||
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
|
|
||||||
{mod.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div className="shl-shell-context">
|
|
||||||
<span className="shl-tenant-context">{tenantLabel}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
aria-label={
|
|
||||||
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{scheme === "light" ? "🌙" : "☀️"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
|
|
||||||
export interface TableColumn<Row> {
|
|
||||||
key: string;
|
|
||||||
header: string;
|
|
||||||
render: (row: Row) => ReactNode;
|
|
||||||
sortable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TableProps<Row> {
|
|
||||||
columns: TableColumn<Row>[];
|
|
||||||
rows: Row[];
|
|
||||||
rowKey: (row: Row) => string;
|
|
||||||
sortKey?: string;
|
|
||||||
sortDirection?: "asc" | "desc";
|
|
||||||
onSort?: (key: string) => void;
|
|
||||||
caption?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Table<Row>({
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
rowKey,
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
onSort,
|
|
||||||
caption,
|
|
||||||
}: TableProps<Row>) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table className="shl-table">
|
|
||||||
{caption && <caption>{caption}</caption>}
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{columns.map((column) => {
|
|
||||||
const isSorted = column.key === sortKey;
|
|
||||||
const ariaSort = column.sortable
|
|
||||||
? isSorted
|
|
||||||
? sortDirection === "asc"
|
|
||||||
? "ascending"
|
|
||||||
: "descending"
|
|
||||||
: "none"
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<th key={column.key} scope="col" aria-sort={ariaSort}>
|
|
||||||
{column.sortable ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSort?.(column.key)}
|
|
||||||
className="shl-table-sort-button"
|
|
||||||
>
|
|
||||||
{column.header}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
column.header
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
rows.map((row) => (
|
|
||||||
<tr key={rowKey(row)}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<td key={column.key}>{column.render(row)}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
|
|
||||||
export type ToastVariant = "info" | "success" | "danger" | "warning";
|
|
||||||
|
|
||||||
export interface ToastMessage {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
variant: ToastVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastContextValue {
|
|
||||||
toasts: ToastMessage[];
|
|
||||||
push: (text: string, variant?: ToastVariant) => void;
|
|
||||||
dismiss: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
|
||||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const push = useCallback((text: string, variant: ToastVariant = "info") => {
|
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
setToasts((current) => [...current, { id, text, variant }]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
|
|
||||||
{toasts.map((toast) => (
|
|
||||||
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
|
|
||||||
<span>{toast.text}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => dismiss(toast.id)}
|
|
||||||
aria-label={t("shl.toast.dismiss")}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToast(): ToastContextValue {
|
|
||||||
const ctx = useContext(ToastContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
|
|
||||||
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
|
|
||||||
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
|
|
||||||
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
|
|
||||||
|
|
||||||
import { createContext, useContext, useMemo, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export type Locale = "de" | "en";
|
|
||||||
|
|
||||||
type MessageDict = Record<string, string>;
|
|
||||||
type MessageBundle = Record<Locale, MessageDict>;
|
|
||||||
|
|
||||||
const baseMessages: MessageBundle = {
|
|
||||||
de: {
|
|
||||||
"shl.dialog.close": "Schließen",
|
|
||||||
"shl.toast.dismiss": "Meldung schließen",
|
|
||||||
"shl.table.noRows": "Keine Einträge vorhanden",
|
|
||||||
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
|
|
||||||
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
"shl.dialog.close": "Close",
|
|
||||||
"shl.toast.dismiss": "Dismiss message",
|
|
||||||
"shl.table.noRows": "No entries",
|
|
||||||
"shl.theme.toggleToLight": "Switch to light theme",
|
|
||||||
"shl.theme.toggleToDark": "Switch to dark theme",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
|
|
||||||
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
|
|
||||||
|
|
||||||
export function registerMessages(locale: Locale, messages: MessageDict): void {
|
|
||||||
registry[locale] = { ...registry[locale], ...messages };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface I18nContextValue {
|
|
||||||
locale: Locale;
|
|
||||||
setLocale: (locale: Locale) => void;
|
|
||||||
t: (key: string, fallback?: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
|
||||||
|
|
||||||
export function I18nProvider({
|
|
||||||
initialLocale = "de",
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
initialLocale?: Locale;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const [locale, setLocale] = useState<Locale>(initialLocale);
|
|
||||||
|
|
||||||
const value = useMemo<I18nContextValue>(
|
|
||||||
() => ({
|
|
||||||
locale,
|
|
||||||
setLocale,
|
|
||||||
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
|
|
||||||
}),
|
|
||||||
[locale],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useI18n(): I18nContextValue {
|
|
||||||
const ctx = useContext(I18nContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
|
|
||||||
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
|
|
||||||
|
|
||||||
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
|
|
||||||
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
|
|
||||||
|
|
||||||
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
|
|
||||||
|
|
||||||
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
|
|
||||||
export type { Locale } from "./i18n/i18n";
|
|
||||||
|
|
||||||
export { Shell } from "./components/Shell";
|
|
||||||
export type { ShellProps, ModuleLink } from "./components/Shell";
|
|
||||||
|
|
||||||
export { Dialog } from "./components/Dialog";
|
|
||||||
export type { DialogProps } from "./components/Dialog";
|
|
||||||
|
|
||||||
export { Table } from "./components/Table";
|
|
||||||
export type { TableProps, TableColumn } from "./components/Table";
|
|
||||||
|
|
||||||
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
|
|
||||||
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
|
|
||||||
|
|
||||||
export { ToastProvider, useToast } from "./components/Toast";
|
|
||||||
export type { ToastMessage, ToastVariant } from "./components/Toast";
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@nexarch/shl",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
|
|
||||||
"main": "index.ts",
|
|
||||||
"types": "index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@testing-library/jest-dom": "^6.4.8",
|
|
||||||
"@testing-library/react": "^16.0.0",
|
|
||||||
"@types/react": "18.3.3",
|
|
||||||
"@types/react-dom": "18.3.0",
|
|
||||||
"jsdom": "^24.1.1",
|
|
||||||
"typescript": "5.5.3",
|
|
||||||
"vitest": "^2.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
|
|
||||||
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "nexarch-shl-theme";
|
|
||||||
|
|
||||||
interface ThemeContextValue {
|
|
||||||
scheme: ColorScheme;
|
|
||||||
setScheme: (scheme: ColorScheme) => void;
|
|
||||||
toggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
function readStoredScheme(): ColorScheme | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
return stored === "light" || stored === "dark" ? stored : null;
|
|
||||||
} catch {
|
|
||||||
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function systemPrefersDark(): boolean {
|
|
||||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [scheme, setSchemeState] = useState<ColorScheme>("light");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = readStoredScheme();
|
|
||||||
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setScheme = useCallback((next: ColorScheme) => {
|
|
||||||
setSchemeState(next);
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, next);
|
|
||||||
} catch {
|
|
||||||
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
|
||||||
setScheme(scheme === "light" ? "dark" : "light");
|
|
||||||
}, [scheme, setScheme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const vars = cssVariables(scheme);
|
|
||||||
const root = document.documentElement;
|
|
||||||
for (const [key, value] of Object.entries(vars)) {
|
|
||||||
root.style.setProperty(key, value);
|
|
||||||
}
|
|
||||||
root.dataset.shlTheme = scheme;
|
|
||||||
}, [scheme]);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
|
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
|
||||||
const ctx = useContext(ThemeContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currentColors(scheme: ColorScheme) {
|
|
||||||
return colorTokens[scheme];
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
|
|
||||||
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
|
|
||||||
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
|
|
||||||
|
|
||||||
export type ColorScheme = "light" | "dark";
|
|
||||||
|
|
||||||
export interface ColorTokens {
|
|
||||||
background: string;
|
|
||||||
surface: string;
|
|
||||||
surfaceRaised: string;
|
|
||||||
border: string;
|
|
||||||
textPrimary: string;
|
|
||||||
textSecondary: string;
|
|
||||||
accent: string;
|
|
||||||
accentContrast: string;
|
|
||||||
danger: string;
|
|
||||||
dangerContrast: string;
|
|
||||||
success: string;
|
|
||||||
warning: string;
|
|
||||||
focusRing: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
|
|
||||||
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
|
|
||||||
export const colorTokens: Record<ColorScheme, ColorTokens> = {
|
|
||||||
light: {
|
|
||||||
background: "#FFFFFF",
|
|
||||||
surface: "#F5F6F8",
|
|
||||||
surfaceRaised: "#FFFFFF",
|
|
||||||
border: "#D7DBE0",
|
|
||||||
textPrimary: "#14181F",
|
|
||||||
textSecondary: "#4B5563",
|
|
||||||
accent: "#1D4ED8",
|
|
||||||
accentContrast: "#FFFFFF",
|
|
||||||
danger: "#B91C1C",
|
|
||||||
dangerContrast: "#FFFFFF",
|
|
||||||
success: "#15803D",
|
|
||||||
warning: "#B45309",
|
|
||||||
focusRing: "#1D4ED8",
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
background: "#0F1115",
|
|
||||||
surface: "#181B21",
|
|
||||||
surfaceRaised: "#20242C",
|
|
||||||
border: "#333944",
|
|
||||||
textPrimary: "#F2F4F7",
|
|
||||||
textSecondary: "#B4BAC4",
|
|
||||||
accent: "#5B8DEF",
|
|
||||||
accentContrast: "#0F1115",
|
|
||||||
danger: "#F87171",
|
|
||||||
dangerContrast: "#0F1115",
|
|
||||||
success: "#4ADE80",
|
|
||||||
warning: "#FBBF24",
|
|
||||||
focusRing: "#5B8DEF",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const spacing = {
|
|
||||||
xs: "4px",
|
|
||||||
sm: "8px",
|
|
||||||
md: "16px",
|
|
||||||
lg: "24px",
|
|
||||||
xl: "32px",
|
|
||||||
xxl: "48px",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const breakpoints = {
|
|
||||||
mobile: "0px",
|
|
||||||
tablet: "768px",
|
|
||||||
desktop: "1200px",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const typography = {
|
|
||||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
|
||||||
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
|
|
||||||
sizeSm: "13px",
|
|
||||||
sizeMd: "15px",
|
|
||||||
sizeLg: "18px",
|
|
||||||
sizeXl: "24px",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
weightRegular: 400,
|
|
||||||
weightMedium: 500,
|
|
||||||
weightBold: 600,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function cssVariables(scheme: ColorScheme): Record<string, string> {
|
|
||||||
const c = colorTokens[scheme];
|
|
||||||
const vars: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(c)) {
|
|
||||||
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(spacing)) {
|
|
||||||
vars[`--shl-spacing-${key}`] = value;
|
|
||||||
}
|
|
||||||
return vars;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"declaration": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true
|
|
||||||
},
|
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "jsdom",
|
|
||||||
setupFiles: ["./vitest.setup.ts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { afterEach } from "vitest";
|
|
||||||
import { cleanup } from "@testing-library/react";
|
|
||||||
|
|
||||||
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
|
|
||||||
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user