diff --git a/.gitignore b/.gitignore index ac00d6f..a5e59d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.log .env +web/*/node_modules/ +web/*/.next/ diff --git a/DEVLOG.md b/DEVLOG.md index 7178fe5..abf0c2d 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -44,3 +44,41 @@ Keine Commits in dieser Session. Keine Änderungen ermittelbar. --- +## 2026-08-28 21:44 – 21:44 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 2 ++ +- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ +- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++ +- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ +- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- 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 + + +--- diff --git a/web/shl/README.md b/web/shl/README.md new file mode 100644 index 0000000..86f7609 --- /dev/null +++ b/web/shl/README.md @@ -0,0 +1,45 @@ +# @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 ( + + + + + {children} + + + + + ); +} +``` + +## 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 +``` diff --git a/web/shl/__tests__/Dialog.test.tsx b/web/shl/__tests__/Dialog.test.tsx new file mode 100644 index 0000000..cde82cb --- /dev/null +++ b/web/shl/__tests__/Dialog.test.tsx @@ -0,0 +1,38 @@ +// 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( + + + Erste Aktion + Zweite Aktion + + , + ); +} + +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"); + }); +}); diff --git a/web/shl/__tests__/tokens.test.ts b/web/shl/__tests__/tokens.test.ts new file mode 100644 index 0000000..91e186f --- /dev/null +++ b/web/shl/__tests__/tokens.test.ts @@ -0,0 +1,39 @@ +// 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); + }); + } +}); diff --git a/web/shl/components/Dialog.tsx b/web/shl/components/Dialog.tsx new file mode 100644 index 0000000..4815b31 --- /dev/null +++ b/web/shl/components/Dialog.tsx @@ -0,0 +1,88 @@ +"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(null); + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + previouslyFocused.current = document.activeElement as HTMLElement | null; + + const node = dialogRef.current; + const focusables = node?.querySelectorAll(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(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 ( + { + if (event.target === event.currentTarget) onClose(); + }} + > + + + {title} + + × + + + {children} + + + ); +} diff --git a/web/shl/components/FormElements.tsx b/web/shl/components/FormElements.tsx new file mode 100644 index 0000000..12c312a --- /dev/null +++ b/web/shl/components/FormElements.tsx @@ -0,0 +1,98 @@ +// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes , +// 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 ( + + {label} + {children({ inputId, describedBy })} + {hint && ( + + {hint} + + )} + {error && ( + + {error} + + )} + + ); +} + +export interface TextFieldProps + extends Omit, "id" | "aria-describedby"> { + label: string; + error?: string; + hint?: string; +} + +export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) { + return ( + + {({ inputId, describedBy }) => ( + + )} + + ); +} + +export interface SelectFieldProps + extends Omit, "id" | "aria-describedby"> { + label: string; + error?: string; + hint?: string; + children: ReactNode; +} + +export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) { + return ( + + {({ inputId, describedBy }) => ( + + {children} + + )} + + ); +} + +export interface CheckboxFieldProps + extends Omit, "id" | "type"> { + label: string; +} + +export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) { + const inputId = useId(); + return ( + + + {label} + + ); +} diff --git a/web/shl/components/Shell.tsx b/web/shl/components/Shell.tsx new file mode 100644 index 0000000..abd265a --- /dev/null +++ b/web/shl/components/Shell.tsx @@ -0,0 +1,62 @@ +"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 ( + + + {t("shl.shell.skipToContent", "Zum Inhalt springen")} + + + + + {modules.map((mod) => ( + + + {mod.label} + + + ))} + + + + {tenantLabel} + + {scheme === "light" ? "🌙" : "☀️"} + + + + + {children} + + + ); +} diff --git a/web/shl/components/Table.tsx b/web/shl/components/Table.tsx new file mode 100644 index 0000000..9e7b16f --- /dev/null +++ b/web/shl/components/Table.tsx @@ -0,0 +1,83 @@ +// Table-Basis-Komponente — SHL-01. WCAG: semantische , scope auf Kopfzellen, sortierbare Spalten per Tastatur. + +import type { ReactNode } from "react"; +import { useI18n } from "../i18n/i18n"; + +export interface TableColumn { + key: string; + header: string; + render: (row: Row) => ReactNode; + sortable?: boolean; +} + +export interface TableProps { + columns: TableColumn[]; + rows: Row[]; + rowKey: (row: Row) => string; + sortKey?: string; + sortDirection?: "asc" | "desc"; + onSort?: (key: string) => void; + caption?: string; +} + +export function Table({ + columns, + rows, + rowKey, + sortKey, + sortDirection, + onSort, + caption, +}: TableProps) { + const { t } = useI18n(); + + return ( + + {caption && {caption}} + + + {columns.map((column) => { + const isSorted = column.key === sortKey; + const ariaSort = column.sortable + ? isSorted + ? sortDirection === "asc" + ? "ascending" + : "descending" + : "none" + : undefined; + return ( + + {column.sortable ? ( + onSort?.(column.key)} + className="shl-table-sort-button" + > + {column.header} + + ) : ( + column.header + )} + + ); + })} + + + + {rows.length === 0 ? ( + + {t("shl.table.noRows")} + + ) : ( + rows.map((row) => ( + + {columns.map((column) => ( + {column.render(row)} + ))} + + )) + )} + + + ); +} diff --git a/web/shl/components/Toast.tsx b/web/shl/components/Toast.tsx new file mode 100644 index 0000000..29ec8fa --- /dev/null +++ b/web/shl/components/Toast.tsx @@ -0,0 +1,67 @@ +"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(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + 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 ( + + {children} + + {toasts.map((toast) => ( + + {toast.text} + dismiss(toast.id)} + aria-label={t("shl.toast.dismiss")} + > + × + + + ))} + + + ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error("useToast muss innerhalb von aufgerufen werden"); + } + return ctx; +} diff --git a/web/shl/i18n/i18n.tsx b/web/shl/i18n/i18n.tsx new file mode 100644 index 0000000..1fd4554 --- /dev/null +++ b/web/shl/i18n/i18n.tsx @@ -0,0 +1,75 @@ +"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; +type MessageBundle = Record; + +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(null); + +export function I18nProvider({ + initialLocale = "de", + children, +}: { + initialLocale?: Locale; + children: ReactNode; +}) { + const [locale, setLocale] = useState(initialLocale); + + const value = useMemo( + () => ({ + locale, + setLocale, + t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key, + }), + [locale], + ); + + return {children}; +} + +export function useI18n(): I18nContextValue { + const ctx = useContext(I18nContext); + if (!ctx) { + throw new Error("useI18n muss innerhalb von aufgerufen werden"); + } + return ctx; +} diff --git a/web/shl/index.ts b/web/shl/index.ts new file mode 100644 index 0000000..4af544e --- /dev/null +++ b/web/shl/index.ts @@ -0,0 +1,25 @@ +// Ö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"; diff --git a/web/shl/package.json b/web/shl/package.json new file mode 100644 index 0000000..4216b43 --- /dev/null +++ b/web/shl/package.json @@ -0,0 +1,25 @@ +{ + "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" + } +} diff --git a/web/shl/theme/ThemeProvider.tsx b/web/shl/theme/ThemeProvider.tsx new file mode 100644 index 0000000..ebb53bb --- /dev/null +++ b/web/shl/theme/ThemeProvider.tsx @@ -0,0 +1,81 @@ +"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(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("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 {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme muss innerhalb von aufgerufen werden"); + } + return ctx; +} + +export function currentColors(scheme: ColorScheme) { + return colorTokens[scheme]; +} diff --git a/web/shl/tokens/tokens.ts b/web/shl/tokens/tokens.ts new file mode 100644 index 0000000..6da4305 --- /dev/null +++ b/web/shl/tokens/tokens.ts @@ -0,0 +1,96 @@ +// 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 = { + 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 { + const c = colorTokens[scheme]; + const vars: Record = {}; + 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; +} diff --git a/web/shl/tsconfig.json b/web/shl/tsconfig.json new file mode 100644 index 0000000..cfe5f6c --- /dev/null +++ b/web/shl/tsconfig.json @@ -0,0 +1,18 @@ +{ + "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"] +} diff --git a/web/shl/vitest.config.ts b/web/shl/vitest.config.ts new file mode 100644 index 0000000..262024c --- /dev/null +++ b/web/shl/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + }, +}); diff --git a/web/shl/vitest.setup.ts b/web/shl/vitest.setup.ts new file mode 100644 index 0000000..fe6f07b --- /dev/null +++ b/web/shl/vitest.setup.ts @@ -0,0 +1,9 @@ +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 /-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet. +afterEach(() => { + cleanup(); +});
+ {hint} +
+ {error} +