diff --git a/apps/web/src/app/apple-icon.png b/apps/web/src/app/apple-icon.png
new file mode 100644
index 0000000..cada778
Binary files /dev/null and b/apps/web/src/app/apple-icon.png differ
diff --git a/apps/web/src/app/icon.svg b/apps/web/src/app/icon.svg
new file mode 100644
index 0000000..94a0e81
--- /dev/null
+++ b/apps/web/src/app/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/components/brand/brand.ts b/apps/web/src/components/brand/brand.ts
new file mode 100644
index 0000000..3c9613f
--- /dev/null
+++ b/apps/web/src/components/brand/brand.ts
@@ -0,0 +1,18 @@
+/**
+ * Markenfarben der Tessera-Bildmarke.
+ *
+ * Dies ist die einzige Stelle im Web-Quellcode, an der eine Markenfarbe
+ * notiert wird. Jede Stelle der Oberflaeche, die eine Markenfarbe braucht
+ * (die Bildmarken-Komponente, das Anmelde-Panel, der Vorgabewert der
+ * persoenlichen Akzentfarbe), importiert sie von hier. Eine spaetere
+ * mandantenspezifische Gestaltung setzt an dieser Datei an.
+ */
+
+/** Gelbwert der gedrehten Signalkachel. */
+export const BRAND_YELLOW = '#ffed00';
+
+/** Olivton der vier achsenparallelen Kacheln. */
+export const BRAND_OLIVE = '#9c9440';
+
+/** Farbe der abgerundeten Grundplatte. */
+export const BRAND_PLATE = '#1a1a1a';
diff --git a/apps/web/src/components/brand/tessera-logo.test.tsx b/apps/web/src/components/brand/tessera-logo.test.tsx
new file mode 100644
index 0000000..e23ebbf
--- /dev/null
+++ b/apps/web/src/components/brand/tessera-logo.test.tsx
@@ -0,0 +1,99 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { BRAND_YELLOW } from './brand';
+import { TesseraLogo } from './tessera-logo';
+import de from '@/messages/de.json';
+import en from '@/messages/en.json';
+
+vi.mock('next-intl', () => ({
+ useTranslations: (ns: string) => (key: string) => {
+ const map: Record> = {
+ common: {
+ appName: 'Tessera',
+ 'brand.logoAlt': 'Tessera Logo',
+ },
+ };
+ return map[ns]?.[key] ?? key;
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+function flattenKeys(obj: unknown, prefix = ''): string[] {
+ if (obj === null || typeof obj !== 'object') {
+ return [prefix];
+ }
+ return Object.entries(obj as Record).flatMap(([key, value]) =>
+ flattenKeys(value, prefix ? `${prefix}.${key}` : key),
+ );
+}
+
+describe('TesseraLogo', () => {
+ it('variant "mark" renders an img-role element with the translated accessible name and no visible wordmark', () => {
+ render();
+
+ const mark = screen.getByRole('img', { name: 'Tessera Logo' });
+ expect(mark).toBeInTheDocument();
+ expect(screen.queryByText('Tessera')).not.toBeInTheDocument();
+ });
+
+ it('variant "horizontal" renders the img-role mark and the visible wordmark text', () => {
+ render();
+
+ expect(screen.getByRole('img', { name: 'Tessera Logo' })).toBeInTheDocument();
+ expect(screen.getByText('Tessera')).toBeInTheDocument();
+ });
+
+ it('variant "stacked" renders the img-role mark and the visible wordmark text', () => {
+ render();
+
+ expect(screen.getByRole('img', { name: 'Tessera Logo' })).toBeInTheDocument();
+ expect(screen.getByText('Tessera')).toBeInTheDocument();
+ });
+
+ it('enables the dark-mode plate outline class by default', () => {
+ render();
+
+ const mark = screen.getByRole('img', { name: 'Tessera Logo' });
+ const plate = mark.querySelector('rect');
+ expect(plate?.getAttribute('class')).toContain('dark:stroke-white/25');
+ });
+
+ it('omits the dark-mode plate outline class when plateOutline is false', () => {
+ render();
+
+ const mark = screen.getByRole('img', { name: 'Tessera Logo' });
+ const plate = mark.querySelector('rect');
+ expect(plate?.getAttribute('class')).not.toContain('dark:stroke-white/25');
+ });
+
+ it('renders exactly five tiles, exactly one in the brand yellow and exactly one rotated', () => {
+ render();
+
+ const mark = screen.getByRole('img', { name: 'Tessera Logo' });
+ const tiles = Array.from(mark.querySelectorAll('g rect'));
+ expect(tiles).toHaveLength(5);
+
+ const yellowTiles = tiles.filter((tile) => tile.getAttribute('fill') === BRAND_YELLOW);
+ expect(yellowTiles).toHaveLength(1);
+
+ const rotatedTiles = tiles.filter((tile) => tile.hasAttribute('transform'));
+ expect(rotatedTiles).toHaveLength(1);
+ });
+});
+
+describe('common.brand i18n namespace — de/en key parity', () => {
+ it('both locales define common.brand.logoAlt', () => {
+ expect(de.common).toHaveProperty('brand.logoAlt');
+ expect(en.common).toHaveProperty('brand.logoAlt');
+ });
+
+ it('has an identical (recursively-flattened) key set under common.brand in de and en', () => {
+ const deKeys = flattenKeys((de.common as { brand: unknown }).brand).sort();
+ const enKeys = flattenKeys((en.common as { brand: unknown }).brand).sort();
+
+ expect(deKeys).toEqual(enKeys);
+ });
+});
diff --git a/apps/web/src/components/brand/tessera-logo.tsx b/apps/web/src/components/brand/tessera-logo.tsx
new file mode 100644
index 0000000..3727984
--- /dev/null
+++ b/apps/web/src/components/brand/tessera-logo.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { BRAND_OLIVE, BRAND_PLATE, BRAND_YELLOW } from './brand';
+
+export type TesseraLogoVariant = 'mark' | 'horizontal' | 'stacked';
+
+export interface TesseraLogoProps {
+ /** Welche Bausteine gerendert werden. Vorgabe: `'horizontal'`. */
+ variant?: TesseraLogoVariant;
+ /** Kantenlaenge der Bildmarke in Pixeln. Vorgabe: 32. */
+ size?: number;
+ /**
+ * Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen
+ * Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom
+ * Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss
+ * dies auf `false` gesetzt werden. Vorgabe: `true`.
+ */
+ plateOutline?: boolean;
+ /** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */
+ className?: string;
+ /** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`/`stacked`). */
+ wordmarkClassName?: string;
+}
+
+function LogoMark({
+ size,
+ plateOutline,
+ className,
+ ariaLabel,
+}: {
+ size: number;
+ plateOutline: boolean;
+ className?: string;
+ ariaLabel: string;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Zentrale Bildmarken-Komponente von Tessera. Buendelt die Bildmarke, den
+ * Schriftzug und deren Anordnung an einer einzigen Stelle -- eine spaetere
+ * mandantenspezifische Gestaltung setzt hier an, nicht an den einzelnen
+ * Verwendungsstellen.
+ */
+export function TesseraLogo({
+ variant = 'horizontal',
+ size = 32,
+ plateOutline = true,
+ className,
+ wordmarkClassName,
+}: TesseraLogoProps) {
+ const t = useTranslations('common');
+ const ariaLabel = t('brand.logoAlt');
+
+ if (variant === 'mark') {
+ return (
+
+ );
+ }
+
+ if (variant === 'stacked') {
+ return (
+
+
+
+ {t('appName')}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t('appName')}
+
+
+ );
+}
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json
index d20f690..0c74903 100644
--- a/apps/web/src/messages/de.json
+++ b/apps/web/src/messages/de.json
@@ -15,7 +15,10 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"actions": "Aktionen",
- "accessDenied": "Zugriff verweigert"
+ "accessDenied": "Zugriff verweigert",
+ "brand": {
+ "logoAlt": "Tessera Logo"
+ }
},
"auth": {
"login": "Anmelden",
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index 46077d9..9629aaf 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -15,7 +15,10 @@
"active": "Active",
"inactive": "Inactive",
"actions": "Actions",
- "accessDenied": "Access denied"
+ "accessDenied": "Access denied",
+ "brand": {
+ "logoAlt": "Tessera logo"
+ }
},
"auth": {
"login": "Sign In",