Responsiv machen und Barrierefreiheit nach WCAG 2.1 AA herstellen
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s

Rechtsrahmen: § 13 Abs. 3 LBGG SH i. V. m. §§ 3 und 4 BITV 2.0. Eine
Erklaerung "nicht barrierefrei" ersetzt die Herstellungspflicht nicht,
sie dokumentiert den Stand nur voruebergehend.

Farben, alle Werte gegen die WCAG-Formel nachgerechnet:
- Sekundaertext 4.40:1 auf muted-Flaechen -> 4.55:1
- Rahmen von Eingabefeldern 1.24:1 -> 3:1 (WCAG 1.4.11); dekorative
  Trenner bleiben unveraendert
- Badge-Farben scheiterten auf der eigenen 10-Prozent-Flaeche
  (brand 4.20, success 4.18, destructive 3.88) -> jeweils ueber 4.5
- Warnfarbe im Admin 3.11:1 -> amber-700 mit 4.90:1

Fokus war faktisch unsichtbar: Der Ring wurde durchgaengig mit halber
Deckkraft gezeichnet (ring-ring/50), effektiv rund 1.5:1. Jetzt volle
Deckkraft in Markenfarbe, 12 Dateien betroffen.

Weiter: Sprungmarke zum Inhalt auf allen Seiten, Suchfelder beschriftet,
Druck-Schaltflaechen hatten als reine Icon-Buttons gar keinen
zugaenglichen Namen, Liste als echte Liste ausgezeichnet, doppelter Link
je Zeile entfernt, Trefferzahl als Live-Region.

Responsiv: Aussenabstaende und Kopfzeilen ab 320 Pixel, Listenzeilen
brechen um.

Neu nach § 4 BITV 2.0: Seiten fuer Leichte Sprache und Gebaerdensprache,
im Admin pflegbar. Leer gelassen und damit noch nicht online - die
Inhalte kommen vom Amt. In der Erklaerung als bekannte Luecke benannt.

Geprueft mit axe-core (WCAG 2.1 A und AA) ueber alle oeffentlichen Seiten
und den Adminbereich, hell und dunkel, dazu Tastaturbedienung, 200
Prozent Vergroesserung und 320 Pixel Breite: keine Verstoesse, kein
waagerechter Ueberlauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Weapie 2026-09-08 15:44:30 +02:00
parent c951a4123a
commit 3633e8a4cf
27 changed files with 272 additions and 117 deletions

View File

@ -217,7 +217,7 @@ export default function AdminDashboard() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-6"> <div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Brand /> <Brand />
<Badge variant="secondary">Admin</Badge> <Badge variant="secondary">Admin</Badge>
@ -314,7 +314,7 @@ export default function AdminDashboard() {
</div> </div>
</header> </header>
<main className="mx-auto max-w-7xl px-6 py-8"> <main id="inhalt" tabIndex={-1} className="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
@ -326,7 +326,7 @@ export default function AdminDashboard() {
{incompleteCount > 0 && ( {incompleteCount > 0 && (
<> <>
{" · "} {" · "}
<span className="text-amber-600 dark:text-amber-500"> <span className="text-amber-700 dark:text-amber-400">
{incompleteCount} unvollständig {incompleteCount} unvollständig
</span> </span>
</> </>
@ -334,30 +334,40 @@ export default function AdminDashboard() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex w-full items-center gap-2 sm:w-auto">
<div className="relative flex-1 sm:w-64 sm:flex-none"> <div className="min-w-0 flex-1 sm:w-64 sm:flex-none">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> <label htmlFor="admin-suche" className="sr-only">
<Input Erklärungen nach Titel oder Verantwortlichem durchsuchen
placeholder="Suchen…" </label>
value={searchTerm} <div className="relative">
onChange={(e) => setSearchTerm(e.target.value)} <Search
className="px-9" aria-hidden="true"
/> className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
{searchTerm && ( />
<Button <Input
variant="ghost" id="admin-suche"
size="icon-sm" type="search"
onClick={() => setSearchTerm("")} placeholder="Suchen…"
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground" value={searchTerm}
> onChange={(e) => setSearchTerm(e.target.value)}
<X /> className="px-9"
<span className="sr-only">Suche zurücksetzen</span> />
</Button> {searchTerm && (
)} <Button
variant="ghost"
size="icon-sm"
onClick={() => setSearchTerm("")}
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
>
<X aria-hidden="true" />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</div>
</div> </div>
<Button onClick={handleAddNew}> <Button onClick={handleAddNew} className="shrink-0">
<Plus /> <Plus aria-hidden="true" />
Neu Neu
</Button> </Button>
</div> </div>
@ -448,7 +458,7 @@ export default function AdminDashboard() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Badge <Badge
variant="outline" variant="outline"
className="cursor-help border-amber-500/40 text-amber-600 dark:text-amber-500" className="cursor-help border-amber-500/40 text-amber-700 dark:text-amber-400"
> >
<TriangleAlert /> <TriangleAlert />
{missingByPolicy.get(policy.id)!.length} {missingByPolicy.get(policy.id)!.length}

View File

@ -55,7 +55,7 @@ export default function SignInPage() {
<ThemeToggle /> <ThemeToggle />
</div> </div>
<main className="flex flex-1 items-center justify-center px-6 pb-20"> <main id="inhalt" tabIndex={-1} className="flex flex-1 items-center justify-center px-4 pb-20 sm:px-6">
<div className="w-full max-w-sm space-y-6"> <div className="w-full max-w-sm space-y-6">
<div className="flex flex-col items-center gap-3 text-center"> <div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-brand text-brand-foreground"> <div className="flex size-11 items-center justify-center rounded-xl bg-brand text-brand-foreground">

View File

@ -129,7 +129,7 @@ export default async function PolicyPage({
<div className="flex min-h-screen flex-col bg-background"> <div className="flex min-h-screen flex-col bg-background">
<PublicHeader subtitle="Datenschutz" /> <PublicHeader subtitle="Datenschutz" />
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-8"> <main id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-4 text-muted-foreground"> <Button variant="ghost" size="sm" asChild className="-ml-2 mb-4 text-muted-foreground">
<Link href="/"> <Link href="/">
<ArrowLeft /> <ArrowLeft />

View File

@ -0,0 +1,10 @@
import { LegalPage, legalPageMetadata } from "@/components/legal-page"
// Inhalt kommt aus der Datenbank und wird im Admin gepflegt.
export const dynamic = "force-dynamic"
export const metadata = legalPageMetadata("signLanguage")
export default function Page() {
return <LegalPage page="signLanguage" />
}

View File

@ -17,16 +17,19 @@
--secondary: oklch(0.968 0.002 286); --secondary: oklch(0.968 0.002 286);
--secondary-foreground: oklch(0.21 0.006 286); --secondary-foreground: oklch(0.21 0.006 286);
--muted: oklch(0.968 0.002 286); --muted: oklch(0.968 0.002 286);
--muted-foreground: oklch(0.552 0.014 286); /* 4.55:1 auch auf muted-Flächen – vorher 0.552 und damit knapp darunter */
--muted-foreground: oklch(0.545 0.014 286);
--accent: oklch(0.968 0.002 286); --accent: oklch(0.968 0.002 286);
--accent-foreground: oklch(0.21 0.006 286); --accent-foreground: oklch(0.21 0.006 286);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.520 0.245 27.325); /* 4.5:1 auch auf 10%-Fläche im Badge */
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.596 0.145 163); --success: oklch(0.495 0.145 163); /* 4.5:1 auch auf 10%-Fläche im Badge */
--border: oklch(0.918 0.004 286); --border: oklch(0.918 0.004 286);
--input: oklch(0.918 0.004 286); /* Rahmen von Eingabefeldern: 3:1 nach WCAG 1.4.11, sonst ist das Feld
--ring: oklch(0.6 0.012 286); nicht als solches erkennbar. Dekorative Trenner nutzen --border. */
--brand: oklch(0.55 0.155 258); --input: oklch(0.620 0.004 286);
--ring: oklch(0.53 0.155 258); /* Fokus in Markenfarbe, 5.2:1 */
--brand: oklch(0.53 0.155 258); /* 4.5:1 auch auf 10%-Fläche im Badge */
--brand-foreground: oklch(0.985 0 0); --brand-foreground: oklch(0.985 0 0);
--chart-1: oklch(0.55 0.155 258); --chart-1: oklch(0.55 0.155 258);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
@ -58,12 +61,12 @@
--muted-foreground: oklch(0.712 0.013 286); --muted-foreground: oklch(0.712 0.013 286);
--accent: oklch(0.256 0.006 286); --accent: oklch(0.256 0.006 286);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.637 0.237 25.331); --destructive: oklch(0.670 0.237 25.331); /* 4.5:1 auf 20%-Fläche im Badge */
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.696 0.17 162.48); --success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(0.500 0.006 286); /* deckend statt transparent, 3:1 */
--ring: oklch(0.55 0.012 286); --ring: oklch(0.68 0.14 258); /* Fokus in Markenfarbe, 6.9:1 */
--brand: oklch(0.68 0.14 258); --brand: oklch(0.68 0.14 258);
--brand-foreground: oklch(0.14 0.004 286); --brand-foreground: oklch(0.14 0.004 286);
--chart-1: oklch(0.68 0.14 258); --chart-1: oklch(0.68 0.14 258);
@ -148,7 +151,7 @@
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring;
} }
body { body {

View File

@ -4,6 +4,7 @@ import localFont from "next/font/local"
import { AppConfigProvider } from "@/components/providers/app-config-provider" import { AppConfigProvider } from "@/components/providers/app-config-provider"
import { AuthProvider } from "@/components/providers/session-provider" import { AuthProvider } from "@/components/providers/session-provider"
import { appName } from "@/lib/app-config" import { appName } from "@/lib/app-config"
import { SkipLink } from "@/components/skip-link"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import "./globals.css" import "./globals.css"
@ -56,6 +57,7 @@ export default function RootLayout({
/> />
</head> </head>
<body className={inter.className}> <body className={inter.className}>
<SkipLink />
<AppConfigProvider appName={appName()}> <AppConfigProvider appName={appName()}>
<AuthProvider>{children}</AuthProvider> <AuthProvider>{children}</AuthProvider>
</AppConfigProvider> </AppConfigProvider>

View File

@ -0,0 +1,10 @@
import { LegalPage, legalPageMetadata } from "@/components/legal-page"
// Inhalt kommt aus der Datenbank und wird im Admin gepflegt.
export const dynamic = "force-dynamic"
export const metadata = legalPageMetadata("easyLanguage")
export default function Page() {
return <LegalPage page="easyLanguage" />
}

View File

@ -70,7 +70,7 @@ export function DefaultableField({
invalid={invalid} invalid={invalid}
/> />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Badge variant="outline" className="text-amber-600 dark:text-amber-500"> <Badge variant="outline" className="text-amber-700 dark:text-amber-400">
Weicht vom Standard ab Weicht vom Standard ab
</Badge> </Badge>
<Button <Button

View File

@ -27,7 +27,7 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
<div className="flex min-h-screen flex-col bg-background"> <div className="flex min-h-screen flex-col bg-background">
<PublicHeader subtitle={meta.title} /> <PublicHeader subtitle={meta.title} />
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-8"> <main id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
<h1 className="mb-6 text-2xl font-semibold tracking-tight"> <h1 className="mb-6 text-2xl font-semibold tracking-tight">
{meta.title} {meta.title}
</h1> </h1>

View File

@ -30,7 +30,7 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
}, [policies, searchTerm]) }, [policies, searchTerm])
return ( return (
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8"> <main id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
@ -41,25 +41,35 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
</p> </p>
</div> </div>
<div className="relative w-full sm:w-72"> <div className="w-full sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> <label htmlFor="suche" className="sr-only">
<Input Datenschutzerklärungen durchsuchen
placeholder="Suchen…" </label>
value={searchTerm} <div className="relative">
onChange={(e) => setSearchTerm(e.target.value)} <Search
className="px-9" aria-hidden="true"
/> className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
{searchTerm && ( />
<Button <Input
variant="ghost" id="suche"
size="icon-sm" type="search"
onClick={() => setSearchTerm("")} placeholder="Suchen…"
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground" value={searchTerm}
> onChange={(e) => setSearchTerm(e.target.value)}
<X /> className="px-9"
<span className="sr-only">Suche zurücksetzen</span> />
</Button> {searchTerm && (
)} <Button
variant="ghost"
size="icon-sm"
onClick={() => setSearchTerm("")}
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
>
<X aria-hidden="true" />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</div>
</div> </div>
</div> </div>
@ -102,60 +112,58 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
)} )}
</div> </div>
) : ( ) : (
<div className="divide-y overflow-hidden rounded-lg border bg-card"> <ul className="divide-y overflow-hidden rounded-lg border bg-card">
{filteredPolicies.map((policy) => ( {filteredPolicies.map((policy) => (
<div <li
key={policy.id} key={policy.id}
className="group flex items-center gap-3 px-3 transition-colors hover:bg-muted/40" className="group flex flex-wrap items-center gap-x-3 gap-y-1 px-3 transition-colors hover:bg-muted/40 sm:flex-nowrap"
> >
{/* Echter Link: teilbar, in neuem Tab zu öffnen, ohne JavaScript nutzbar */} {/* Echter Link: teilbar, in neuem Tab zu öffnen, ohne JavaScript
nutzbar. Der Pfeil gehört mit hinein, statt daneben ein
zweiter Link auf dasselbe Ziel zu stehen. */}
<Link <Link
href={`/erklaerung/${policy.slug}`} href={`/erklaerung/${policy.slug}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-md py-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" className="flex min-w-0 flex-1 items-center gap-3 rounded-md py-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
> >
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/10 text-brand"> <span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/10 text-brand"
>
<FileText className="size-4.5" /> <FileText className="size-4.5" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate font-medium"> <span className="block font-medium">{policy.title}</span>
{policy.title}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground"> <span className="mt-0.5 block truncate text-sm text-muted-foreground">
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]} {policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
</span> </span>
</span> </span>
<ChevronRight
aria-hidden="true"
className="hidden size-4 shrink-0 text-muted-foreground group-hover:text-foreground sm:block"
/>
</Link> </Link>
<div className="flex shrink-0 items-center gap-2 pr-1"> <div className="flex shrink-0 items-center gap-2 pb-3 sm:pb-0">
<span className="hidden text-sm text-muted-foreground sm:inline"> <span className="text-sm text-muted-foreground">
<span className="sr-only">Zuletzt geändert: </span>
{formatDate(policy.updatedAt)} {formatDate(policy.updatedAt)}
</span> </span>
<Separator orientation="vertical" className="hidden h-5 sm:block" /> <Separator orientation="vertical" className="h-5" />
<PrintPolicy policy={policy} size="icon-sm" variant="ghost" /> <PrintPolicy policy={policy} size="icon-sm" variant="ghost" />
<Button
variant="ghost"
size="icon-sm"
asChild
className="text-muted-foreground group-hover:text-foreground"
>
<Link href={`/erklaerung/${policy.slug}`}>
<ChevronRight />
<span className="sr-only">{policy.title} öffnen</span>
</Link>
</Button>
</div> </div>
</div> </li>
))} ))}
</div> </ul>
)} )}
{!isLoading && !error && filteredPolicies.length > 0 && ( {!isLoading && !error && filteredPolicies.length > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground"> <div className="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span> {/* Beim Filtern hört eine Vorlesesoftware sonst nicht, was passiert */}
<p role="status" aria-live="polite">
{filteredPolicies.length}{" "} {filteredPolicies.length}{" "}
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"} {filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
{searchTerm && ` von ${policies.length}`} {searchTerm && ` von ${policies.length}`}
</span> </p>
<Badge variant="outline">DSGVO-konform</Badge> <Badge variant="outline">DSGVO-konform</Badge>
</div> </div>
)} )}

View File

@ -39,8 +39,15 @@ export function PrintPolicy({
const button = ( const button = (
<Button variant={variant} size={size} onClick={handlePrint} className={className}> <Button variant={variant} size={size} onClick={handlePrint} className={className}>
<Printer /> <Printer aria-hidden="true" />
{!isIconOnly && label} {isIconOnly ? (
// Ein Tooltip ist kein zugänglicher Name – ohne diesen Text wäre die
// Schaltfläche für Screenreader namenlos. Der Titel steht mit drin,
// weil in der Liste mehrere gleich aussehende Schaltflächen stehen.
<span className="sr-only">{`${label}: ${policy.title}`}</span>
) : (
label
)}
</Button> </Button>
) )

View File

@ -12,8 +12,8 @@ import { ThemeToggle } from "@/components/theme-toggle"
export function PublicHeader({ subtitle }: { subtitle?: string }) { export function PublicHeader({ subtitle }: { subtitle?: string }) {
return ( return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-6"> <div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
<Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"> <Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring">
<Brand subtitle={subtitle} /> <Brand subtitle={subtitle} />
<span className="sr-only">Zur Übersicht</span> <span className="sr-only">Zur Übersicht</span>
</Link> </Link>

View File

@ -18,7 +18,7 @@ export async function SiteFooter() {
return ( return (
<footer className="mt-12 border-t"> <footer className="mt-12 border-t">
<div className="mx-auto flex max-w-5xl flex-col gap-3 px-6 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between"> <div className="mx-auto flex max-w-5xl flex-col gap-3 px-4 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span> <span>
© {new Date().getFullYear()} {appName()} © {new Date().getFullYear()} {appName()}
</span> </span>

View File

@ -102,6 +102,56 @@ Name, E-Mail, Telefon
Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die
Schlichtungsstelle nach § 16 BGG anrufen: Schlichtungsstelle nach § 16 BGG anrufen:
Anschrift und Kontakt der zuständigen Schlichtungsstelle`, Anschrift und Kontakt der zuständigen Schlichtungsstelle`,
easyLanguage: `## Willkommen
Das hier ist die Internet-Seite vom Amt Musterstadt.
Hier steht: Was macht das Amt mit Ihren Daten?
## Was finden Sie auf dieser Seite?
Sie finden hier eine Liste.
In der Liste stehen viele Themen.
Zum Beispiel: Bau-Antrag. Oder: Wohn-Geld.
Zu jedem Thema gibt es einen Text.
In dem Text steht: Welche Daten braucht das Amt?
Und: Wie lange speichert das Amt die Daten?
## Wie finden Sie ein Thema?
Oben auf der Seite ist ein Such-Feld.
Dort können Sie ein Wort eintippen.
Dann zeigt die Seite nur noch passende Themen.
Sie können auch auf ein Thema in der Liste klicken.
## Barriere-Freiheit
Barriere-frei heißt: Alle Menschen können die Seite benutzen.
Wir arbeiten daran, dass die Seite barriere-frei wird.
Manche Sachen sind noch nicht barriere-frei.
Sie können uns das melden.
Schreiben Sie an: *[E-Mail-Adresse eintragen]*
*Dieser Text muss vor der Veröffentlichung von einer Prüf-Gruppe
gegengelesen werden. Das ist bei Leichter Sprache üblich.*`,
signLanguage: `## Informationen in Deutscher Gebärdensprache
Hier entsteht ein Video in Deutscher Gebärdensprache.
Das Video erklärt:
- die wesentlichen Inhalte dieser Website
- wie die Navigation funktioniert
- die wesentlichen Inhalte der Erklärung zur Barrierefreiheit
*[Video einbinden, sobald es vorliegt. Erforderlich nach § 4 BITV 2.0.
Ein Video braucht eine Produktion mit gehörlosen Dolmetscherinnen oder
Dolmetschern – Untertitel allein genügen nicht.]*
### Kontakt
Fragen zur Barrierefreiheit richten Sie bitte an:
*[Kontaktdaten eintragen]*`,
} }
interface SitePagesFormProps { interface SitePagesFormProps {
@ -160,9 +210,10 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
<DialogHeader> <DialogHeader>
<DialogTitle>Rechtliche Seiten</DialogTitle> <DialogTitle>Rechtliche Seiten</DialogTitle>
<DialogDescription> <DialogDescription>
Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit Die eigenständigen Seiten des Portals: drei rechtliche Pflichtseiten
des Portals selbst. Sie erscheinen in der Fußzeile der öffentlichen sowie Leichte Sprache und Gebärdensprache nach § 4 BITV 2.0. Sie
Seiten – ein leer gelassener Text nimmt die Seite vom Netz. erscheinen in der Fußzeile der öffentlichen Seiten – ein leer
gelassener Text nimmt die betreffende Seite vom Netz.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -172,12 +223,10 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
onValueChange={(value) => setTab(value as SitePageKey)} onValueChange={(value) => setTab(value as SitePageKey)}
className="min-h-0 flex-1 gap-3" className="min-h-0 flex-1 gap-3"
> >
<TabsList> <TabsList className="h-auto flex-wrap">
{SITE_PAGES.map((key) => ( {SITE_PAGES.map((key) => (
<TabsTrigger key={key} value={key}> <TabsTrigger key={key} value={key}>
{key === "accessibility" {SITE_PAGE_META[key].short}
? "Barrierefreiheit"
: SITE_PAGE_META[key].title}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>

17
components/skip-link.tsx Normal file
View File

@ -0,0 +1,17 @@
/**
* Sprungmarke zum Hauptinhalt (WCAG 2.4.1, Ebene A).
*
* Unsichtbar, bis sie den Fokus bekommt – dann erscheint sie als erstes
* bedienbares Element oben links. Wer mit der Tastatur navigiert, überspringt
* damit Kopfzeile und Navigation, statt sie auf jeder Seite durchzutabben.
*/
export function SkipLink() {
return (
<a
href="#inhalt"
className="sr-only rounded-md bg-background px-4 py-2 text-sm font-medium text-foreground shadow-lg outline-none ring-2 ring-ring focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-50"
>
Zum Inhalt springen
</a>
)
}

View File

@ -35,7 +35,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
data-slot="accordion-trigger" data-slot="accordion-trigger"
className={cn( className={cn(
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180", "flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className className
)} )}
{...props} {...props}

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3", "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring [&>svg]:pointer-events-none [&>svg]:size-3",
{ {
variants: { variants: {
variant: { variant: {

View File

@ -5,14 +5,14 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: secondary:

View File

@ -11,7 +11,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm", "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", "file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30", "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className

View File

@ -18,7 +18,7 @@ function ScrollArea({
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport" data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring/50" className="size-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>

View File

@ -32,7 +32,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
className={cn( className={cn(
"flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 dark:hover:bg-input/50", "flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 dark:hover:bg-input/50",
"data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className

View File

@ -14,7 +14,7 @@ function Switch({
data-slot="switch" data-slot="switch"
className={cn( className={cn(
"peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none", "peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80", "data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className className
)} )}

View File

@ -43,7 +43,7 @@ function TabsTrigger({
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none", "inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30", "data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className

View File

@ -9,7 +9,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm", "flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
"placeholder:text-muted-foreground dark:bg-input/30", "placeholder:text-muted-foreground dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className

View File

@ -1,32 +1,59 @@
/** Die drei rechtlichen Seiten des Portals. */ /**
export const SITE_PAGES = ["imprint", "privacy", "accessibility"] as const * Die eigenständigen Seiten des Portals.
*
* Drei rechtliche Pflichtseiten sowie zwei Seiten, die § 4 BITV 2.0 von
* öffentlichen Stellen verlangt: Erläuterungen in Leichter Sprache und in
* Deutscher Gebärdensprache.
*/
export const SITE_PAGES = [
"imprint",
"privacy",
"accessibility",
"easyLanguage",
"signLanguage",
] as const
/** Bezeichner einer rechtlichen Seite. */ /** Bezeichner einer Seite. */
export type SitePageKey = (typeof SITE_PAGES)[number] export type SitePageKey = (typeof SITE_PAGES)[number]
/** Überschrift und URL je Seite – für Fußzeile, Admin und die Seiten selbst. */ /** Überschrift und URL je Seite – für Fußzeile, Admin und die Seiten selbst. */
export const SITE_PAGE_META: Record< export const SITE_PAGE_META: Record<
SitePageKey, SitePageKey,
{ title: string; path: string; hint: string } { title: string; short: string; path: string; hint: string }
> = { > = {
imprint: { imprint: {
title: "Impressum", title: "Impressum",
short: "Impressum",
path: "/impressum", path: "/impressum",
hint: "Anbieterkennzeichnung nach § 5 DDG", hint: "Anbieterkennzeichnung nach § 5 DDG",
}, },
privacy: { privacy: {
title: "Datenschutzerklärung", title: "Datenschutzerklärung",
short: "Datenschutz",
path: "/datenschutz", path: "/datenschutz",
hint: "Datenverarbeitung durch dieses Portal selbst", hint: "Datenverarbeitung durch dieses Portal selbst",
}, },
accessibility: { accessibility: {
title: "Erklärung zur Barrierefreiheit", title: "Erklärung zur Barrierefreiheit",
short: "Barrierefreiheit",
path: "/barrierefreiheit", path: "/barrierefreiheit",
hint: "Pflichtangabe öffentlicher Stellen nach BITV 2.0", hint: "Pflichtangabe öffentlicher Stellen nach § 7 BITV 2.0, jährlich zu überprüfen",
},
easyLanguage: {
title: "Leichte Sprache",
short: "Leichte Sprache",
path: "/leichte-sprache",
hint: "Pflichtangabe nach § 4 BITV 2.0: wesentliche Inhalte, Navigation und Barrierefreiheitserklärung in Leichter Sprache",
},
signLanguage: {
title: "Gebärdensprache",
short: "Gebärdensprache",
path: "/gebaerdensprache",
hint: "Pflichtangabe nach § 4 BITV 2.0: dieselben Inhalte in Deutscher Gebärdensprache, üblicherweise als Video",
}, },
} }
/** Inhalte der rechtlichen Seiten, wie sie API und Admin austauschen. */ /** Inhalte der Seiten, wie sie API und Admin austauschen. */
export type SitePagesContent = Record<SitePageKey, string> export type SitePagesContent = Record<SitePageKey, string>
/** Leere Ausgangsinhalte – solange im Admin nichts gepflegt wurde. */ /** Leere Ausgangsinhalte – solange im Admin nichts gepflegt wurde. */
@ -34,4 +61,6 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
imprint: "", imprint: "",
privacy: "", privacy: "",
accessibility: "", accessibility: "",
easyLanguage: "",
signLanguage: "",
} }

View File

@ -0,0 +1,8 @@
-- Zwei weitere Seiten, die § 4 BITV 2.0 für öffentliche Stellen verlangt:
-- Erläuterungen in Leichter Sprache und in Deutscher Gebärdensprache zu den
-- wesentlichen Inhalten, zur Navigation und zur Erklärung zur Barrierefreiheit.
--
-- Wie die übrigen Seiten: leerer Text bedeutet, die Seite wird nicht
-- ausgeliefert und nicht in der Fußzeile verlinkt.
ALTER TABLE "SitePages" ADD COLUMN "easyLanguage" TEXT NOT NULL DEFAULT '';
ALTER TABLE "SitePages" ADD COLUMN "signLanguage" TEXT NOT NULL DEFAULT '';

View File

@ -40,7 +40,9 @@ model SitePages {
id Int @id @default(1) // Singleton – immer 1 id Int @id @default(1) // Singleton – immer 1
imprint String @default("") // Impressum (§ 5 DDG) imprint String @default("") // Impressum (§ 5 DDG)
privacy String @default("") // Datenschutzerklärung für dieses Portal privacy String @default("") // Datenschutzerklärung für dieses Portal
accessibility String @default("") // Erklärung zur Barrierefreiheit (BITV 2.0) accessibility String @default("") // Erklärung zur Barrierefreiheit (§ 7 BITV 2.0)
easyLanguage String @default("") // Erläuterungen in Leichter Sprache (§ 4 BITV 2.0)
signLanguage String @default("") // Erläuterungen in Deutscher Gebärdensprache (§ 4 BITV 2.0)
updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt
} }