feat(quick-260907-fgv-01): Markenbausteine — Icons, Farbkonstanten, TesseraLogo-Komponente
- favicon.svg und apple-touch-icon.png byteidentisch nach src/app/ kopiert (dateibasierte Next.js-Metadaten fuer den Browser-Tab) - brand.ts: einzige Stelle im Web-Quellcode fuer die drei Markenfarben (BRAND_YELLOW, BRAND_OLIVE, BRAND_PLATE), aus favicon.svg uebernommen - TesseraLogo-Komponente mit den Varianten mark/horizontal/stacked, als JSX-SVG von Hand gesetzt (kein dangerouslySetInnerHTML), Kontur-Klasse fuer die dunkle Platte per plateOutline-Eigenschaft schaltbar - common.brand.logoAlt in de.json und en.json ergaenzt, Schluesselparitaet per Test abgesichert Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a"></rect><g><rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"></rect><rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"></rect></g></svg>
|
||||||
|
After Width: | Height: | Size: 543 B |
@@ -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';
|
||||||
@@ -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<string, Record<string, string>> = {
|
||||||
|
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<string, unknown>).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(<TesseraLogo variant="mark" />);
|
||||||
|
|
||||||
|
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(<TesseraLogo variant="horizontal" />);
|
||||||
|
|
||||||
|
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(<TesseraLogo variant="stacked" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('img', { name: 'Tessera Logo' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tessera')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables the dark-mode plate outline class by default', () => {
|
||||||
|
render(<TesseraLogo variant="mark" />);
|
||||||
|
|
||||||
|
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(<TesseraLogo variant="mark" plateOutline={false} />);
|
||||||
|
|
||||||
|
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(<TesseraLogo variant="mark" />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 72 72"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="4"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
rx="14"
|
||||||
|
fill={BRAND_PLATE}
|
||||||
|
strokeWidth={2}
|
||||||
|
className={
|
||||||
|
plateOutline
|
||||||
|
? 'stroke-transparent dark:stroke-white/25'
|
||||||
|
: 'stroke-transparent'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<g>
|
||||||
|
<rect x="16" y="16" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
|
||||||
|
<rect x="30" y="16" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
|
||||||
|
<rect
|
||||||
|
x="45"
|
||||||
|
y="15"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
rx="2.5"
|
||||||
|
transform="rotate(12 51 21)"
|
||||||
|
fill={BRAND_YELLOW}
|
||||||
|
/>
|
||||||
|
<rect x="30" y="30" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
|
||||||
|
<rect x="30" y="44" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<LogoMark
|
||||||
|
size={size}
|
||||||
|
plateOutline={plateOutline}
|
||||||
|
className={className}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'stacked') {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex flex-col items-center gap-2 ${className ?? ''}`}>
|
||||||
|
<LogoMark size={size} plateOutline={plateOutline} ariaLabel={ariaLabel} />
|
||||||
|
<span
|
||||||
|
className={`text-2xl font-bold tracking-tight text-foreground ${wordmarkClassName ?? ''}`}
|
||||||
|
>
|
||||||
|
{t('appName')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-2 ${className ?? ''}`}>
|
||||||
|
<LogoMark size={size} plateOutline={plateOutline} ariaLabel={ariaLabel} />
|
||||||
|
<span
|
||||||
|
className={`font-bold tracking-tight text-foreground ${wordmarkClassName ?? ''}`}
|
||||||
|
>
|
||||||
|
{t('appName')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
"inactive": "Inaktiv",
|
"inactive": "Inaktiv",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"accessDenied": "Zugriff verweigert"
|
"accessDenied": "Zugriff verweigert",
|
||||||
|
"brand": {
|
||||||
|
"logoAlt": "Tessera Logo"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
"active": "Active",
|
"active": "Active",
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"accessDenied": "Access denied"
|
"accessDenied": "Access denied",
|
||||||
|
"brand": {
|
||||||
|
"logoAlt": "Tessera logo"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign In",
|
"login": "Sign In",
|
||||||
|
|||||||
Reference in New Issue
Block a user