Files
qrcode-generator/src/components/design-panel.tsx
T
Florian Wilde b828f38ca5 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.
2026-08-16 09:09:10 +02:00

214 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Icon } from "@/components/icons";
import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast";
import {
COLOR_PRESETS,
EC_LEVELS,
EC_LEVEL_LABELS,
MAX_SIZE,
MIN_SIZE,
type EcLevel,
type QrDesign,
} from "@/lib/qr/design";
const HEX_INPUT = /^#[0-9a-fA-F]{6}$/;
function ColorField({
label,
value,
onChange,
id,
}: {
label: string;
value: string;
onChange: (value: string) => void;
id: string;
}) {
const valid = HEX_INPUT.test(value);
return (
<Field
label={label}
htmlFor={id}
error={valid ? null : "Bitte im Format #RRGGBB angeben."}
>
<div className="flex items-center gap-2">
<input
type="color"
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-surface p-1"
/>
<TextInput
id={id}
value={value}
invalid={!valid}
spellCheck={false}
maxLength={7}
onChange={(event) => {
const next = event.target.value.trim();
onChange(next.startsWith("#") ? next.toUpperCase() : `#${next.toUpperCase()}`);
}}
className="font-mono uppercase"
/>
</div>
</Field>
);
}
export function DesignPanel({
design,
onChange,
}: {
design: QrDesign;
onChange: (design: QrDesign) => void;
}) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor);
return (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorField
id="fg-color"
label="Vordergrund"
value={design.fgColor}
onChange={(fgColor) => patch({ fgColor })}
/>
<ColorField
id="bg-color"
label="Hintergrund"
value={design.bgColor}
onChange={(bgColor) => patch({ bgColor })}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{COLOR_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
title={preset.label}
onClick={() =>
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
}
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"
style={{
background: `linear-gradient(135deg, ${preset.fgColor} 50%, ${preset.bgColor} 50%)`,
}}
/>
{preset.label}
</button>
))}
<button
type="button"
onClick={() =>
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
}
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>
</div>
{contrast.message ? (
<Notice tone={contrast.verdict === "schlecht" ? "error" : "warning"}>
<span className="flex gap-2">
<Icon name="warning" className="mt-0.5 size-4 shrink-0" />
<span>
{contrast.message}
<span className="mt-0.5 block text-xs opacity-80">
Kontrastverhältnis: {contrast.ratio.toFixed(1)} : 1
</span>
</span>
</span>
</Notice>
) : null}
<Field
label={`Größe: ${design.size} × ${design.size} px`}
htmlFor="qr-size"
hint={`Zwischen ${MIN_SIZE} und ${MAX_SIZE} px. Gilt für den PNG-Download; das SVG skaliert verlustfrei.`}
>
<div className="flex items-center gap-3">
<input
id="qr-size"
type="range"
min={MIN_SIZE}
max={MAX_SIZE}
step={32}
value={design.size}
onChange={(event) => patch({ size: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
<input
type="number"
aria-label="Größe in Pixeln"
min={MIN_SIZE}
max={MAX_SIZE}
value={design.size}
onChange={(event) => {
const raw = Number(event.target.value);
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-surface px-2.5 py-2 text-sm tabular-nums shadow-sm"
/>
</div>
</Field>
<Field
label="Fehlerkorrektur"
hint={EC_LEVEL_LABELS[design.ecLevel]}
>
<div
role="radiogroup"
aria-label="Fehlerkorrektur-Level"
className="grid grid-cols-4 gap-2"
>
{EC_LEVELS.map((level: EcLevel) => (
<button
key={level}
type="button"
role="radio"
aria-checked={design.ecLevel === level}
onClick={() => patch({ ecLevel: level })}
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)}
>
{level}
</button>
))}
</div>
</Field>
<Field
label={`Rand: ${design.margin} Module`}
htmlFor="qr-margin"
hint="Die weiße Ruhezone rundherum. Weniger als 4 Module können das Scannen erschweren."
>
<input
id="qr-margin"
type="range"
min={0}
max={16}
step={1}
value={design.margin}
onChange={(event) => patch({ margin: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
</div>
);
}