feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner

- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung
  /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor);
  Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel
  bauen die Seite nicht neu auf.
- Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf,
  Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue.
- Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle
  Tokens, danger-/warn-/ok-text-Tokens statt fester Farben.
- Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg.
- User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:39:09 +00:00
1 parent 1a64e12bc5
commit 25ff496548
30 files changed
+1783 -189

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mountDonateBanner } from './donate-banner';
const KEY = 'aircard.donateBannerClosed';
const URL_ = 'https://buymeacoffee.com/aircard';
const TEXT = '☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.';
let slot: HTMLElement;
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '<div id="donate-banner"></div>';
slot = document.getElementById('donate-banner') as HTMLElement;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('mountDonateBanner', () => {
it('leere URL: nichts wird gerendert', () => {
mountDonateBanner(slot, '');
expect(slot.childElementCount).toBe(0);
mountDonateBanner(slot, ' ');
expect(slot.childElementCount).toBe(0);
});
it('zeigt Text, Link „Unterstützen →“ (neuer Tab) und Schließen-Knopf', () => {
mountDonateBanner(slot, URL_);
expect(slot.querySelector('.donate-banner-text')?.textContent).toBe(TEXT);
const link = slot.querySelector<HTMLAnchorElement>('a.donate-banner-link')!;
expect(link.textContent).toBe('Unterstützen →');
expect(link.getAttribute('href')).toBe(URL_);
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(link.hasAttribute('data-link')).toBe(false);
expect(slot.querySelector('button')?.getAttribute('aria-label')).toBe('Banner schließen');
});
it('Schließen setzt das Flag und entfernt das Banner', () => {
mountDonateBanner(slot, URL_);
slot.querySelector('button')!.click();
expect(localStorage.getItem(KEY)).toBe('1');
expect(slot.childElementCount).toBe(0);
});
it('Flag gesetzt: nichts wird gerendert', () => {
localStorage.setItem(KEY, '1');
mountDonateBanner(slot, URL_);
expect(slot.childElementCount).toBe(0);
});
it('zweimal eingehängt gibt es nur ein Banner', () => {
mountDonateBanner(slot, URL_);
mountDonateBanner(slot, URL_);
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
});
it('localStorage wirft beim Lesen: Banner wird gezeigt', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blockiert', 'SecurityError');
});
expect(() => mountDonateBanner(slot, URL_)).not.toThrow();
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
});
it('localStorage wirft beim Schreiben: Banner verschwindet trotzdem, kein Fehler', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
mountDonateBanner(slot, URL_);
expect(() => slot.querySelector('button')!.click()).not.toThrow();
expect(slot.childElementCount).toBe(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
// Schließbares Spenden-Banner ganz oben. Ohne Spenden-Link (config.ts) oder nach dem Schließen wird nichts angezeigt.
import './shell.css';
import { el } from '../editor/dom';
const CLOSED_KEY = 'aircard.donateBannerClosed';
function isClosed(): boolean {
try {
return localStorage.getItem(CLOSED_KEY) !== null;
} catch {
return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis die Seite neu geladen wird
}
}
function rememberClosed(): void {
try {
localStorage.setItem(CLOSED_KEY, '1');
} catch {
// gilt dann nur für diese Sitzung
}
}
export function mountDonateBanner(slot: HTMLElement, url: string): void {
slot.replaceChildren();
if (!url.trim() || isClosed()) return;
const close = el('button', { type: 'button', class: 'donate-banner-close', 'aria-label': 'Banner schließen' }, '×');
close.addEventListener('click', () => {
rememberClosed();
slot.replaceChildren();
});
slot.append(
el(
'div',
{ class: 'donate-banner' },
el(
'p',
{ class: 'donate-banner-text' },
el('span', { 'aria-hidden': 'true' }, '☕'),
' Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.',
),
el('a', { class: 'donate-banner-link', href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Unterstützen →'),
close,
),
);
}
+206
View File
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }));
vi.mock('../pages/router', () => ({ navigate: mocks.navigate }));
import { mountHeader } from './header';
const DONATE = 'https://buymeacoffee.com/aircard';
const html = document.documentElement;
let header: HTMLElement;
let dispose: (() => void) | null = null;
function mount(donateUrl = '') {
dispose = mountHeader(header, { donateUrl });
}
const search = () => header.querySelector<HTMLFormElement>('form.header-search')!;
const input = () => search().querySelector<HTMLInputElement>('input')!;
const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
beforeEach(() => {
mocks.navigate.mockReset();
localStorage.clear();
html.setAttribute('data-theme', 'dark');
document.body.innerHTML =
'<header id="app-header">' +
'<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>' +
'<div id="account-slot"></div>' +
'</header>';
header = document.getElementById('app-header') as HTMLElement;
});
afterEach(() => {
dispose?.();
dispose = null;
});
describe('mountHeader: Aufbau', () => {
it('lässt Marke und Konto-Slot stehen und ergänzt Navigation, Suche und Knöpfe', () => {
const brand = header.querySelector('.brand');
const slot = header.querySelector('#account-slot');
mount();
expect(header.querySelector('.brand')).toBe(brand);
expect(header.querySelector('#account-slot')).toBe(slot);
expect(header.firstElementChild).toBe(brand);
expect(header.lastElementChild).toBe(slot);
expect(nav()).not.toBeNull();
expect(search()).not.toBeNull();
});
it('Navigation: Community, Gestalten, Meine Karten als data-link', () => {
mount();
const links = [...nav().querySelectorAll<HTMLAnchorElement>('a')].filter((a) => !a.classList.contains('nav-donate'));
expect(links.map((a) => [a.textContent, a.getAttribute('href'), a.hasAttribute('data-link')])).toEqual([
['Community', '/#community', true],
['Gestalten', '/gestalten', true],
['Meine Karten', '/karten', true],
]);
expect(nav().getAttribute('aria-label')).toBe('Hauptnavigation');
});
it('Suchfeld mit Platzhalter und Beschriftung', () => {
mount();
expect(input().placeholder).toBe('Titel, Tags oder Herausgeber');
expect(input().type).toBe('search');
expect(input().getAttribute('aria-label')).toBe('Community-Karten durchsuchen');
expect(search().getAttribute('role')).toBe('search');
});
});
describe('mountHeader: Suche', () => {
it('Enter mit „ visa “ navigiert zu /?q=visa#community', () => {
mount();
input().value = ' visa ';
submit();
expect(mocks.navigate).toHaveBeenCalledOnce();
expect(mocks.navigate).toHaveBeenCalledWith('/?q=visa#community');
});
it('kodiert Sonderzeichen', () => {
mount();
input().value = 'a b&c=d#e';
submit();
expect(mocks.navigate).toHaveBeenCalledWith('/?q=a%20b%26c%3Dd%23e#community');
});
it('leere oder nur aus Leerzeichen bestehende Eingabe: nichts passiert', () => {
mount();
input().value = '';
submit();
input().value = ' ';
submit();
expect(mocks.navigate).not.toHaveBeenCalled();
});
it('das Absenden lädt die Seite nicht neu (Standardaktion verhindert)', () => {
mount();
input().value = 'visa';
const event = new Event('submit', { bubbles: true, cancelable: true });
search().dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
});
describe('mountHeader: Spenden-Knopf', () => {
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
mount('');
expect(header.querySelector('.header-donate')).toBeNull();
expect(header.querySelector('.nav-donate')).toBeNull();
expect(header.textContent).not.toContain('☕');
});
it('mit donateUrl: Link „☕ Unterstützen“ in neuem Tab', () => {
mount(DONATE);
const link = header.querySelector<HTMLAnchorElement>('a.header-donate')!;
expect(link.getAttribute('href')).toBe(DONATE);
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(link.textContent).toContain('☕');
expect(link.textContent).toContain('Unterstützen');
expect(link.hasAttribute('data-link')).toBe(false);
});
});
describe('mountHeader: Hell/Dunkel', () => {
it('Knopf „Hell/Dunkel umschalten“ wechselt data-theme und speichert', () => {
mount();
const button = header.querySelector<HTMLButtonElement>('button.theme-btn')!;
expect(button.getAttribute('aria-label')).toBe('Hell/Dunkel umschalten');
button.click();
expect(html.getAttribute('data-theme')).toBe('light');
expect(localStorage.getItem('aircard.theme')).toBe('light');
button.click();
expect(html.getAttribute('data-theme')).toBe('dark');
});
});
describe('mountHeader: Menü unter 900 px', () => {
it('Menü-Knopf öffnet und schließt die Navigation (aria-expanded)', () => {
mount();
const button = menuButton();
expect(button.getAttribute('aria-label')).toBe('Menü');
expect(button.getAttribute('aria-controls')).toBe(nav().id);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(nav().classList.contains('is-open')).toBe(false);
button.click();
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(nav().classList.contains('is-open')).toBe(true);
button.click();
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(nav().classList.contains('is-open')).toBe(false);
});
it('Escape schließt das Menü und gibt den Fokus an den Knopf', () => {
mount();
menuButton().click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(nav().classList.contains('is-open')).toBe(false);
expect(menuButton().getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(menuButton());
});
it('ein Klick auf einen Navigationslink oder außerhalb schließt das Menü', () => {
mount();
menuButton().click();
nav().querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(nav().classList.contains('is-open')).toBe(false);
menuButton().click();
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(nav().classList.contains('is-open')).toBe(false);
});
it('Suchen schließt das Menü ebenfalls', () => {
mount();
menuButton().click();
input().value = 'visa';
submit();
expect(nav().classList.contains('is-open')).toBe(false);
});
});
describe('mountHeader: Aufräumen', () => {
it('entfernt Ergänztes; Marke und Konto-Slot bleiben', () => {
mount(DONATE);
dispose?.();
dispose = null;
expect([...header.children].map((c) => c.className || c.id)).toEqual(['brand', 'account-slot']);
});
it('meldet alle Dokument-Listener wieder ab', () => {
const added = vi.spyOn(document, 'addEventListener');
const removed = vi.spyOn(document, 'removeEventListener');
mount(DONATE);
expect(added).toHaveBeenCalled();
dispose?.();
dispose = null;
for (const [type, listener] of added.mock.calls) {
expect(removed.mock.calls.some(([t, l]) => t === type && l === listener)).toBe(true);
}
vi.restoreAllMocks();
});
});
+113
View File
@@ -0,0 +1,113 @@
// Kopfzeile: Marke und Konto-Slot stehen in index.html, hier kommen Navigation, Suche und Knöpfe dazu.
// Ab 900 px Breite liegt alles in einer Zeile; darunter steckt die Navigation hinter dem Menü-Knopf
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
import './shell.css';
import { el, icon } from '../editor/dom';
import { navigate } from '../pages/router';
import { toggleTheme } from './theme';
export interface HeaderOptions {
/** Spenden-Link; leer = kein Spenden-Knopf. */
donateUrl: string;
}
export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => void {
const brand = header.querySelector('.brand');
const accountSlot = header.querySelector('#account-slot');
const hasDonate = opts.donateUrl.trim() !== '';
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
const nav = el(
'nav',
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
link('Community', '/#community'),
link('Gestalten', '/gestalten'),
link('Meine Karten', '/karten'),
);
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
const input = el('input', {
class: 'header-search-input',
type: 'search',
name: 'q',
placeholder: 'Titel, Tags oder Herausgeber',
'aria-label': 'Community-Karten durchsuchen',
autocomplete: 'off',
enterkeyhint: 'search',
});
const searchButton = el('button', { class: 'header-search-btn', type: 'submit', 'aria-label': 'Suchen' }, icon('search'));
const search = el('form', { class: 'header-search', role: 'search' }, input, searchButton);
const themeButton = el(
'button',
{ class: 'header-btn theme-btn', type: 'button', 'aria-label': 'Hell/Dunkel umschalten', title: 'Hell/Dunkel umschalten' },
icon('sun', 'theme-icon-sun'),
icon('moon', 'theme-icon-moon'),
);
themeButton.addEventListener('click', () => void toggleTheme());
const menuButton = el(
'button',
{ class: 'header-btn menu-btn', type: 'button', 'aria-label': 'Menü', 'aria-expanded': 'false', 'aria-controls': nav.id },
icon('menu'),
);
const tools = el('div', { class: 'header-tools' });
if (hasDonate) {
tools.append(
el(
'a',
{ class: 'header-donate', ...donateAttrs },
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
),
);
}
tools.append(themeButton, menuButton);
// Menü (nur unter 900 px sichtbar)
let open = false;
function setOpen(next: boolean): void {
open = next;
nav.classList.toggle('is-open', next);
menuButton.setAttribute('aria-expanded', String(next));
}
menuButton.addEventListener('click', () => setOpen(!open));
const onDocumentClick = (event: MouseEvent) => {
if (!open) return;
const target = event.target as Element | null;
if (target && menuButton.contains(target)) return; // das erledigt der Knopf selbst
if (target && nav.contains(target) && !target.closest('a')) return;
setOpen(false);
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !open) return;
setOpen(false);
menuButton.focus();
};
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onKeydown);
search.addEventListener('submit', (event) => {
event.preventDefault();
const q = input.value.trim();
if (!q) return;
setOpen(false);
input.blur(); // schließt auf dem Handy die Bildschirmtastatur
navigate(`/?q=${encodeURIComponent(q)}#community`);
});
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
return () => {
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onKeydown);
nav.remove();
search.remove();
tools.remove();
};
}
+416
View File
@@ -0,0 +1,416 @@
/* Rahmen der Seite: Spenden-Banner, Kopfzeile (Marke, Navigation, Suche, Knöpfe, Konto). */
/* Spenden-Banner */
.donate-banner {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2px 14px;
padding: 8px 48px 8px 16px;
background: var(--primary);
color: var(--on-primary);
font-size: 0.95rem;
line-height: 1.35;
text-align: center;
}
.donate-banner-text {
margin: 0;
}
.donate-banner-link {
color: inherit;
font-weight: 800;
text-decoration: underline;
text-underline-offset: 3px;
white-space: nowrap;
}
.donate-banner-link:hover {
text-decoration-thickness: 2px;
}
.donate-banner :focus-visible {
outline-color: var(--on-primary);
}
.donate-banner-close {
appearance: none;
position: absolute;
top: 50%;
right: 6px;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: -20px 0 0;
padding: 0;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font: inherit;
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
}
.donate-banner-close:hover {
background: rgb(255 255 255 / 0.16);
}
/* Kopfzeile */
#app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 20px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
margin-right: 6px;
color: var(--text);
font-size: 1.35rem;
font-weight: 800;
letter-spacing: 0.01em;
text-decoration: none;
white-space: nowrap;
}
.brand img {
display: block;
flex: none;
width: 28px;
height: 28px;
}
.app-nav {
display: flex;
align-items: center;
gap: 2px;
}
.app-nav a {
padding: 8px 12px;
border-radius: 10px;
color: var(--muted);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.app-nav a:hover {
background: var(--panel-2);
color: var(--text);
}
.nav-donate {
display: none;
}
/* Suche */
.header-search {
display: flex;
flex: 1 1 120px; /* kleine Basis: die Zeile bricht erst um, wenn auch das Suchfeld nicht mehr schrumpfen kann */
align-items: center;
max-width: 360px;
min-width: 0;
height: 42px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--bg);
}
.header-search:focus-within {
border-color: var(--sel-line);
outline: 2px solid var(--sel-line);
outline-offset: 1px;
}
.header-search-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 4px 0 16px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 1rem; /* mindestens 16 px, sonst zoomt iOS beim Fokussieren */
outline: none;
appearance: none;
}
.header-search-input::placeholder {
color: var(--muted);
opacity: 1;
}
.header-search-input::-webkit-search-cancel-button {
appearance: none;
}
.header-search-btn {
appearance: none;
display: grid;
flex: none;
place-items: center;
width: 40px;
height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 0 999px 999px 0;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.header-search-btn:hover {
color: var(--text);
}
/* Knöpfe rechts: Spenden, Hell/Dunkel, Menü */
.header-tools {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.header-btn {
appearance: none;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: transparent;
color: var(--text);
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.header-btn:hover {
border-color: var(--sel-line);
background: var(--panel-2);
}
.header-btn .icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.header-search-btn .icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Sonne im dunklen Theme (Klick = hell), Mond im hellen (Klick = dunkel); ohne Attribut gilt dunkel */
:root[data-theme='light'] .theme-icon-sun,
:root:not([data-theme='light']) .theme-icon-moon {
display: none;
}
.menu-btn {
display: none;
}
.header-donate {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--sel-line);
border-radius: 12px;
background: var(--sel-bg);
color: var(--text);
font-weight: 700;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.12s;
}
.header-donate:hover {
background: var(--panel-2);
}
/* Konto-Slot */
#account-slot {
display: flex;
align-items: center;
min-width: 0;
}
/* Bis 1179 px zeigt der Spenden-Knopf nur die Tasse; der Text bleibt für Screenreader */
@media (max-width: 1179px) {
.header-donate {
justify-content: center;
width: 40px;
padding: 0;
}
.header-donate-text {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
/* Desktop mit wenig Platz (900–1099 px): alles etwas enger, damit die Zeile nicht umbricht */
@media (min-width: 900px) and (max-width: 1099px) {
#app-header {
gap: 10px;
}
.brand {
margin-right: 0;
font-size: 1.2rem;
}
.app-nav a {
padding: 8px 9px;
font-size: 0.95rem;
}
}
/* Unter 900 px: Navigation hinter dem Menü-Knopf, Suchfeld als eigene Zeile darunter */
@media (max-width: 899px) {
#app-header {
padding: 10px 16px;
gap: 10px 8px;
}
.menu-btn {
display: grid;
}
.header-tools {
order: 1;
}
#account-slot {
order: 2;
}
.app-nav {
display: none;
flex-basis: 100%;
flex-direction: column;
align-items: stretch;
order: 3;
padding: 6px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--bg);
}
.app-nav.is-open {
display: flex;
}
.app-nav a {
padding: 12px 14px;
font-size: 1.05rem;
}
.header-search {
flex: 1 1 100%;
order: 4;
max-width: none;
}
}
/* Unter 600 px: Marke und Knöpfe etwas kleiner, damit „Anmelden“ noch in die erste Zeile passt;
der Spenden-Knopf wandert ins aufgeklappte Menü */
@media (max-width: 599px) {
#app-header {
gap: 10px 6px;
}
.brand {
gap: 8px;
margin-right: 0;
font-size: 1.2rem;
}
.brand img {
width: 24px;
height: 24px;
}
.header-tools {
gap: 6px;
}
.header-btn {
width: 38px;
height: 38px;
}
#account-slot .auth-btn-small {
min-height: 38px;
padding: 0 12px;
}
.header-donate {
display: none;
}
.nav-donate {
display: block;
color: var(--text);
}
}
/* Sehr schmale Telefone */
@media (max-width: 379px) {
#app-header {
padding: 10px 12px;
}
.brand {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.header-btn,
.header-donate {
transition: none;
}
}
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config';
import { renderCard, type CardAssets, type Ctx } from '../card/render';
import { applyTheme, currentTheme, initialTheme, initTheme, toggleTheme } from './theme';
const KEY = 'aircard.theme';
const html = document.documentElement;
function mockMatchMedia(prefersDark: boolean) {
window.matchMedia = vi.fn((query: string) => ({
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
beforeEach(() => {
localStorage.clear();
html.removeAttribute('data-theme');
document.head.innerHTML = '<meta name="theme-color" content="#0F1828" />';
mockMatchMedia(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initialTheme', () => {
it('ohne gespeicherten Wert gilt das System', () => {
expect(initialTheme(null, true)).toBe('dark');
expect(initialTheme(null, false)).toBe('light');
});
it('ein gespeicherter Wert schlägt das System', () => {
expect(initialTheme('light', true)).toBe('light');
expect(initialTheme('dark', false)).toBe('dark');
});
it('ein ungültiger Wert wird ignoriert (System)', () => {
expect(initialTheme('blau', true)).toBe('dark');
expect(initialTheme('blau', false)).toBe('light');
expect(initialTheme('', false)).toBe('light');
expect(initialTheme('LIGHT', true)).toBe('dark');
});
});
describe('applyTheme', () => {
it('setzt data-theme auf <html> und die Browserfarbe, speichert aber nichts', () => {
applyTheme('light');
expect(html.getAttribute('data-theme')).toBe('light');
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#F4F6FB');
applyTheme('dark');
expect(html.getAttribute('data-theme')).toBe('dark');
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#0F1828');
expect(localStorage.getItem(KEY)).toBeNull();
});
it('kommt auch ohne theme-color-Tag klar', () => {
document.head.innerHTML = '';
expect(() => applyTheme('light')).not.toThrow();
expect(html.getAttribute('data-theme')).toBe('light');
});
});
describe('initTheme', () => {
it('nimmt den gespeicherten Wert', () => {
localStorage.setItem(KEY, 'light');
expect(initTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
});
it('ohne gespeicherten Wert das System (dunkel bzw. hell)', () => {
expect(initTheme()).toBe('dark');
mockMatchMedia(false);
expect(initTheme()).toBe('light');
});
it('wirft localStorage beim Lesen, gilt das System', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blockiert', 'SecurityError');
});
mockMatchMedia(false);
expect(initTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
});
it('fehlt matchMedia, bleibt es dunkel', () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
expect(initTheme()).toBe('dark');
});
});
describe('toggleTheme', () => {
it('wechselt zwischen dunkel und hell, setzt data-theme und speichert', () => {
applyTheme('dark');
expect(toggleTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
expect(localStorage.getItem(KEY)).toBe('light');
expect(toggleTheme()).toBe('dark');
expect(html.getAttribute('data-theme')).toBe('dark');
expect(localStorage.getItem(KEY)).toBe('dark');
});
it('ohne data-theme geht es vom Systemwert aus', () => {
mockMatchMedia(false); // System hell → Wechsel auf dunkel
expect(toggleTheme()).toBe('dark');
});
it('wirft localStorage beim Schreiben, gibt es keinen Fehler und der Wechsel gilt trotzdem', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
applyTheme('dark');
expect(() => toggleTheme()).not.toThrow();
expect(html.getAttribute('data-theme')).toBe('light');
expect(currentTheme()).toBe('light');
});
});
describe('Theme und Kartenausgabe', () => {
// Umschalten verändert nur UI-Tokens: der Renderer liest weder data-theme noch CSS-Variablen.
function drawLog(): string[] {
const log: string[] = [];
const state: Record<string, unknown> = {};
const ctx = new Proxy(state, {
get(target, prop: string) {
if (prop in target) return target[prop];
if (prop === 'measureText') return (text: string) => ({ width: text.length * 20 });
if (prop === 'createLinearGradient') {
return (...args: number[]) => {
log.push(`gradient(${args.join(',')})`);
return { addColorStop: (offset: number, color: string) => log.push(`stop(${offset},${color})`) };
};
}
return (...args: unknown[]) => void log.push(`${prop}(${args.map((a) => (typeof a === 'object' ? 'obj' : String(a))).join(',')})`);
},
set(target, prop: string, value) {
target[prop] = value;
log.push(`${prop}=${String(value)}`);
return true;
},
});
const assets: CardAssets = { image: null, network: {} as CanvasImageSource, brand: {} as CanvasImageSource };
renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath'));
return log;
}
it('hell und dunkel zeichnen dieselbe Karte', () => {
applyTheme('dark');
const dark = drawLog();
applyTheme('light');
const light = drawLog();
expect(dark.length).toBeGreaterThan(10);
expect(light).toEqual(dark);
expect(dark.join('\n')).toContain('fillStyle=#1B2738');
});
});
+63
View File
@@ -0,0 +1,63 @@
// Hell/Dunkel: `data-theme` auf <html>, Auswahl in localStorage `aircard.theme`, Standard = System.
// Das Skript im <head> von index.html setzt das Attribut schon vor dem ersten Paint (kein Aufblitzen).
// Das Theme betrifft nur die Oberfläche (CSS-Tokens); die Karte selbst (Vorschau, PNG) bleibt davon unberührt.
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'aircard.theme';
// Farbe der Browserleiste (Handy), passend zu `--bg` des jeweiligen Themes.
const BROWSER_COLOR: Record<Theme, string> = { dark: '#0F1828', light: '#F4F6FB' };
/** Gespeicherter Wert, sonst System; ein ungültiger Wert zählt als „nichts gespeichert“. */
export function initialTheme(stored: string | null, prefersDark: boolean): Theme {
if (stored === 'light' || stored === 'dark') return stored;
return prefersDark ? 'dark' : 'light';
}
function readStored(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt das System
}
}
function systemPrefersDark(): boolean {
try {
// Ohne Angabe (alter Browser) bleibt es beim bisherigen dunklen Look.
return window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : true;
} catch {
return true;
}
}
/** Setzt das Theme (Attribut und Browserleiste), ohne etwas zu speichern. */
export function applyTheme(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', BROWSER_COLOR[theme]);
}
/** Das gerade angezeigte Theme (Attribut, sonst gespeicherter Wert bzw. System). */
export function currentTheme(): Theme {
const attr = document.documentElement.getAttribute('data-theme');
return attr === 'light' || attr === 'dark' ? attr : initialTheme(readStored(), systemPrefersDark());
}
/** Wendet beim Start das gespeicherte bzw. das System-Theme an. */
export function initTheme(): Theme {
const theme = initialTheme(readStored(), systemPrefersDark());
applyTheme(theme);
return theme;
}
/** Wechselt Hell ↔ Dunkel und merkt es sich; ein gesperrter Speicher ist kein Fehler. */
export function toggleTheme(): Theme {
const next: Theme = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// gilt dann nur für diese Sitzung
}
return next;
}