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:
@@ -37,11 +37,11 @@ export default async function AccountPage() {
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
|
||||
Anwendung erreicht, darf alles. Zum Einschalten{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>{" "}
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>{" "}
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
|
||||
import { LogoutButton } from "@/components/auth-forms";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
@@ -26,7 +27,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<header className="border-b border-slate-200 bg-surface">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
@@ -54,6 +55,11 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
|
||||
<ThemeToggle />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||
/>
|
||||
{signedIn ? (
|
||||
<>
|
||||
<Link href="/" className={NAV_LINK}>
|
||||
@@ -81,7 +87,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
title={`Konto von ${actor.user.email}`}
|
||||
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
|
||||
{actorLabel(actor).slice(0, 2)}
|
||||
</span>
|
||||
<span className="hidden truncate sm:inline">
|
||||
@@ -121,7 +127,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
<footer className="border-t border-slate-200 bg-white">
|
||||
<footer className="border-t border-slate-200 bg-surface">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||
{branding.appName}
|
||||
{branding.footerText ? ` · ${branding.footerText}` : null}
|
||||
|
||||
@@ -121,7 +121,7 @@ export default async function FeedbackResultsPage({ params }: PageProps) {
|
||||
{kommentare.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
className="rounded-xl border border-slate-200 bg-surface p-4 shadow-sm"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
|
||||
<span className="font-medium text-slate-900 tabular-nums">
|
||||
|
||||
@@ -50,7 +50,7 @@ export default async function HomePage() {
|
||||
<span
|
||||
className={
|
||||
usable
|
||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
|
||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text"
|
||||
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
|
||||
}
|
||||
>
|
||||
@@ -96,7 +96,7 @@ export default async function HomePage() {
|
||||
{usable ? (
|
||||
<Link
|
||||
href={`/erstellen/${qrType.id}`}
|
||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
{tile}
|
||||
</Link>
|
||||
|
||||
@@ -27,9 +27,9 @@ function Tile({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -78,11 +78,11 @@ export default async function AdminPage() {
|
||||
<Notice tone="info">
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
|
||||
+72
-1
@@ -9,10 +9,81 @@
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Dark Mode
|
||||
|
||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen.
|
||||
|
||||
Der Grund: Die Oberfläche benutzt an rund 440 Stellen feste Farbklassen wie
|
||||
`bg-slate-50` oder `text-slate-900`. Jede davon um eine `dark:`-Variante zu
|
||||
ergänzen wäre eine riesige, fehleranfällige Fleißarbeit — und jede künftige
|
||||
Zeile müsste daran denken. Stattdessen werden hier die Farbvariablen
|
||||
umdefiniert, die Tailwind ohnehin benutzt: `bg-slate-50` wird zu
|
||||
`background-color: var(--color-slate-50)`, und was diese Variable enthält,
|
||||
entscheidet sich hier.
|
||||
|
||||
Welche Hälfte von `light-dark()` gilt, steuert `color-scheme` — deshalb
|
||||
genügt für den Umschalter ein Attribut am <html>-Element. Ohne Attribut
|
||||
folgt die Anwendung dem Betriebssystem, ganz ohne JavaScript.
|
||||
|
||||
Die dunklen Werte sind keine bloße Spiegelung: Reines Schwarz unter weißer
|
||||
Schrift flimmert. Die Flächen liegen deshalb bei 16–26 % Helligkeit, mit
|
||||
demselben leicht blauen Ton wie die helle Reihe.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Grautöne. Hell: die Reihe von Tailwind. Dunkel: von dunkel nach hell
|
||||
gedreht, damit `text-slate-900` weiterhin „die kräftigste Schrift“
|
||||
bedeutet und `bg-slate-50` „der ruhigste Grund“. */
|
||||
--color-slate-50: light-dark(oklch(98.4% 0.003 247.858), oklch(16.5% 0.018 265));
|
||||
--color-slate-100: light-dark(oklch(96.8% 0.007 247.896), oklch(21% 0.02 265));
|
||||
--color-slate-200: light-dark(oklch(92.9% 0.013 255.508), oklch(28% 0.022 262));
|
||||
--color-slate-300: light-dark(oklch(86.9% 0.022 252.894), oklch(34% 0.026 260));
|
||||
--color-slate-400: light-dark(oklch(70.4% 0.04 256.788), oklch(58% 0.032 258));
|
||||
--color-slate-500: light-dark(oklch(55.4% 0.046 257.417), oklch(68% 0.03 257));
|
||||
--color-slate-600: light-dark(oklch(44.6% 0.043 257.281), oklch(77% 0.025 257));
|
||||
--color-slate-700: light-dark(oklch(37.2% 0.044 257.287), oklch(85% 0.02 257));
|
||||
--color-slate-800: light-dark(oklch(27.9% 0.041 260.031), oklch(91% 0.014 258));
|
||||
--color-slate-900: light-dark(oklch(20.8% 0.042 265.755), oklch(96.5% 0.008 258));
|
||||
--color-slate-950: light-dark(oklch(12.9% 0.042 264.695), oklch(99% 0.003 258));
|
||||
|
||||
/* Flächen. `bg-white` wäre im Dunkeln falsch, `text-white` dagegen richtig
|
||||
(es steht auf farbigem Grund) — beide teilen sich aber dieselbe Variable.
|
||||
Deshalb tragen Flächen einen eigenen Namen: `bg-surface`. */
|
||||
--color-surface: light-dark(#ffffff, oklch(22.5% 0.02 264));
|
||||
|
||||
/* Akzent auf dunklem Grund. Die Reihe selbst bleibt, wie das
|
||||
Erscheinungsbild sie setzt — hier wird nur eine andere Stufe gewählt:
|
||||
Fließtext braucht im Dunkeln eine hellere, Flächen eine dunklere. */
|
||||
--color-accent-text: light-dark(
|
||||
var(--color-indigo-600),
|
||||
var(--color-indigo-400)
|
||||
);
|
||||
--color-accent-soft: light-dark(
|
||||
var(--color-indigo-50),
|
||||
color-mix(in oklab, var(--color-indigo-500) 22%, oklch(22.5% 0.02 264))
|
||||
);
|
||||
--color-accent-soft-fg: light-dark(
|
||||
var(--color-indigo-700),
|
||||
var(--color-indigo-300)
|
||||
);
|
||||
|
||||
/* Immer dunkel, unabhängig vom Thema: die Fläche hinter einem Dialog und
|
||||
der Grund einer Videovorschau. */
|
||||
--color-scrim: oklch(20.8% 0.042 265.755);
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-slate-50);
|
||||
color: var(--color-slate-900);
|
||||
@@ -22,7 +93,7 @@ body {
|
||||
|
||||
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-indigo-600);
|
||||
outline: 2px solid var(--color-accent-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
||||
import { accentCss } from "@/lib/branding-colors";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
|
||||
@@ -49,6 +50,12 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="de" className="h-full">
|
||||
<body className="flex min-h-full flex-col">
|
||||
{/*
|
||||
Setzt eine ausdrücklich gewählte Fassung, bevor der Browser zeichnet.
|
||||
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
|
||||
`light-dark()` im CSS von allein.
|
||||
*/}
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
{/*
|
||||
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
|
||||
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
|
||||
|
||||
Reference in New Issue
Block a user