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:
1 parent
1a64e12bc5
commit
25ff496548
30 files changed
+1783
-189
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user