diff --git a/web/index.html b/web/index.html
index f72b352..792ce73 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,19 +3,30 @@
- AirCard-IOS – Kartenmotiv gestalten
+ AirCard-IOS – Kartendesigns für Apple Wallet
+
+
diff --git a/web/src/account/account-menu.test.ts b/web/src/account/account-menu.test.ts
new file mode 100644
index 0000000..c14e0a5
--- /dev/null
+++ b/web/src/account/account-menu.test.ts
@@ -0,0 +1,140 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+type TestUser = { $id: string; email: string; labels?: string[] };
+
+const mocks = vi.hoisted(() => ({
+ user: null as TestUser | null,
+ listeners: new Set<(user: TestUser | null) => void>(),
+ logout: vi.fn(),
+ requireLogin: vi.fn(),
+}));
+
+vi.mock('./auth', () => ({
+ currentUser: () => mocks.user,
+ logout: mocks.logout,
+ onAuthChange: (fn: (user: TestUser | null) => void) => {
+ mocks.listeners.add(fn);
+ return () => mocks.listeners.delete(fn);
+ },
+}));
+vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
+
+import { mountAccountMenu, type AccountMenu } from './account-menu';
+
+let slot: HTMLElement;
+let menu: AccountMenu;
+
+const avatar = () => slot.querySelector('button.account-avatar');
+const panel = () => slot.querySelector('.account-dropdown');
+const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
+
+function setUser(user: TestUser | null) {
+ mocks.user = user;
+ for (const fn of [...mocks.listeners]) fn(user);
+}
+
+beforeEach(() => {
+ mocks.user = null;
+ mocks.listeners.clear();
+ mocks.logout.mockReset();
+ mocks.requireLogin.mockReset();
+ document.body.innerHTML = '';
+ slot = document.getElementById('account-slot') as HTMLElement;
+ menu = mountAccountMenu(slot);
+});
+
+describe('Konto-Menü', () => {
+ it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
+ expect(slot.childElementCount).toBe(0);
+ });
+
+ it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
+ menu.ready();
+ buttonByText('Anmelden')!.click();
+ expect(mocks.requireLogin).toHaveBeenCalledWith('login');
+ expect(avatar()).toBeNull();
+ });
+
+ it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ menu.ready();
+ expect(avatar()?.textContent).toBe('K');
+ expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
+ expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
+ expect(panel()?.hidden).toBe(true);
+ });
+
+ it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ menu.ready();
+ avatar()!.click();
+ expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
+ expect(panel()?.hidden).toBe(false);
+ expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
+ const link = panel()!.querySelector('a[data-link]')!;
+ expect(link.textContent).toBe('Meine Karten');
+ expect(link.getAttribute('href')).toBe('/karten');
+ expect(buttonByText('Abmelden')).toBeDefined();
+ });
+
+ it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ menu.ready();
+ avatar()!.click();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ expect(panel()?.hidden).toBe(true);
+ expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
+ expect(document.activeElement).toBe(avatar());
+ });
+
+ it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ menu.ready();
+ avatar()!.click();
+ document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(panel()?.hidden).toBe(true);
+
+ avatar()!.click();
+ panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ expect(panel()?.hidden).toBe(true);
+ });
+
+ it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ menu.ready();
+ avatar()!.click();
+ panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(panel()?.hidden).toBe(false);
+ });
+
+ it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
+ menu.ready();
+ avatar()!.click();
+ const signOut = buttonByText('Abmelden')!;
+ signOut.click();
+ expect(mocks.logout).toHaveBeenCalledOnce();
+ expect(signOut.disabled).toBe(true);
+ await vi.waitFor(() => expect(signOut.disabled).toBe(false));
+ expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
+ expect(panel()?.hidden).toBe(false);
+ });
+
+ it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ menu.ready();
+ avatar()!.click();
+ setUser(null);
+ expect(avatar()).toBeNull();
+ expect(buttonByText('Anmelden')).toBeDefined();
+ });
+
+ it('dispose leert den Slot und meldet den Listener ab', () => {
+ mocks.user = { $id: 'u1', email: 'kenso@example.com' };
+ menu.ready();
+ menu.dispose();
+ expect(slot.childElementCount).toBe(0);
+ expect(mocks.listeners.size).toBe(0);
+ });
+});
diff --git a/web/src/account/account-menu.ts b/web/src/account/account-menu.ts
index 08a05e5..a409825 100644
--- a/web/src/account/account-menu.ts
+++ b/web/src/account/account-menu.ts
@@ -1,4 +1,5 @@
-// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
+// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
+// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
import './account.css';
import { el } from '../editor/dom';
import { currentUser, logout, onAuthChange } from './auth';
@@ -13,8 +14,12 @@ export interface AccountMenu {
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
let isReady = false;
+ // Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
+ let detach: (() => void) | null = null;
function render(): void {
+ detach?.();
+ detach = null;
if (!isReady) {
slot.replaceChildren();
return;
@@ -26,9 +31,52 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
return;
}
+
+ const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
+ const avatar = el(
+ 'button',
+ {
+ type: 'button',
+ class: 'account-avatar',
+ 'aria-label': 'Konto-Menü',
+ 'aria-expanded': 'false',
+ 'aria-controls': 'account-dropdown',
+ },
+ initial,
+ );
const email = el('span', { class: 'account-email', title: user.email }, user.email);
- const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden');
+ const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten');
+ const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden');
const error = el('span', { class: 'account-error', role: 'alert' });
+ const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
+ const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, avatar, dropdown);
+
+ let isOpen = false;
+ function setOpen(open: boolean): void {
+ isOpen = open;
+ dropdown.hidden = !open;
+ avatar.setAttribute('aria-expanded', String(open));
+ }
+ avatar.addEventListener('click', () => setOpen(!isOpen));
+
+ const onDocumentClick = (event: MouseEvent) => {
+ if (!isOpen) return;
+ const target = event.target as Element | null;
+ if (target && wrap.contains(target) && !target.closest('a')) return; // nur Links schließen das Menü selbst
+ setOpen(false);
+ };
+ const onKeydown = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || !isOpen) return;
+ setOpen(false);
+ avatar.focus();
+ };
+ document.addEventListener('click', onDocumentClick);
+ document.addEventListener('keydown', onKeydown);
+ detach = () => {
+ document.removeEventListener('click', onDocumentClick);
+ document.removeEventListener('keydown', onKeydown);
+ };
+
signOut.addEventListener('click', () => {
signOut.disabled = true;
error.textContent = '';
@@ -38,14 +86,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
signOut.disabled = false;
});
});
- slot.replaceChildren(
- el(
- 'div',
- { class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
- el('div', { class: 'account-row' }, email, signOut),
- error,
- ),
- );
+ slot.replaceChildren(wrap);
}
const off = onAuthChange(render);
@@ -58,6 +99,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
},
dispose() {
off();
+ detach?.();
+ detach = null;
slot.replaceChildren();
},
};
diff --git a/web/src/account/account.css b/web/src/account/account.css
index 3a16bc0..9316271 100644
--- a/web/src/account/account.css
+++ b/web/src/account/account.css
@@ -25,6 +25,7 @@
.auth-btn-primary {
border-color: var(--primary);
background: var(--primary);
+ color: var(--on-primary);
font-weight: 700;
}
@@ -35,7 +36,7 @@
.auth-btn-danger {
border-color: var(--danger);
- color: #ff9a9d;
+ color: var(--danger-text);
}
.auth-btn-danger:hover:not(:disabled) {
@@ -70,43 +71,104 @@
cursor: not-allowed;
}
-/* Konto-Menü im Seitenkopf */
-
-#account-slot {
- min-width: 0;
-}
+/* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */
.account-menu {
+ position: relative;
display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 4px;
+ align-items: center;
min-width: 0;
max-width: 100%;
}
-.account-row {
+.account-avatar {
+ appearance: none;
+ display: grid;
+ place-items: center;
+ width: 40px;
+ height: 40px;
+ margin: 0;
+ padding: 0;
+ border: 2px solid transparent;
+ border-radius: 50%;
+ background: var(--primary);
+ color: var(--on-primary);
+ font: inherit;
+ font-size: 1.05rem;
+ font-weight: 800;
+ line-height: 1;
+ cursor: pointer;
+ transition: border-color 0.12s;
+}
+
+.account-avatar:hover,
+.account-avatar[aria-expanded='true'] {
+ border-color: var(--sel-line);
+}
+
+.account-dropdown {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ z-index: 40;
display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
- max-width: 100%;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 220px;
+ max-width: min(320px, calc(100vw - 32px));
+ padding: 8px;
+ border: 1px solid var(--line-strong);
+ border-radius: 14px;
+ background: var(--panel);
+ box-shadow: var(--shadow-lg);
+}
+
+.account-dropdown[hidden] {
+ display: none;
}
.account-email {
min-width: 0;
- max-width: 28ch;
+ margin-bottom: 4px;
+ padding: 6px 12px 10px;
overflow: hidden;
+ border-bottom: 1px solid var(--line);
color: var(--muted);
- font-size: 0.95rem;
+ font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
+.account-item {
+ appearance: none;
+ display: block;
+ width: 100%;
+ margin: 0;
+ padding: 10px 12px;
+ border: 0;
+ border-radius: 10px;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ font-weight: 600;
+ text-align: left;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.account-item:hover:not(:disabled) {
+ background: var(--panel-2);
+}
+
+.account-item:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
.account-error {
- color: #ff9a9d;
+ padding: 4px 12px 0;
+ color: var(--danger-text);
font-size: 0.85rem;
- text-align: right;
}
.account-error:empty {
@@ -124,11 +186,11 @@
border-radius: 24px;
background: var(--panel);
color: var(--text);
- box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
+ box-shadow: var(--shadow-lg);
}
.auth-dialog::backdrop {
- background: rgb(6 11 20 / 0.72);
+ background: var(--backdrop);
}
.auth-card {
@@ -261,11 +323,11 @@
}
.auth-msg-error {
- color: #ff9a9d;
+ color: var(--danger-text);
}
.auth-msg-ok {
- color: #8fd9a8;
+ color: var(--ok-text);
}
.auth-aside {
@@ -289,29 +351,15 @@
margin: 0;
}
-/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
-
@media (max-width: 599px) {
- #app-header {
- flex-wrap: wrap;
- gap: 8px 16px;
- }
-
- #account-slot {
- max-width: 100%;
- }
-
- .account-email {
- max-width: none;
- }
-
.auth-card {
padding: 24px 20px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
- .auth-btn {
+ .auth-btn,
+ .account-avatar {
transition: none;
}
}
diff --git a/web/src/account/auth.test.ts b/web/src/account/auth.test.ts
index 6afd155..ce0f135 100644
--- a/web/src/account/auth.test.ts
+++ b/web/src/account/auth.test.ts
@@ -29,7 +29,7 @@ describe('getCurrentUser', () => {
it('liefert den Nutzer und merkt sich die ID', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
- await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
+ await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(auth.currentUserId()).toBe('u1');
});
@@ -71,7 +71,7 @@ describe('getCurrentUser', () => {
await auth.getCurrentUser();
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
+ expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
await auth.getCurrentUser();
await auth.getCurrentUser();
@@ -80,6 +80,52 @@ describe('getCurrentUser', () => {
});
});
+describe('Labels und isAdmin', () => {
+ it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
+ const auth = await freshAuth();
+ mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
+ await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
+ mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
+ await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
+ mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
+ await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
+ mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
+ await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
+ });
+
+ it('login liefert die Labels ebenfalls', async () => {
+ const auth = await freshAuth();
+ mocks.createEmailPasswordSession.mockResolvedValue({});
+ mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
+ await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
+ expect(auth.currentUser()?.labels).toEqual(['admin']);
+ });
+
+ it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
+ const auth = await freshAuth();
+ const listener = vi.fn();
+ auth.onAuthChange(listener);
+ mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
+ await auth.getCurrentUser();
+ mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
+ await auth.getCurrentUser();
+ expect(listener).toHaveBeenCalledTimes(1);
+ mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
+ await auth.getCurrentUser();
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
+ });
+
+ it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
+ const auth = await freshAuth();
+ expect(auth.isAdmin(null)).toBe(false);
+ expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
+ expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
+ expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
+ expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
+ });
+});
+
describe('login', () => {
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
const auth = await freshAuth();
@@ -87,10 +133,10 @@ describe('login', () => {
auth.onAuthChange(listener);
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
- await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
+ await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u1');
- expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
+ expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
});
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
@@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
- await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
+ await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
@@ -127,7 +173,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
- await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
+ await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
});
@@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
- await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
+ await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
});
});
@@ -188,7 +234,7 @@ describe('register', () => {
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
- await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
+ await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
const params = mocks.create.mock.calls[0][0];
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
expect(typeof params.userId).toBe('string');
diff --git a/web/src/account/auth.ts b/web/src/account/auth.ts
index 0392598..4af7672 100644
--- a/web/src/account/auth.ts
+++ b/web/src/account/auth.ts
@@ -4,7 +4,7 @@ import { ID } from 'appwrite';
import { account } from './appwrite';
import { errorType, isUnauthorized } from './errors';
-export type User = { $id: string; email: string };
+export type User = { $id: string; email: string; labels?: string[] };
const PASSWORD_MIN = 8;
const PASSWORD_MAX = 256;
@@ -27,10 +27,16 @@ function setUser(user: User | null): void {
function toUser(res: unknown): User {
const data = (typeof res === 'object' && res !== null ? res : {}) as Record;
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
- return { $id: data.$id, email: data.email };
+ const labels = Array.isArray(data.labels) ? data.labels.filter((l): l is string => typeof l === 'string') : [];
+ return { $id: data.$id, email: data.email, labels };
}
-/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */
+/** Wahr nur für Konten mit dem Appwrite-Label `admin` (die Rechte prüft der Server; das hier steuert nur die Oberfläche). */
+export function isAdmin(user: User | null): boolean {
+ return (user?.labels ?? []).includes('admin');
+}
+
+/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen (auch der Labels). */
export async function getCurrentUser(): Promise {
let next: User | null;
try {
@@ -39,7 +45,11 @@ export async function getCurrentUser(): Promise {
if (!isUnauthorized(err)) throw err;
next = null;
}
- if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next);
+ const same =
+ next?.$id === cachedUser?.$id &&
+ next?.email === cachedUser?.email &&
+ (next?.labels ?? []).join('\n') === (cachedUser?.labels ?? []).join('\n');
+ if (!same) setUser(next);
return next;
}
diff --git a/web/src/branding.test.ts b/web/src/branding.test.ts
index 52c48d6..76e594a 100644
--- a/web/src/branding.test.ts
+++ b/web/src/branding.test.ts
@@ -14,7 +14,7 @@ function pngInfo(buf: Buffer) {
describe('Name und Kopfbereich', () => {
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
- expect(indexHtml).toContain('AirCard-IOS – Kartenmotiv gestalten');
+ expect(indexHtml).toContain('AirCard-IOS – Kartendesigns für Apple Wallet');
expect(indexHtml).toContain('');
expect(indexHtml).toContain('');
});
diff --git a/web/src/config.ts b/web/src/config.ts
new file mode 100644
index 0000000..a720caa
--- /dev/null
+++ b/web/src/config.ts
@@ -0,0 +1,7 @@
+// Konfiguration der Website.
+
+/**
+ * Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/`).
+ * Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
+ */
+export const DONATE_URL: string = '';
diff --git a/web/src/editor/auth-reset.test.ts b/web/src/editor/auth-reset.test.ts
index 3225304..3ff99c9 100644
--- a/web/src/editor/auth-reset.test.ts
+++ b/web/src/editor/auth-reset.test.ts
@@ -67,7 +67,7 @@ beforeEach(() => {
describe('bindEditorToAuth', () => {
it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => {
- history.replaceState(null, '', '/?karte=k1');
+ history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
design();
@@ -76,7 +76,7 @@ describe('bindEditorToAuth', () => {
expect(store.get().image).toBeNull();
expect(store.get().saved).toBeNull();
expect(location.search).toBe('');
- expect(location.pathname).toBe('/');
+ expect(location.pathname).toBe('/gestalten');
});
it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => {
@@ -90,7 +90,7 @@ describe('bindEditorToAuth', () => {
});
it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => {
- history.replaceState(null, '', '/?karte=k1');
+ history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
const { blob, config } = design();
@@ -172,10 +172,11 @@ describe('bindEditorToAuth', () => {
});
it('lässt andere Teile der Adresse stehen', () => {
- history.replaceState(null, '', '/?karte=k1&x=1#h');
+ history.replaceState(null, '', '/gestalten?karte=k1&x=1#h');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
fake.logout();
+ expect(location.pathname).toBe('/gestalten');
expect(location.search).toBe('?x=1');
expect(location.hash).toBe('#h');
});
diff --git a/web/src/editor/card-url.ts b/web/src/editor/card-url.ts
index 4958c79..12c4b2a 100644
--- a/web/src/editor/card-url.ts
+++ b/web/src/editor/card-url.ts
@@ -1,4 +1,4 @@
-// Die Adresse des Editors kann eine Karte nennen (`/?karte=`).
+// Die Adresse des Editors kann eine Karte nennen (`/gestalten?karte=`; alte Links `/?karte=` leitet main.ts um).
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
export function dropCardParam(): void {
diff --git a/web/src/editor/confirm-dialog.css b/web/src/editor/confirm-dialog.css
index 1939b10..7db9194 100644
--- a/web/src/editor/confirm-dialog.css
+++ b/web/src/editor/confirm-dialog.css
@@ -7,11 +7,11 @@
border-radius: 20px;
background: var(--panel);
color: var(--text);
- box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
+ box-shadow: var(--shadow-lg);
}
.confirm-dialog::backdrop {
- background: rgb(6 11 20 / 0.72);
+ background: var(--backdrop);
}
.confirm-card {
diff --git a/web/src/editor/dom.ts b/web/src/editor/dom.ts
index 2e6b255..df1d6da 100644
--- a/web/src/editor/dom.ts
+++ b/web/src/editor/dom.ts
@@ -25,6 +25,13 @@ const ICONS = {
lock: '',
check: '',
ban: '',
+ sun:
+ '' +
+ '' +
+ '',
+ moon: '',
+ menu: '',
+ search: '',
} as const;
export type IconName = keyof typeof ICONS;
diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts
index 0abde94..5c1e2ab 100644
--- a/web/src/editor/editor-page.test.ts
+++ b/web/src/editor/editor-page.test.ts
@@ -297,8 +297,8 @@ describe('Speichern', () => {
});
});
-describe('Karte öffnen (/?karte=)', () => {
- const open = (id: string) => history.replaceState(null, '', `/?karte=${id}`);
+describe('Karte öffnen (/gestalten?karte=)', () => {
+ const open = (id: string) => history.replaceState(null, '', `/gestalten?karte=${id}`);
it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => {
open('k1');
@@ -531,7 +531,7 @@ describe('Karte öffnen (/?karte=)', () => {
expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob);
expect(store.get().image).toBe(img);
- expect(location.pathname + location.search).toBe('/');
+ expect(location.pathname + location.search).toBe('/gestalten');
});
it('Esc im Dialog verwirft nichts', async () => {
@@ -733,7 +733,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
});
it('ist nach dem Öffnen einer Karte da', async () => {
- history.replaceState(null, '', '/?karte=k1');
+ history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
@@ -758,7 +758,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
describe('„Neue Karte“', () => {
it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => {
- history.replaceState(null, '', '/?karte=k1');
+ history.replaceState(null, '', '/gestalten?karte=k1');
const { config } = boundEditor();
mount();
newCardButton().click();
@@ -774,7 +774,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
expect(s.config).not.toBe(DEFAULT_CONFIG);
expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop);
expect(s.config).not.toBe(config);
- expect(location.pathname + location.search).toBe('/');
+ expect(location.pathname + location.search).toBe('/gestalten');
expect(boundRow().hidden).toBe(true);
});
@@ -787,7 +787,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
});
it('bricht eine noch ladende Karte ab', async () => {
- history.replaceState(null, '', '/?karte=k2');
+ history.replaceState(null, '', '/gestalten?karte=k2');
boundEditor();
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise((r) => (finish = r)));
@@ -869,7 +869,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
});
it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => {
- history.replaceState(null, '', '/?karte=k1');
+ history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image(3000, 2000));
@@ -928,7 +928,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
describe('Abmelden während des Ladens', () => {
it('eine noch ladende Karte landet nicht mehr im Editor', async () => {
- history.replaceState(null, '', '/?karte=k1');
+ history.replaceState(null, '', '/gestalten?karte=k1');
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise((r) => (finish = r)));
mount();
diff --git a/web/src/editor/editor.css b/web/src/editor/editor.css
index 6af003a..b52a288 100644
--- a/web/src/editor/editor.css
+++ b/web/src/editor/editor.css
@@ -183,6 +183,13 @@
-webkit-user-drag: none;
}
+/* Die Logos sind für die dunkle Karte gemacht (weiß, silbern, Umriss): im hellen Theme stehen sie wie auf der Karte auf deren Grund. */
+:root[data-theme='light'] .mark-img {
+ padding: 5px 8px;
+ border-radius: 8px;
+ background: #1b2738;
+}
+
.mark-none {
width: 46px;
height: 46px;
@@ -356,7 +363,7 @@
height: auto;
border-radius: var(--card-radius);
background: #1b2738;
- box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
+ box-shadow: var(--shadow-card);
cursor: grab;
touch-action: none;
user-select: none;
@@ -402,11 +409,11 @@
}
.msg-error {
- color: #ff9a9d;
+ color: var(--danger-text);
}
.msg-warning {
- color: #f2c66d;
+ color: var(--warn-text);
}
.msg-notice {
@@ -456,6 +463,7 @@
.btn-primary {
border-color: var(--primary);
background: var(--primary);
+ color: var(--on-primary);
font-weight: 700;
}
diff --git a/web/src/main.ts b/web/src/main.ts
index af97b63..e3cbccf 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -2,18 +2,37 @@ import '@fontsource-variable/nunito-sans';
import './styles.css';
import { mountAccountMenu } from './account/account-menu';
import { getCurrentUser } from './account/auth';
-import { startRouter } from './pages/router';
+import { DONATE_URL } from './config';
+import { legacyRedirect, startRouter } from './pages/router';
import { mountCardsPage } from './pages/cards-page';
+import { mountCommunityPage } from './pages/community-page';
+import { mountHomePage } from './pages/home-page';
import { mountResetPage } from './pages/reset-page';
import { mountEditor } from './editor/editor-page';
import { bindEditorToAuth } from './editor/auth-reset';
import { createStore, initialEditorState } from './editor/state';
+import { mountDonateBanner } from './shell/donate-banner';
+import { mountHeader } from './shell/header';
+import { initTheme } from './shell/theme';
+
+// Hell/Dunkel: das Skript in index.html hat es vor dem ersten Paint gesetzt; hier wird nur die Browserleiste angeglichen.
+initTheme();
+
+// Alte Editor-Links (`/?karte=`) zeigen jetzt auf `/gestalten?karte=`; der Verlauf bekommt keinen Eintrag dazu.
+const legacyTarget = legacyRedirect(new URL(location.href));
+if (legacyTarget) history.replaceState(null, '', legacyTarget);
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
const editorStore = createStore(initialEditorState());
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
bindEditorToAuth(editorStore);
+// Spenden-Banner (ohne DONATE_URL bleibt der Platz leer) und Kopfzeile mit Navigation, Suche und Knöpfen.
+const bannerSlot = document.getElementById('donate-banner');
+if (bannerSlot) mountDonateBanner(bannerSlot, DONATE_URL);
+const header = document.getElementById('app-header');
+if (header) mountHeader(header, { donateUrl: DONATE_URL });
+
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
const accountSlot = document.getElementById('account-slot');
@@ -26,12 +45,18 @@ if (accountSlot) {
.finally(() => menu.ready());
}
+// Der Router setzt `document.title` je Seite (titleFor) und ruft für jede Route die passende Mount-Funktion.
startRouter((route, root) => {
- if (route === 'editor') {
- return mountEditor(root, editorStore);
+ switch (route.id) {
+ case 'gestalten':
+ return mountEditor(root, editorStore);
+ case 'cards':
+ return mountCardsPage(root, editorStore);
+ case 'community':
+ return mountCommunityPage(root, route.params.id ?? '');
+ case 'reset':
+ return mountResetPage(root);
+ case 'home':
+ return mountHomePage(root);
}
- if (route === 'reset') {
- return mountResetPage(root);
- }
- return mountCardsPage(root, editorStore);
});
diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts
index a3ec15d..9914f7c 100644
--- a/web/src/pages/cards-page.test.ts
+++ b/web/src/pages/cards-page.test.ts
@@ -141,7 +141,7 @@ describe('Meine Karten: Zustände', () => {
mount();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
const link = root.querySelector('a[data-link]')!;
- expect(link.getAttribute('href')).toBe('/');
+ expect(link.getAttribute('href')).toBe('/gestalten');
});
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
@@ -247,7 +247,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Öffnen').click();
- expect(mocks.navigate).toHaveBeenCalledWith('/?karte=a');
+ expect(mocks.navigate).toHaveBeenCalledWith('/gestalten?karte=a');
});
it('Herunterladen: Bild laden, als PNG rendern und unter aircard-.png speichern', async () => {
diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts
index f56a29d..2b33ad4 100644
--- a/web/src/pages/cards-page.ts
+++ b/web/src/pages/cards-page.ts
@@ -156,7 +156,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
state = 'list';
thumbSlots.clear();
if (cards.length === 0) {
- const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/', 'data-link': true }, 'Zum Editor');
+ const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, 'Zum Editor');
show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor));
return;
}
@@ -218,7 +218,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
}
}
- openButton.addEventListener('click', () => navigate(`/?karte=${encodeURIComponent(card.id)}`));
+ openButton.addEventListener('click', () => navigate(`/gestalten?karte=${encodeURIComponent(card.id)}`));
downloadButton.addEventListener('click', () => {
void run(async () => {
diff --git a/web/src/pages/cards.css b/web/src/pages/cards.css
index 1656fea..d2a35ad 100644
--- a/web/src/pages/cards.css
+++ b/web/src/pages/cards.css
@@ -10,7 +10,7 @@
}
.cards-msg-error {
- color: #ff9a9d;
+ color: var(--danger-text);
}
.cards-actions {
@@ -85,7 +85,7 @@
.card-msg {
margin: 0;
- color: #ff9a9d;
+ color: var(--danger-text);
font-size: 0.9rem;
}
@@ -94,7 +94,7 @@
}
.card-msg.is-warning {
- color: #f2c66d;
+ color: var(--warn-text);
}
.card-rename {
diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts
new file mode 100644
index 0000000..b242baf
--- /dev/null
+++ b/web/src/pages/community-page.ts
@@ -0,0 +1,10 @@
+// Detailseite „/community/“. Platzhalter – Task 6 füllt sie.
+import { el } from '../editor/dom';
+
+export function mountCommunityPage(root: HTMLElement, id: string): () => void {
+ root.replaceChildren(
+ el('h1', {}, 'Community-Karte'),
+ el('p', { class: 'community-placeholder', 'data-card-id': id }, el('a', { href: '/#community', 'data-link': true }, 'Zur Galerie')),
+ );
+ return () => {};
+}
diff --git a/web/src/pages/home-page.ts b/web/src/pages/home-page.ts
new file mode 100644
index 0000000..2818080
--- /dev/null
+++ b/web/src/pages/home-page.ts
@@ -0,0 +1,10 @@
+// Startseite „/“: Hero + Community-Galerie. Platzhalter – Task 5 füllt sie.
+import { el } from '../editor/dom';
+
+export function mountHomePage(root: HTMLElement): () => void {
+ root.replaceChildren(
+ el('h1', {}, 'Kartendesigns für Apple Wallet'),
+ el('section', { id: 'community', class: 'home-community' }, el('h2', {}, 'Community-Karten')),
+ );
+ return () => {};
+}
diff --git a/web/src/pages/router.test.ts b/web/src/pages/router.test.ts
index c90a21c..e428948 100644
--- a/web/src/pages/router.test.ts
+++ b/web/src/pages/router.test.ts
@@ -1,22 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { matchRoute, navigate, startRouter } from './router';
+import { legacyRedirect, matchRoute, navigate, ROUTE_TITLES, startRouter, titleFor } from './router';
describe('matchRoute', () => {
- it('ordnet / dem Editor zu', () => {
- expect(matchRoute('/')).toBe('editor');
+ it('ordnet / der Startseite zu', () => {
+ expect(matchRoute('/')).toEqual({ id: 'home', params: {} });
+ });
+
+ it('ordnet /gestalten und /gestalten/ dem Editor zu', () => {
+ expect(matchRoute('/gestalten')).toEqual({ id: 'gestalten', params: {} });
+ expect(matchRoute('/gestalten/')).toEqual({ id: 'gestalten', params: {} });
});
it('ordnet /karten und /karten/ den Karten zu', () => {
- expect(matchRoute('/karten')).toBe('cards');
- expect(matchRoute('/karten/')).toBe('cards');
+ expect(matchRoute('/karten')).toEqual({ id: 'cards', params: {} });
+ expect(matchRoute('/karten/')).toEqual({ id: 'cards', params: {} });
+ });
+
+ it('ordnet /community/ der Detailseite zu und liefert die ID', () => {
+ expect(matchRoute('/community/abc123')).toEqual({ id: 'community', params: { id: 'abc123' } });
+ expect(matchRoute('/community/abc123/')).toEqual({ id: 'community', params: { id: 'abc123' } });
+ });
+
+ it('decodiert die ID; eine kaputte Kodierung fällt auf die Startseite zurück', () => {
+ expect(matchRoute('/community/a%20b')).toEqual({ id: 'community', params: { id: 'a b' } });
+ expect(matchRoute('/community/%E0%A4%A')).toEqual({ id: 'home', params: {} });
+ });
+
+ it('/community ohne ID und mit weiteren Segmenten ist unbekannt und führt auf die Startseite', () => {
+ expect(matchRoute('/community')).toEqual({ id: 'home', params: {} });
+ expect(matchRoute('/community/')).toEqual({ id: 'home', params: {} });
+ expect(matchRoute('/community/abc/def')).toEqual({ id: 'home', params: {} });
});
it('ordnet /passwort-neu dem Zurücksetzen zu', () => {
- expect(matchRoute('/passwort-neu')).toBe('reset');
+ expect(matchRoute('/passwort-neu')).toEqual({ id: 'reset', params: {} });
});
- it('fällt bei unbekannten Pfaden auf den Editor zurück', () => {
- expect(matchRoute('/irgendwas')).toBe('editor');
+ it('fällt bei unbekannten Pfaden auf die Startseite zurück', () => {
+ expect(matchRoute('/irgendwas')).toEqual({ id: 'home', params: {} });
+ });
+});
+
+describe('legacyRedirect', () => {
+ it('leitet /?karte= auf /gestalten?karte= um', () => {
+ expect(legacyRedirect(new URL('https://x/?karte=K1'))).toBe('/gestalten?karte=K1');
+ });
+
+ it('behält weitere Parameter und den Hash bei', () => {
+ expect(legacyRedirect(new URL('https://x/?karte=K1&x=1#h'))).toBe('/gestalten?karte=K1&x=1#h');
+ });
+
+ it('ohne (oder mit leerem) karte-Parameter gibt es keine Umleitung', () => {
+ expect(legacyRedirect(new URL('https://x/'))).toBeNull();
+ expect(legacyRedirect(new URL('https://x/?q=visa'))).toBeNull();
+ expect(legacyRedirect(new URL('https://x/?karte='))).toBeNull();
+ });
+
+ it('nur die Startseite wird umgeleitet, andere Pfade bleiben unberührt', () => {
+ expect(legacyRedirect(new URL('https://x/gestalten?karte=K1'))).toBeNull();
+ expect(legacyRedirect(new URL('https://x/karten?karte=K1'))).toBeNull();
+ });
+});
+
+describe('Seitentitel', () => {
+ it('nennt je Route den Titel aus der Spec', () => {
+ expect(titleFor({ id: 'home', params: {} })).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
+ expect(titleFor({ id: 'gestalten', params: {} })).toBe('Gestalten – AirCard-IOS');
+ expect(titleFor({ id: 'cards', params: {} })).toBe('Meine Karten – AirCard-IOS');
+ expect(titleFor({ id: 'reset', params: {} })).toBe('Passwort neu setzen – AirCard-IOS');
+ });
+
+ it('Detailseite: „ – AirCard-IOS“, ohne Titel ein allgemeiner Name', () => {
+ const route = { id: 'community', params: { id: 'abc' } } as const;
+ expect(titleFor(route, 'Mein Motiv')).toBe('Mein Motiv – AirCard-IOS');
+ expect(titleFor(route)).toBe(ROUTE_TITLES.community);
+ expect(ROUTE_TITLES.community).toBe('Community – AirCard-IOS');
+ });
+
+ it('ein Titel wird nur auf der Detailseite eingesetzt', () => {
+ expect(titleFor({ id: 'cards', params: {} }, 'Fremd')).toBe('Meine Karten – AirCard-IOS');
});
});
@@ -39,7 +101,7 @@ describe('startRouter', () => {
const render = vi.fn();
startRouter(render);
expect(render).toHaveBeenCalledTimes(1);
- expect(render).toHaveBeenCalledWith('cards', root);
+ expect(render).toHaveBeenCalledWith({ id: 'cards', params: {} }, root);
});
it('navigate setzt die URL per pushState und rendert neu', () => {
@@ -49,23 +111,23 @@ describe('startRouter', () => {
navigate('/passwort-neu');
expect(location.pathname).toBe('/passwort-neu');
expect(history.length).toBe(lengthBefore + 1);
- expect(render).toHaveBeenLastCalledWith('reset', root);
+ expect(render).toHaveBeenLastCalledWith({ id: 'reset', params: {} }, root);
});
it('ruft vor dem nächsten Rendern den Cleanup der vorherigen Seite', () => {
const order: string[] = [];
startRouter((route) => {
- order.push(`render:${route}`);
- return () => order.push(`cleanup:${route}`);
+ order.push(`render:${route.id}`);
+ return () => order.push(`cleanup:${route.id}`);
});
navigate('/karten');
navigate('/');
expect(order).toEqual([
- 'render:editor',
- 'cleanup:editor',
+ 'render:home',
+ 'cleanup:home',
'render:cards',
'cleanup:cards',
- 'render:editor',
+ 'render:home',
]);
});
@@ -91,7 +153,7 @@ describe('startRouter', () => {
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(location.pathname).toBe('/karten');
- expect(render).toHaveBeenLastCalledWith('cards', root);
+ expect(render).toHaveBeenLastCalledWith({ id: 'cards', params: {} }, root);
});
it('lässt Klicks auf Links ohne data-link und Klicks mit Modifier-Taste unberührt', () => {
@@ -143,6 +205,60 @@ describe('startRouter', () => {
}
});
+ it('navigate zu einem Hash-Ziel scrollt den Anker ins Bild statt an den Seitenanfang', () => {
+ const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ try {
+ const anchor = document.createElement('section');
+ anchor.id = 'community';
+ const scrollIntoView = vi.fn();
+ anchor.scrollIntoView = scrollIntoView;
+ startRouter(() => {
+ root.append(anchor); // der Anker entsteht erst beim Rendern
+ });
+ navigate('/karten');
+ scrollTo.mockClear();
+ navigate('/?q=visa#community');
+ expect(location.pathname + location.search + location.hash).toBe('/?q=visa#community');
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(scrollTo).not.toHaveBeenCalled();
+ } finally {
+ scrollTo.mockRestore();
+ }
+ });
+
+ it('ein Hash ohne passenden Anker springt an den Seitenanfang', () => {
+ const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ try {
+ startRouter(vi.fn());
+ navigate('/#gibt-es-nicht');
+ expect(scrollTo).toHaveBeenCalledWith(0, 0);
+ } finally {
+ scrollTo.mockRestore();
+ }
+ });
+
+ it('ändert sich nur der Hash, wird nicht neu gerendert, nur gescrollt', () => {
+ const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ try {
+ const anchor = document.createElement('section');
+ anchor.id = 'community';
+ const scrollIntoView = vi.fn();
+ anchor.scrollIntoView = scrollIntoView;
+ root.append(anchor);
+ const render = vi.fn();
+ startRouter(render);
+ render.mockClear();
+ const lengthBefore = history.length;
+ navigate('/#community');
+ expect(location.hash).toBe('#community');
+ expect(history.length).toBe(lengthBefore + 1);
+ expect(render).not.toHaveBeenCalled();
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+ } finally {
+ scrollTo.mockRestore();
+ }
+ });
+
it('beim Start und bei popstate wird nicht gescrollt (das übernimmt der Browser)', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
@@ -159,13 +275,17 @@ describe('startRouter', () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
startRouter(vi.fn());
- expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
+ expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
+ navigate('/gestalten');
+ expect(document.title).toBe('Gestalten – AirCard-IOS');
navigate('/karten');
expect(document.title).toBe('Meine Karten – AirCard-IOS');
+ navigate('/community/abc123');
+ expect(document.title).toBe('Community – AirCard-IOS');
navigate('/passwort-neu');
expect(document.title).toBe('Passwort neu setzen – AirCard-IOS');
navigate('/');
- expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
+ expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
} finally {
vi.restoreAllMocks();
}
@@ -177,6 +297,13 @@ describe('startRouter', () => {
startRouter(render);
history.pushState(null, '', '/passwort-neu');
window.dispatchEvent(new PopStateEvent('popstate'));
- expect(render).toHaveBeenLastCalledWith('reset', root);
+ expect(render).toHaveBeenLastCalledWith({ id: 'reset', params: {} }, root);
+ });
+
+ it('übergibt die ID der Detailseite als Parameter', () => {
+ history.replaceState(null, '', '/community/abc123');
+ const render = vi.fn();
+ startRouter(render);
+ expect(render).toHaveBeenCalledWith({ id: 'community', params: { id: 'abc123' } }, root);
});
});
diff --git a/web/src/pages/router.ts b/web/src/pages/router.ts
index ebe0d07..2e116b1 100644
--- a/web/src/pages/router.ts
+++ b/web/src/pages/router.ts
@@ -1,33 +1,81 @@
-export type RouteId = 'editor' | 'cards' | 'reset';
+export type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset';
-type Render = (route: RouteId, root: HTMLElement) => void | (() => void);
-
-export function matchRoute(pathname: string): RouteId {
- const path = pathname.replace(/\/+$/, '') || '/';
- if (path === '/karten') return 'cards';
- if (path === '/passwort-neu') return 'reset';
- return 'editor';
+export interface Route {
+ id: RouteId;
+ params: { id?: string };
}
-const TITLES: Record = {
- editor: 'AirCard-IOS – Kartenmotiv gestalten',
+type Render = (route: Route, root: HTMLElement) => void | (() => void);
+
+const normalize = (pathname: string): string => pathname.replace(/\/+$/, '') || '/';
+
+export function matchRoute(pathname: string): Route {
+ const path = normalize(pathname);
+ if (path === '/gestalten') return { id: 'gestalten', params: {} };
+ if (path === '/karten') return { id: 'cards', params: {} };
+ if (path === '/passwort-neu') return { id: 'reset', params: {} };
+ const detail = /^\/community\/([^/]+)$/.exec(path);
+ if (detail) {
+ try {
+ return { id: 'community', params: { id: decodeURIComponent(detail[1]) } };
+ } catch {
+ // kaputte Prozent-Kodierung: wie jede unbekannte Adresse
+ }
+ }
+ return { id: 'home', params: {} };
+}
+
+/** Alte Adresse des Editors: `/?karte=` heißt jetzt `/gestalten?karte=`; sonst null (keine Umleitung). */
+export function legacyRedirect(url: URL): string | null {
+ if (normalize(url.pathname) !== '/' || !url.searchParams.get('karte')) return null;
+ return `/gestalten${url.search}${url.hash}`;
+}
+
+export const ROUTE_TITLES: Record = {
+ home: 'AirCard-IOS – Kartendesigns für Apple Wallet',
+ gestalten: 'Gestalten – AirCard-IOS',
cards: 'Meine Karten – AirCard-IOS',
+ community: 'Community – AirCard-IOS',
reset: 'Passwort neu setzen – AirCard-IOS',
};
+/** Titel der Seite; `detailTitle` (Titel der Community-Karte) gilt nur für die Detailseite. */
+export function titleFor(route: Route, detailTitle?: string): string {
+ if (route.id === 'community' && detailTitle) return `${detailTitle} – AirCard-IOS`;
+ return ROUTE_TITLES[route.id];
+}
+
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null;
+/** Springt zum Anker `#`; gibt es ihn nicht, an den Seitenanfang. */
+function scrollToHash(hash: string): void {
+ let id = hash.slice(1);
+ try {
+ id = decodeURIComponent(id);
+ } catch {
+ // unverändert verwenden
+ }
+ const target = id ? document.getElementById(id) : null;
+ if (target) target.scrollIntoView();
+ else window.scrollTo(0, 0);
+}
+
export function navigate(path: string): void {
const url = new URL(path, location.href);
const target = url.pathname + url.search + url.hash;
+ // Ändert sich nur der Hash (z. B. „Community“ auf der Startseite), bleibt die Seite stehen: nur zum Anker scrollen.
+ const hashOnly = !!url.hash && url.pathname + url.search === location.pathname + location.search;
if (target !== location.pathname + location.search + location.hash) {
history.pushState(null, '', target);
}
- renderCurrent?.();
- // Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück); bei popstate bleibt es dem Browser überlassen.
- if (renderCurrent) window.scrollTo(0, 0);
+ if (!renderCurrent) return;
+ if (!hashOnly) renderCurrent();
+ // Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück), ein Hash-Ziel kommt ins Bild;
+ // bei popstate bleibt es dem Browser überlassen.
+ if (url.hash) scrollToHash(url.hash);
+ else window.scrollTo(0, 0);
}
export function startRouter(render: Render): void {
@@ -42,7 +90,7 @@ export function startRouter(render: Render): void {
cleanup?.();
cleanup = undefined;
const route = matchRoute(location.pathname);
- document.title = TITLES[route];
+ document.title = titleFor(route);
cleanup = render(route, root);
};
diff --git a/web/src/shell/donate-banner.test.ts b/web/src/shell/donate-banner.test.ts
new file mode 100644
index 0000000..af06533
--- /dev/null
+++ b/web/src/shell/donate-banner.test.ts
@@ -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 = '';
+ 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('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);
+ });
+});
diff --git a/web/src/shell/donate-banner.ts b/web/src/shell/donate-banner.ts
new file mode 100644
index 0000000..2134268
--- /dev/null
+++ b/web/src/shell/donate-banner.ts
@@ -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,
+ ),
+ );
+}
diff --git a/web/src/shell/header.test.ts b/web/src/shell/header.test.ts
new file mode 100644
index 0000000..4801f40
--- /dev/null
+++ b/web/src/shell/header.test.ts
@@ -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('form.header-search')!;
+const input = () => search().querySelector('input')!;
+const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+const menuButton = () => header.querySelector('.menu-btn')!;
+const nav = () => header.querySelector('nav.app-nav')!;
+
+beforeEach(() => {
+ mocks.navigate.mockReset();
+ localStorage.clear();
+ html.setAttribute('data-theme', 'dark');
+ document.body.innerHTML =
+ '';
+ 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('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('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('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();
+ });
+});
diff --git a/web/src/shell/header.ts b/web/src/shell/header.ts
new file mode 100644
index 0000000..f29316f
--- /dev/null
+++ b/web/src/shell/header.ts
@@ -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();
+ };
+}
diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css
new file mode 100644
index 0000000..7dbb0be
--- /dev/null
+++ b/web/src/shell/shell.css
@@ -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;
+ }
+}
diff --git a/web/src/shell/theme.test.ts b/web/src/shell/theme.test.ts
new file mode 100644
index 0000000..6c4afac
--- /dev/null
+++ b/web/src/shell/theme.test.ts
@@ -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 = '';
+ 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 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 = {};
+ 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');
+ });
+});
diff --git a/web/src/shell/theme.ts b/web/src/shell/theme.ts
new file mode 100644
index 0000000..92518b7
--- /dev/null
+++ b/web/src/shell/theme.ts
@@ -0,0 +1,63 @@
+// Hell/Dunkel: `data-theme` auf , Auswahl in localStorage `aircard.theme`, Standard = System.
+// Das Skript im 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 = { 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;
+}
diff --git a/web/src/styles.css b/web/src/styles.css
index 5c1dbf2..1e90424 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -1,4 +1,5 @@
-:root {
+:root,
+:root[data-theme='dark'] {
--bg: #0F1828;
--panel: #162133;
--panel-2: #1B2738;
@@ -10,12 +11,43 @@
--muted: #9DB0C8;
--primary: #2A4566;
--primary-hover: #34557C;
+ --on-primary: #E8EEF7;
--danger: #E5484D;
+ --danger-text: #ff9a9d;
+ --warn-text: #f2c66d;
+ --ok-text: #8fd9a8;
+ --backdrop: rgb(6 11 20 / 0.72);
+ --shadow-lg: 0 24px 80px rgb(0 0 0 / 0.55);
+ --shadow-card: 0 10px 34px rgb(0 0 0 / 0.45);
--radius: 14px;
color-scheme: dark;
}
+/* Hell: nur die Oberfläche; die Kartenfarben (Vorschau, PNG) hängen nicht vom Theme ab. */
+:root[data-theme='light'] {
+ --bg: #F4F6FB;
+ --panel: #FFFFFF;
+ --panel-2: #EEF1F7;
+ --line: #D9DFEA;
+ --line-strong: #B8C2D3;
+ --sel-bg: #E3ECFB;
+ --sel-line: #3A6FD8;
+ --text: #0F1828;
+ --muted: #5B6B82;
+ --primary: #2A4566;
+ --primary-hover: #34557C;
+ --on-primary: #FFFFFF;
+ --danger-text: #B42318;
+ --warn-text: #8A5A00;
+ --ok-text: #1B6B3A;
+ --backdrop: rgb(15 24 40 / 0.5);
+ --shadow-lg: 0 24px 80px rgb(15 24 40 / 0.28);
+ --shadow-card: 0 10px 34px rgb(15 24 40 / 0.22);
+
+ color-scheme: light;
+}
+
*,
*::before,
*::after {
@@ -47,63 +79,6 @@ a {
outline-offset: 2px;
}
-#app-header {
- display: flex;
- align-items: center;
- gap: 24px;
- padding: 14px 20px;
- background: var(--panel);
- border-bottom: 1px solid var(--line);
-}
-
-.brand {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- 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;
-}
-
-/* Schmale Bildschirme: Marke etwas kleiner, damit sie samt „Meine Karten“ in die erste Kopfzeile passt */
-@media (max-width: 599px) {
- .brand {
- gap: 8px;
- font-size: 1.2rem;
- }
-
- .brand img {
- width: 24px;
- height: 24px;
- }
-}
-
-.app-nav a {
- color: var(--muted);
- font-weight: 600;
- text-decoration: none;
-}
-
-.app-nav a:hover {
- color: var(--text);
-}
-
-#account-slot {
- display: flex;
- align-items: center;
- margin-left: auto;
-}
-
#app-root {
flex: 1;
width: 100%;