Dark Mode: folgt dem System, mit Umschalter

Drei Zustaende in der Kopfzeile — System, Hell, Dunkel. Drei statt zwei, weil
ein reiner Ein/Aus-Schalter nach dem ersten Klick fuer immer eine feste Wahl
bedeutet; wer "System" waehlt, bekommt abends dunkel und morgens hell.

Umgesetzt ueber `light-dark()` im CSS, nicht ueber `dark:`-Klassen. Die
Oberflaeche benutzt an rund 440 Stellen feste Farbklassen; jede davon zu
verdoppeln waere eine riesige Fleissarbeit, und jede kuenftige Zeile muesste
daran denken. Stattdessen entscheiden die Farbvariablen selbst, welchen Wert
sie tragen — gesteuert ueber `color-scheme`. Deshalb genuegt fuer den
Umschalter ein Attribut am html-Element, und die Systemwahl funktioniert
sogar ohne JavaScript. Das Skript im Kopf setzt nur eine ausdrueckliche Wahl,
damit die helle Fassung nicht kurz aufblitzt.

Zwei Faelle liessen sich nicht ueber Variablen loesen: `bg-white` muss im
Dunkeln eine dunkle Flaeche sein, `text-white` dagegen weiss bleiben — beide
teilen sich aber dieselbe Variable. Fuer Flaechen gibt es deshalb jetzt
`bg-surface`. Ebenso brauchen Akzent-Links im Dunkeln eine hellere Stufe als
Akzent-Knoepfe: dafuer `text-accent-text` und `bg-accent-soft`.

Bewusst unveraendert bleiben die QR-Codes selbst — deren Farben legt die
Gestaltung fest.

Die dunklen Werte sind keine Spiegelung: Reines Schwarz unter weisser Schrift
flimmert. Alle Textkontraste sind gemessen und liegen ueber 4,5:1, der
niedrigste bei 5,9:1.
This commit is contained in:
Florian Wilde
2026-08-16 09:09:10 +02:00
parent 4046b72626
commit b828f38ca5
32 changed files with 332 additions and 67 deletions
+2 -2
View File
@@ -178,7 +178,7 @@ export function BrandingAdmin({ initial }: { initial: BrandingDto }) {
onChange={(event) =>
setWert({ ...wert, accentColor: event.target.value.toUpperCase() })
}
className="size-10 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
className="size-10 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
/>
<TextInput
id="branding-accent"
@@ -259,7 +259,7 @@ export function BrandingAdmin({ initial }: { initial: BrandingDto }) {
accept={UPLOAD_KINDS.logo.accept}
inputRef={logoInput}
laeuft={laeuft}
hintergrund="bg-white"
hintergrund="bg-surface"
onWaehlen={(datei) => hochladen(datei, "logo", "logoUploadId")}
onEntfernen={() => setWert({ ...wert, logoUploadId: null })}
/>
+9 -9
View File
@@ -297,7 +297,7 @@ export function CodesList() {
<div
role="group"
aria-label="Ansicht"
className="flex shrink-0 rounded-lg border border-slate-300 bg-white p-0.5"
className="flex shrink-0 rounded-lg border border-slate-300 bg-surface p-0.5"
>
{(
[
@@ -316,7 +316,7 @@ export function CodesList() {
className={cx(
"rounded-md p-2 transition-colors",
view === value
? "bg-indigo-50 text-indigo-700"
? "bg-accent-soft text-accent-soft-fg"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
)}
>
@@ -374,7 +374,7 @@ export function CodesList() {
setQuery("");
setGroupFilter("alle");
}}
className="font-medium text-indigo-600 underline"
className="font-medium text-accent-text underline"
>
Filter zurücksetzen
</button>
@@ -384,7 +384,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Listenansicht */}
{view === "list" && visible.length > 0 ? (
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-2xl border border-slate-200 bg-surface shadow-sm">
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
@@ -466,7 +466,7 @@ export function CodesList() {
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="truncate font-mono text-xs text-indigo-600 hover:underline"
className="truncate font-mono text-xs text-accent-text hover:underline"
title={code.shortUrl}
>
{code.shortUrl.replace(/^https?:\/\//, "")}
@@ -617,7 +617,7 @@ export function CodesList() {
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="font-mono text-xs break-all text-indigo-600 hover:underline"
className="font-mono text-xs break-all text-accent-text hover:underline"
>
{code.shortUrl}
</a>
@@ -670,7 +670,7 @@ export function CodesList() {
{code.type === "feedback" ? (
<Link
href={`/meine-codes/${code.id}/feedback`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="star" className="size-3.5" />
Rückmeldungen
@@ -687,14 +687,14 @@ export function CodesList() {
</Button>
<a
href={`/api/codes/${code.id}/qr?format=png&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
PNG
</a>
<a
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
SVG
+6 -6
View File
@@ -40,7 +40,7 @@ function ColorField({
aria-label={`${label} auswählen`}
value={valid ? value : "#000000"}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
/>
<TextInput
id={id}
@@ -95,7 +95,7 @@ export function DesignPanel({
onClick={() =>
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
}
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-surface py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<span
className="size-4 rounded border border-slate-300"
@@ -111,7 +111,7 @@ export function DesignPanel({
onClick={() =>
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
Farben tauschen
</button>
@@ -158,7 +158,7 @@ export function DesignPanel({
if (Number.isNaN(raw)) return;
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
}}
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm"
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-surface px-2.5 py-2 text-sm tabular-nums shadow-sm"
/>
</div>
</Field>
@@ -182,8 +182,8 @@ export function DesignPanel({
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
? "border-indigo-600 bg-indigo-50 text-indigo-700"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50",
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)}
>
{level}
+18
View File
@@ -214,6 +214,24 @@ const PATHS = {
<path d="M4 12h16" />
</>
),
sun: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2" />
<path d="m5.3 5.3 1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4" />
</>
),
moon: (
<>
<path d="M20 14.3A8.5 8.5 0 0 1 9.7 4a8.5 8.5 0 1 0 10.3 10.3Z" />
</>
),
monitor: (
<>
<rect x="2.5" y="4" width="19" height="13" rx="2" />
<path d="M9 21h6M12 17v4" />
</>
),
lock: (
<>
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
+2 -2
View File
@@ -60,7 +60,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
return (
<LandingShell title={title}>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6">
<fieldset>
<legend className="text-center text-base font-medium text-slate-900">
{question}
@@ -118,7 +118,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
value={kommentar}
disabled={laeuft}
onChange={(event) => setKommentar(event.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
className="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
placeholder="Optional"
/>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ export function ImageLanding({ data }: LandingProps) {
{images.map((image, index) => (
<figure
key={image.fileId}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
className="overflow-hidden rounded-2xl border border-slate-200 bg-surface shadow-sm"
>
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
Auslieferung ohne bekannte Maße; der Optimierer von Next
+1 -1
View File
@@ -15,7 +15,7 @@ export function LinksLanding({ data }: LandingProps) {
href={link.url}
// Fremde Ziele: kein Zugriff auf dieses Fenster.
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
>
<span className="min-w-0 flex-1 font-medium break-words">
{link.label}
+2 -2
View File
@@ -14,7 +14,7 @@ export function PdfLanding({ data }: LandingProps) {
title={title || filename || "PDF-Dokument"}
subtitle={description || null}
>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm">
<div className="flex items-center gap-4">
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
<Icon name="document" className="size-6" />
@@ -37,7 +37,7 @@ export function PdfLanding({ data }: LandingProps) {
</a>
<a
href={`${href}?download`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-4" />
Herunterladen
+1 -1
View File
@@ -20,7 +20,7 @@ export function SocialLanding({ data }: LandingProps) {
<a
href={profile.url}
rel="noopener noreferrer"
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-surface px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<span
aria-hidden="true"
+1 -1
View File
@@ -44,7 +44,7 @@ export function VideoLanding({ data }: LandingProps) {
<a
href={url}
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
<Icon name="video" className="size-5" />
+2 -2
View File
@@ -41,13 +41,13 @@ export function EmbeddedVideo({
<button
type="button"
onClick={() => setGestartet(true)}
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-slate-900 px-6 text-center shadow-sm transition-colors hover:bg-slate-800"
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-scrim px-6 text-center shadow-sm transition-colors hover:brightness-125"
>
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
<Icon name="video" className="size-8" />
</span>
<span className="font-medium text-white">Video abspielen</span>
<span className="max-w-xs text-xs leading-relaxed text-slate-300">
<span className="max-w-xs text-xs leading-relaxed text-white/75">
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
werden Daten an den Anbieter übertragen.
</span>
+1 -1
View File
@@ -39,7 +39,7 @@ export function Modal({
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
if (event.target === ref.current) onClose();
}}
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-white p-0 shadow-xl backdrop:bg-slate-900/40"
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50"
>
<div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
+4 -4
View File
@@ -64,7 +64,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
<img
src={`/api/uploads/${image.fileId}`}
alt=""
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-white object-cover"
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-surface object-cover"
/>
<div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-sm text-slate-600">
@@ -95,7 +95,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label={`Bild ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 rotate-90" />
</button>
@@ -104,7 +104,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === images.length - 1}
onClick={() => move(index, 1)}
aria-label={`Bild ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 -rotate-90" />
</button>
@@ -115,7 +115,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
setImages(images.filter((_, i) => i !== index))
}
aria-label={`Bild ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
>
<Icon name="trash" className="size-4" />
</button>
+3 -3
View File
@@ -125,7 +125,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label={`Link ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 rotate-90" />
</button>
@@ -134,7 +134,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === links.length - 1}
onClick={() => move(index, 1)}
aria-label={`Link ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 -rotate-90" />
</button>
@@ -143,7 +143,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || links.length === 1}
onClick={() => setLinks(links.filter((_, i) => i !== index))}
aria-label={`Link ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="trash" className="size-4" />
</button>
+1 -1
View File
@@ -41,7 +41,7 @@ export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
onChange({ ...value, fileId: "", filename: "", size: 0 })
}
aria-label="Datei entfernen"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
>
<Icon name="trash" className="size-4" />
</button>
+1 -1
View File
@@ -133,7 +133,7 @@ export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
setProfiles(profiles.filter((_, i) => i !== index))
}
aria-label={`Profil ${index + 1} entfernen`}
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="trash" className="size-4" />
</button>
+1 -1
View File
@@ -36,7 +36,7 @@ export function WebsiteForm({ value, onChange, error, disabled }: QrFormProps) {
onClick={() =>
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` })
}
className="mt-2 rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="mt-2 rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
https:// voranstellen
</button>
+7 -7
View File
@@ -226,7 +226,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
</Link>
<div className="mb-7 flex flex-wrap items-center gap-3">
<span className="flex size-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
<span className="flex size-11 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
<Icon name={qrType.icon} className="size-6" />
</span>
<div>
@@ -290,8 +290,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
!isDynamic
? "border-indigo-600 bg-indigo-50/60"
: "border-slate-300 bg-white hover:bg-slate-50",
? "border-indigo-600 bg-accent-soft/60"
: "border-slate-300 bg-surface hover:bg-slate-50",
)}
>
<span className="flex items-center gap-2 font-medium text-slate-900">
@@ -314,8 +314,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
isDynamic
? "border-indigo-600 bg-indigo-50/60"
: "border-slate-300 bg-white hover:bg-slate-50",
? "border-indigo-600 bg-accent-soft/60"
: "border-slate-300 bg-surface hover:bg-slate-50",
)}
>
<span className="flex items-center gap-2 font-medium text-slate-900">
@@ -353,7 +353,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<span className="font-medium">Code gespeichert.</span> Der
QR-Code zeigt dauerhaft auf diese Kurz-URL:
<span className="mt-2 flex flex-wrap items-center gap-2">
<code className="rounded bg-white/70 px-2 py-1 font-mono text-xs break-all">
<code className="rounded bg-surface/70 px-2 py-1 font-mono text-xs break-all">
{saved.shortUrl}
</code>
<Button
@@ -383,7 +383,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
) : null}
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-indigo-600 hover:text-indigo-700"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
>
Zu „Meine Codes“
<Icon name="external" className="size-3.5" />
+1 -1
View File
@@ -42,7 +42,7 @@ export function QrPreview({
</div>
)}
{loading ? (
<span className="absolute top-3 right-3 rounded-full bg-white/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
<span className="absolute top-3 right-3 rounded-full bg-surface/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
aktualisiert …
</span>
) : null}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useSyncExternalStore } from "react";
import { Icon, type IconName } from "@/components/icons";
/**
* Umschalter für das Farbthema.
*
* Drei Zustände statt zwei: Wer „System“ wählt, folgt weiterhin dem
* Betriebssystem — abends dunkel, morgens hell, ohne Zutun. Ein reiner
* Ein/Aus-Schalter kann das nicht abbilden, weil er nach dem ersten Klick
* für immer eine feste Wahl bedeutet.
*
* Umgesetzt wird die Wahl allein über das Attribut `data-theme` am
* <html>-Element; die Farben stecken in `globals.css`. Ohne Attribut gilt die
* Systemeinstellung — deshalb wird bei „System“ nicht etwa „light“ gesetzt,
* sondern das Attribut entfernt.
*/
export const THEME_STORAGE_KEY = "qrgen.theme";
export type Theme = "system" | "light" | "dark";
const OPTIONEN: { wert: Theme; icon: IconName; label: string }[] = [
{ wert: "system", icon: "monitor", label: "System" },
{ wert: "light", icon: "sun", label: "Hell" },
{ wert: "dark", icon: "moon", label: "Dunkel" },
];
export function applyTheme(theme: Theme): void {
if (theme === "system") {
delete document.documentElement.dataset.theme;
} else {
document.documentElement.dataset.theme = theme;
}
}
/**
* Läuft vor dem ersten Zeichnen und verhindert das kurze Aufblitzen der
* hellen Fassung bei jemandem, der ausdrücklich „Dunkel“ gewählt hat.
*
* Bewusst winzig und ohne Abhängigkeiten: Alles, was hier steht, blockiert
* den Seitenaufbau. Wer der Systemeinstellung folgt, braucht das Skript gar
* nicht — dafür sorgt schon das CSS.
*/
export const THEME_INIT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
THEME_STORAGE_KEY,
)});if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}`;
/**
* Die Wahl liegt im `localStorage` — also außerhalb von React. Dafür ist
* `useSyncExternalStore` gedacht: Es liest den Wert beim Einhängen, ohne dass
* der Server-Aufbau davon abweicht, und benachrichtigt alle Stellen, wenn er
* sich ändert. Nebenbei zieht ein zweiter Tab automatisch nach.
*/
const zuhoerer = new Set<() => void>();
function abonnieren(melden: () => void): () => void {
zuhoerer.add(melden);
window.addEventListener("storage", melden);
return () => {
zuhoerer.delete(melden);
window.removeEventListener("storage", melden);
};
}
function lesen(): Theme {
try {
const wert = localStorage.getItem(THEME_STORAGE_KEY);
return wert === "dark" || wert === "light" ? wert : "system";
} catch {
return "system";
}
}
/** Auf dem Server ist nichts gespeichert — dort gilt die Systemeinstellung. */
function aufDemServer(): Theme {
return "system";
}
export function ThemeToggle() {
const theme = useSyncExternalStore(abonnieren, lesen, aufDemServer);
const waehlen = (wert: Theme) => {
applyTheme(wert);
try {
if (wert === "system") localStorage.removeItem(THEME_STORAGE_KEY);
else localStorage.setItem(THEME_STORAGE_KEY, wert);
} catch {
// Privater Modus ohne Speicher — die Wahl gilt dann bis zum Neuladen.
}
for (const melden of zuhoerer) melden();
};
return (
<div
role="group"
aria-label="Farbthema"
className="flex items-center gap-0.5 rounded-lg border border-slate-200 p-0.5"
>
{OPTIONEN.map((option) => {
const aktiv = theme === option.wert;
return (
<button
key={option.wert}
type="button"
data-theme-option={option.wert}
aria-pressed={aktiv}
title={`Farbthema: ${option.label}`}
aria-label={`Farbthema: ${option.label}`}
onClick={() => waehlen(option.wert)}
className={
aktiv
? "rounded-md bg-accent-soft p-1.5 text-accent-soft-fg"
: "rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
}
>
<Icon name={option.icon} className="size-4" />
</button>
);
})}
</div>
);
}
+2 -2
View File
@@ -42,7 +42,7 @@ function Toggle({
>
<span
className={cx(
"inline-block size-5 rounded-full bg-white shadow-sm transition-transform",
"inline-block size-5 rounded-full bg-surface shadow-sm transition-transform",
checked ? "translate-x-5.5" : "translate-x-0.5",
)}
/>
@@ -114,7 +114,7 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
className={cx(
"flex size-10 shrink-0 items-center justify-center rounded-xl",
entry.enabled && entry.implemented
? "bg-indigo-50 text-indigo-600"
? "bg-accent-soft text-accent-text"
: "bg-slate-100 text-slate-400",
)}
>
+5 -5
View File
@@ -22,7 +22,7 @@ export function Card({
return (
<section
className={cx(
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6",
"rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6",
className,
)}
>
@@ -57,7 +57,7 @@ const BUTTON_VARIANTS: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary:
"bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600",
secondary:
"border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 disabled:hover:bg-white",
"border border-slate-300 bg-surface text-slate-700 hover:bg-slate-50 disabled:hover:bg-surface",
ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent",
danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600",
};
@@ -124,7 +124,7 @@ export function TextInput({
return (
<input
className={cx(
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
invalid
? "border-red-400 focus-visible:outline-red-500"
: "border-slate-300",
@@ -137,7 +137,7 @@ export function TextInput({
/** Gemeinsame Grundgestaltung aller Eingabefelder. */
export const CONTROL_BASE =
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
export function TextArea({
invalid,
@@ -243,7 +243,7 @@ export function Badge({
neutral: "bg-slate-100 text-slate-600",
// Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen
// „Beliebt" auf der Startseite gedacht war.
accent: "bg-indigo-100 text-indigo-700",
accent: "bg-accent-soft text-accent-soft-fg",
soon: "bg-slate-100 text-slate-500",
success: "bg-emerald-100 text-emerald-700",
};