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 (
<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">
<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">
<Brand />
<Badge variant="secondary">Admin</Badge>
@ -314,7 +314,7 @@ export default function AdminDashboard() {
</div>
</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="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">
@ -326,7 +326,7 @@ export default function AdminDashboard() {
{incompleteCount > 0 && (
<>
{" · "}
<span className="text-amber-600 dark:text-amber-500">
<span className="text-amber-700 dark:text-amber-400">
{incompleteCount} unvollständig
</span>
</>
@ -334,30 +334,40 @@ export default function AdminDashboard() {
</p>
</div>
<div className="flex items-center gap-2">
<div className="relative 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" />
<Input
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{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 />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
<div className="flex w-full items-center gap-2 sm:w-auto">
<div className="min-w-0 flex-1 sm:w-64 sm:flex-none">
<label htmlFor="admin-suche" className="sr-only">
Erklärungen nach Titel oder Verantwortlichem durchsuchen
</label>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="admin-suche"
type="search"
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{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>
<Button onClick={handleAddNew}>
<Plus />
<Button onClick={handleAddNew} className="shrink-0">
<Plus aria-hidden="true" />
Neu
</Button>
</div>
@ -448,7 +458,7 @@ export default function AdminDashboard() {
<TooltipTrigger asChild>
<Badge
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 />
{missingByPolicy.get(policy.id)!.length}

View File

@ -55,7 +55,7 @@ export default function SignInPage() {
<ThemeToggle />
</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="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">

View File

@ -129,7 +129,7 @@ export default async function PolicyPage({
<div className="flex min-h-screen flex-col bg-background">
<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">
<Link href="/">
<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-foreground: oklch(0.21 0.006 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-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);
--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);
--input: oklch(0.918 0.004 286);
--ring: oklch(0.6 0.012 286);
--brand: oklch(0.55 0.155 258);
/* Rahmen von Eingabefeldern: 3:1 nach WCAG 1.4.11, sonst ist das Feld
nicht als solches erkennbar. Dekorative Trenner nutzen --border. */
--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);
--chart-1: oklch(0.55 0.155 258);
--chart-2: oklch(0.6 0.118 184.704);
@ -58,12 +61,12 @@
--muted-foreground: oklch(0.712 0.013 286);
--accent: oklch(0.256 0.006 286);
--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);
--success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0.012 286);
--input: oklch(0.500 0.006 286); /* deckend statt transparent, 3:1 */
--ring: oklch(0.68 0.14 258); /* Fokus in Markenfarbe, 6.9:1 */
--brand: oklch(0.68 0.14 258);
--brand-foreground: oklch(0.14 0.004 286);
--chart-1: oklch(0.68 0.14 258);
@ -148,7 +151,7 @@
@layer base {
* {
@apply border-border outline-ring/50;
@apply border-border outline-ring;
}
body {

View File

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

View File

@ -27,7 +27,7 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
<div className="flex min-h-screen flex-col bg-background">
<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">
{meta.title}
</h1>

View File

@ -30,7 +30,7 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
}, [policies, searchTerm])
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="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">
@ -41,25 +41,35 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
</p>
</div>
<div className="relative 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" />
<Input
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{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 />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
<div className="w-full sm:w-72">
<label htmlFor="suche" className="sr-only">
Datenschutzerklärungen durchsuchen
</label>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="suche"
type="search"
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{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>
@ -102,60 +112,58 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
)}
</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) => (
<div
<li
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
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" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{policy.title}
</span>
<span className="block font-medium">{policy.title}</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
</span>
</span>
<ChevronRight
aria-hidden="true"
className="hidden size-4 shrink-0 text-muted-foreground group-hover:text-foreground sm:block"
/>
</Link>
<div className="flex shrink-0 items-center gap-2 pr-1">
<span className="hidden text-sm text-muted-foreground sm:inline">
<div className="flex shrink-0 items-center gap-2 pb-3 sm:pb-0">
<span className="text-sm text-muted-foreground">
<span className="sr-only">Zuletzt geändert: </span>
{formatDate(policy.updatedAt)}
</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<Separator orientation="vertical" className="h-5" />
<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>
</li>
))}
</div>
</ul>
)}
{!isLoading && !error && filteredPolicies.length > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<span>
<div className="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
{/* Beim Filtern hört eine Vorlesesoftware sonst nicht, was passiert */}
<p role="status" aria-live="polite">
{filteredPolicies.length}{" "}
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
{searchTerm && ` von ${policies.length}`}
</span>
</p>
<Badge variant="outline">DSGVO-konform</Badge>
</div>
)}

View File

@ -39,8 +39,15 @@ export function PrintPolicy({
const button = (
<Button variant={variant} size={size} onClick={handlePrint} className={className}>
<Printer />
{!isIconOnly && label}
<Printer aria-hidden="true" />
{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>
)

View File

@ -12,8 +12,8 @@ import { ThemeToggle } from "@/components/theme-toggle"
export function PublicHeader({ subtitle }: { subtitle?: string }) {
return (
<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">
<Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<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">
<Brand subtitle={subtitle} />
<span className="sr-only">Zur Übersicht</span>
</Link>

View File

@ -18,7 +18,7 @@ export async function SiteFooter() {
return (
<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>
© {new Date().getFullYear()} {appName()}
</span>

View File

@ -102,6 +102,56 @@ Name, E-Mail, Telefon
Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die
Schlichtungsstelle nach § 16 BGG anrufen:
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 {
@ -160,9 +210,10 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
<DialogHeader>
<DialogTitle>Rechtliche Seiten</DialogTitle>
<DialogDescription>
Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit
des Portals selbst. Sie erscheinen in der Fußzeile der öffentlichen
Seiten – ein leer gelassener Text nimmt die Seite vom Netz.
Die eigenständigen Seiten des Portals: drei rechtliche Pflichtseiten
sowie Leichte Sprache und Gebärdensprache nach § 4 BITV 2.0. Sie
erscheinen in der Fußzeile der öffentlichen Seiten – ein leer
gelassener Text nimmt die betreffende Seite vom Netz.
</DialogDescription>
</DialogHeader>
@ -172,12 +223,10 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
onValueChange={(value) => setTab(value as SitePageKey)}
className="min-h-0 flex-1 gap-3"
>
<TabsList>
<TabsList className="h-auto flex-wrap">
{SITE_PAGES.map((key) => (
<TabsTrigger key={key} value={key}>
{key === "accessibility"
? "Barrierefreiheit"
: SITE_PAGE_META[key].title}
{SITE_PAGE_META[key].short}
</TabsTrigger>
))}
</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
data-slot="accordion-trigger"
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
)}
{...props}

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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: {
variant: {

View File

@ -5,14 +5,14 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
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:
"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:

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",
"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",
"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",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className

View File

@ -18,7 +18,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.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}
</ScrollAreaPrimitive.Viewport>

View File

@ -32,7 +32,7 @@ function SelectTrigger({
data-slot="select-trigger"
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",
"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",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className

View File

@ -14,7 +14,7 @@ function Switch({
data-slot="switch"
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",
"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",
className
)}

View File

@ -43,7 +43,7 @@ function TabsTrigger({
data-slot="tabs-trigger"
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",
"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",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className

View File

@ -9,7 +9,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
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",
"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",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
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]
/** Überschrift und URL je Seite – für Fußzeile, Admin und die Seiten selbst. */
export const SITE_PAGE_META: Record<
SitePageKey,
{ title: string; path: string; hint: string }
{ title: string; short: string; path: string; hint: string }
> = {
imprint: {
title: "Impressum",
short: "Impressum",
path: "/impressum",
hint: "Anbieterkennzeichnung nach § 5 DDG",
},
privacy: {
title: "Datenschutzerklärung",
short: "Datenschutz",
path: "/datenschutz",
hint: "Datenverarbeitung durch dieses Portal selbst",
},
accessibility: {
title: "Erklärung zur Barrierefreiheit",
short: "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>
/** Leere Ausgangsinhalte – solange im Admin nichts gepflegt wurde. */
@ -34,4 +61,6 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
imprint: "",
privacy: "",
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
imprint String @default("") // Impressum (§ 5 DDG)
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
}