waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src shared theme.ts

6,8 KB Rohdatei
app/src/shared/theme.ts — 175 Zeilen
1 /**
2 * Gemeinsame Theme-Definitionen für Main-, Preload- und Renderer-Prozess.
3 *
4 * Es gibt drei tatsächlich gerenderte Themes (hell, dunkel, hochkontrast).
5 * Die Nutzerauswahl kennt zusätzlich `system`: dann entscheiden die
6 * Betriebssystem-Einstellungen (`prefers-color-scheme`, `forced-colors`,
7 * `prefers-contrast`).
8 */
9
10 /** Auswahlmöglichkeiten im Theme-Umschalter. */
11 export const THEME_AUSWAHLEN = ['system', 'hell', 'dunkel', 'hochkontrast'] as const;
12
13 /** Was die Nutzerin/der Nutzer eingestellt hat. */
14 export type ThemeAuswahl = (typeof THEME_AUSWAHLEN)[number];
15
16 /** Was tatsächlich gerendert wird – `system` ist hier bereits aufgelöst. */
17 export type ThemeAufgeloest = Exclude<ThemeAuswahl, 'system'>;
18
19 /** Deutsche Beschriftungen für die Oberfläche. */
20 export const THEME_BESCHRIFTUNGEN: Readonly<Record<ThemeAuswahl, string>> = Object.freeze({
21 system: 'Systemvorgabe',
22 hell: 'Hell',
23 dunkel: 'Dunkel',
24 hochkontrast: 'Hoher Kontrast',
25 });
26
27 /** Ergänzende Erläuterung je Auswahl (für `aria-describedby`). */
28 export const THEME_ERLAEUTERUNGEN: Readonly<Record<ThemeAuswahl, string>> = Object.freeze({
29 system: 'Folgt automatisch den Anzeigeeinstellungen Ihres Betriebssystems.',
30 hell: 'Dunkle Schrift auf hellem Grund, ruhige Farbgebung.',
31 dunkel: 'Helle Schrift auf dunklem Grund, blendarm für Abendstunden.',
32 hochkontrast: 'Maximaler Kontrast mit kräftigen Rändern und Akzentfarben.',
33 });
34
35 /**
36 * Signale, die das Betriebssystem bzw. der Browser über Media Queries meldet.
37 * Werden separat übergeben, damit die Auflösungslogik rein und testbar bleibt.
38 */
39 export interface SystemAnzeigeSignale {
40 /** `(prefers-color-scheme: dark)` */
41 readonly bevorzugtDunkel: boolean;
42 /** `(forced-colors: active)` – z. B. Windows-Kontrastdesign */
43 readonly erzwungeneFarben: boolean;
44 /** `(prefers-contrast: more)` */
45 readonly bevorzugtMehrKontrast: boolean;
46 }
47
48 export const SYSTEM_SIGNALE_STANDARD: SystemAnzeigeSignale = Object.freeze({
49 bevorzugtDunkel: false,
50 erzwungeneFarben: false,
51 bevorzugtMehrKontrast: false,
52 });
53
54 /** Prüft zur Laufzeit, ob ein unbekannter Wert eine gültige Theme-Auswahl ist. */
55 export function istThemeAuswahl(wert: unknown): wert is ThemeAuswahl {
56 return typeof wert === 'string' && (THEME_AUSWAHLEN as readonly string[]).includes(wert);
57 }
58
59 /**
60 * Löst die Nutzerauswahl gegen die Systemsignale zu einem konkreten Theme auf.
61 *
62 * Regeln:
63 * 1. Erzwungene Farben (Windows-Kontrastdesign) haben immer Vorrang – dann
64 * liefert das Betriebssystem die Farben und wir schalten auf `hochkontrast`.
65 * 2. Eine explizite Nutzerauswahl schlägt die restlichen Systemsignale.
66 * 3. Bei `system` entscheiden `prefers-contrast` und `prefers-color-scheme`.
67 */
68 export function themeAufloesen(
69 auswahl: ThemeAuswahl,
70 signale: SystemAnzeigeSignale = SYSTEM_SIGNALE_STANDARD,
71 ): ThemeAufgeloest {
72 if (signale.erzwungeneFarben) {
73 return 'hochkontrast';
74 }
75
76 if (auswahl !== 'system') {
77 return auswahl;
78 }
79
80 if (signale.bevorzugtMehrKontrast) {
81 return 'hochkontrast';
82 }
83
84 return signale.bevorzugtDunkel ? 'dunkel' : 'hell';
85 }
86
87 /**
88 * Passendes `color-scheme` für das aufgelöste Theme – steuert Scrollbalken,
89 * Formularelemente und die Fensterdekoration.
90 *
91 * Muss mit den `color-scheme`-Deklarationen in `tokens.css` übereinstimmen:
92 * Das eigene Thema „Hoher Kontrast“ zeichnet weiße Schrift auf Schwarz und
93 * zählt deshalb als `dark`.
94 *
95 * **Beim Windows-Kontrastdesign entscheidet aber das System.** Es gibt auch
96 * helle Kontrastdesigns (schwarz auf weiß). `hochkontrast` steht dann nur
97 * dafür, dass die Farben von aussen kommen – über hell oder dunkel sagt es
98 * nichts. Bis Fassung 0.24.1 lieferte diese Funktion dort unbedingt `dark`
99 * und stellte damit Scrollbalken und Formularelemente auf dunkel, während
100 * das System hell zeichnete. {@link startfarbeFuer} macht es seit jeher
101 * richtig; hier fehlte die Entsprechung.
102 */
103 export function farbschemaFuer(
104 theme: ThemeAufgeloest,
105 signale: SystemAnzeigeSignale = SYSTEM_SIGNALE_STANDARD,
106 ): 'light' | 'dark' {
107 if (signale.erzwungeneFarben) {
108 return signale.bevorzugtDunkel ? 'dark' : 'light';
109 }
110 return theme === 'hell' ? 'light' : 'dark';
111 }
112
113 /**
114 * Grundfarbe je Theme – dieselben Werte wie `--farbe-grund` in `tokens.css`.
115 *
116 * Sie stehen hier ein zweites Mal, weil der Hauptprozess die Fensterfarbe
117 * setzen muss, bevor es ein Dokument gibt, das ein Stilblatt laden könnte.
118 * Dass beide Stellen übereinstimmen, rechnet `tests/token-kontraste.test.ts`
119 * gegen die CSS-Datei nach – eine Kopie, die niemand vergleicht, läuft
120 * auseinander.
121 */
122 const STARTFARBEN: Readonly<Record<ThemeAufgeloest, string>> = Object.freeze({
123 hell: '#f7f7f5',
124 dunkel: '#14161a',
125 hochkontrast: '#000000',
126 });
127
128 /**
129 * Hintergrundfarbe des Fensters für den Augenblick vor dem ersten Frame.
130 *
131 * `BrowserWindow` zeichnet diese Farbe, solange der Renderer noch nichts
132 * geliefert hat. Sie muss deshalb der **gespeicherten Wahl** folgen und nicht
133 * dem Systemdesign: Wer „Dunkel“ eingestellt hat, während das Betriebssystem
134 * hell läuft, bekäme sonst bei jedem Start ein helles Aufblitzen – dieselbe
135 * Falle, die bei der Anzeigegröße schon einmal zugeschlagen hat.
136 *
137 * Bei erzwungenen Farben (Windows-Kontrastdesign) gilt das nicht: Dort
138 * liefert das Betriebssystem die Farben, und `tokens.css` greift auf `Canvas`
139 * zurück. Ein helles Kontrastdesign ist weiß, ein dunkles schwarz – eine
140 * eigene Farbe wäre hier schlicht falsch.
141 */
142 export function startfarbeFuer(auswahl: ThemeAuswahl, signale: SystemAnzeigeSignale): string {
143 if (signale.erzwungeneFarben) {
144 return signale.bevorzugtDunkel ? '#000000' : '#ffffff';
145 }
146 return STARTFARBEN[themeAufloesen(auswahl, signale)];
147 }
148
149 /**
150 * Satz für die `aria-live`-Region nach einer Umschaltung.
151 *
152 * **Bei erzwungenen Farben wird nichts umgestellt, und das muss dastehen.**
153 * `themeAufloesen` gibt dem Windows-Kontrastdesign unbedingt Vorrang: Die
154 * Wahl wird gespeichert, sie wirkt aber erst, wenn das Kontrastdesign wieder
155 * aus ist. Bis Fassung 0.24.1 meldete die Ansage trotzdem „Darstellung
156 * umgestellt auf Dunkel“ – für jemanden, der die Umstellung nicht sehen
157 * kann, die denkbar irreführendste Auskunft.
158 */
159 export function umschaltAnsage(
160 auswahl: ThemeAuswahl,
161 aufgeloest: ThemeAufgeloest,
162 signale: SystemAnzeigeSignale = SYSTEM_SIGNALE_STANDARD,
163 ): string {
164 if (signale.erzwungeneFarben) {
165 return (
166 `Ihre Wahl ${THEME_BESCHRIFTUNGEN[auswahl]} ist gespeichert. ` +
167 'Sichtbar wird sie erst, wenn das Kontrastdesign von Windows wieder ' +
168 'ausgeschaltet ist – solange es läuft, bestimmt das Betriebssystem die Farben.'
169 );
170 }
171 if (auswahl === 'system') {
172 return `Darstellung folgt der Systemvorgabe. Aktiv ist ${THEME_BESCHRIFTUNGEN[aufgeloest]}.`;
173 }
174 return `Darstellung umgestellt auf ${THEME_BESCHRIFTUNGEN[auswahl]}.`;
175 }