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",