From 49d3df44854aa4871840653c5f9075eadb1fdec7 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 7 Sep 2026 11:21:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(quick-260907-fgv-01):=20Markenbausteine=20?= =?UTF-8?q?=E2=80=94=20Icons,=20Farbkonstanten,=20TesseraLogo-Komponente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq --- apps/web/src/app/apple-icon.png | Bin 0 -> 5677 bytes apps/web/src/app/icon.svg | 1 + apps/web/src/components/brand/brand.ts | 18 +++ .../components/brand/tessera-logo.test.tsx | 99 +++++++++++++ .../web/src/components/brand/tessera-logo.tsx | 130 ++++++++++++++++++ apps/web/src/messages/de.json | 5 +- apps/web/src/messages/en.json | 5 +- 7 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/apple-icon.png create mode 100644 apps/web/src/app/icon.svg create mode 100644 apps/web/src/components/brand/brand.ts create mode 100644 apps/web/src/components/brand/tessera-logo.test.tsx create mode 100644 apps/web/src/components/brand/tessera-logo.tsx diff --git a/apps/web/src/app/apple-icon.png b/apps/web/src/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cada778720dd390bdcac969f0ddc24ae2be0a013 GIT binary patch literal 5677 zcmV+|7Sid7P)O9*#T@O_WK{LzC*^2s8#i9diCnd z&pr3tEo;`S`6oVqjL-M-aX*)1TxPhmQ(5hU18@z__h&hH3_0QREzJMrAx@DX1ifn2 zsuduK$$FTHdYy^-cB|Fe(QdbIP%UIyoBX`Il za*3Q8Su`A6i=Z*1x$I@^kAe_BW)J@v$s7;o0qd{D$g^BNirjM9V$rm;DX)U&8GIuL zx)(E=3$_u`K!faPOAxAdRQnf^=V<*vZn^A0u95R1_b%$a2zu?>wdZqS5AzIufCG&~ z$TFg_B?whJs{LIkN5Up@jhrL*(164c<1Wqx&9ijN$&)9a<-o8%HU{j?K&fbC)b0ss zKv0SzM(&{jXrZW&t@;WtB#C@*^3s8E^IXqtk zEkF|veUKAD<0Blt+I^Y>_(PZAb!^Y+sAeV9?7o+~IZk?O70wk5KoiggG@{@^x~p|T zW3R-ZcQb+wT!MQ9q$4eE6tp94-@AVZo16()LU zY3{lN#=rz?eg8^s?+R}|iThP2Z-=*s`vT+TB8^(KL5egG+X$cyeci!&={pCp!Sq)UD zMo3}2Cl;ZAwxBVMz_kCCw<$^5faw6q)6trepz)nMu0joHvoN%n`L+e??oAZq zckjEzgA4KuLxt9$xf0X^O-h318FTGC3$?s(!Mx8vV#lw*m4#4MHK!!#k&%(B6v9be zYi8iQw@%YV>!#^#Yp1DYxLVc~imK+61kG;&zg<^g$*(*E8?Tt8f4%2*dhEW}={WfS!K%0DboE*Xh=+N9fAmoTRmDPRbjsv8Sr$qy^1aocQUdxPAmC(p5=UKVysA zbnBKQwCxjb(A8HSqvgwHx~7#jORfBw-!^rh`Tr`=yWK)b&38r}Vu2kG~2 zev^LfSB}s1?~XEmX20e}Xj!H`DO1vd=9%(tDT^U>YInid;k9ckL+Vt*sPSGvzJ4)OB;thJ}-k-6^U#0uM_%r(C#}Cns*B+(y?>IpV7iJ{t1a2_2 z*PV*?qy^1;nM-?}xH^{+pG==#d@{VM+U&I-!uhI|Cu#G>qt#F{GDPuWBE0w4kJE=g zaD@JG+d+Erfmb^s$M)s+j~u3rS5DF|y=}VMhvgnB+LIPE&ysbPaSF|4d<)wyUw=A% z@aDJBogZIEpZdgwR?1y$3pRh5Z3~?iROVPP-ZmE4E^u2|ec2@^=vUt}RoFh;AX4&d zKd0@VK1es;ATeZx+!F}EqLJrzNpnp^jIeCMU7 z`*hdwRphg%zB9rkkls6#87XN?f-WH^h9<1e_=c|ztU9Z+qW+b` zN`h9VtUJKgN@HrLuGJYVjPsTQ$B>F$LHA`Rq(E(Bv9;2e+PP%*^XHNXO`Ql#zj?G3 z-2NFb?F!lfalSse2N^LIsTJD6LNm@6gD>t|5FBN81??id(n;bVQ!Zj~WD|d+k3F;_ z^QC6THMbkCGZwy}Gg`$&l&U=&+1vxfykx8%4!+MTv<|bb(MBU^*F@+fr`|Cw{``5m zHVC>av}?fD1T9`cGx(*AS)X)Jyj*Crpz}XS@=M+;uP*4nAoL5M#G5rim#(0tjV0)Y ztVxBY7B3PPuk$SE3?NqL*AH5gj@2xOiOOn-%`{g04IwMtRmhp?an1d+D_FgDlc0Oq zi0FLrqHMVwz+6;6&{4Z$T=NqSb%)suQW%&7tw5GZSM}7CdpmKWMRk1+Jnn6UR-c?n z(B-FYH3(%5gG$shiBl)mq6h3<1M@iGZof~34lpQiHa z@}sR*finO5e-_b^H_K6#w@QHP^NZCP?+y|pYw3~%tw4KUZWTtl>ALH`7Sr~frz!sl zrxKdJ88II4>WsL87xdJW|NjzE{Cep9A$>h{c9x zTYBS~t&}<$@L4eG@R1fh@zf%^=kur0hi+d^8?IkLSKYLNK6ckK`r^MYrT_cJsf2I% z3Wir!A38ij_ujvh_8$oEFYJ232PWu2h3wgTh<@<=o4uHwp2HV>ikzSx?V`5wy1}^0 z`*&O63qHHA)ShwSw)cAr>A^?G=u>}pI^F!?vpOPw-|wGAcYXQ{y6>T}&Zi{!yJi{7 zpSyP{edViTGF@x!Q>UP%1ApL=2`{3CfZ(eP7~0XVtdEU0_<%1Pw(>s`!PnLQ|E*K$ z-|k;Rcl^~dy5@J!W|6O?58iqv{q1K?r~iC%G2x=``0?&L3i>~^YmByj>`eMB3)&n@ z*RHvpVhd3SdV0D|_kDFAJ@VLo`q7V%Qq_f0akzsmumzj24Il6|*lVr^?dXU87>Dt5 zt!8Ji1)H!9AMj;nF&mw|L5SppFK3?m-$k^2$5Q&^JC@TGH>{-J+Ps2(W7A4{&z0xU zZFerCC!StJGghw6Gawl(7l$YWod=G$-!I+&5^ekY7wD6J^PH6eTd)b+xgVJ$`ePi% z*NsI6FZ#&H3IB`xCohfCkA5;rlarz;088PPs01A@jSWL4qx?WaPJ#|JF$tC)14+<& zyhS;xz72#wx}p*EFeF1Y?W&waypxcoCK^FIONOc{GHUT#(YTdGowuY=tZ$EFWbL$QGfG?YID~8&2T@1I|Zt(2FbR>dy5xQO` zaLWq(vcBGEjFOWTe83k*)LP`=YC&{iIub#<2wg4*y;BL>357j@`^6Pj79nh*^IVZ`S0R!<0 zI-pg-fd3&Fnyoz9!F9j^3)*$)#r8dMUkpI9B@Ud~%99-=L(}f&QP92g_nvEGcQ()y z_r~~R!D$?*f&q_$?xnx?Tmh|2*Rbb`Oa!HU8XjJ0>@tm(1RW5~#&ad0SOgg~4o_fH zTopDCV$4LA1nrOJqCo7`SH#jX_oQk6pYEO=2wI@tW^v{&8_BXU0}ce8)N7oT zWI&q%2ZGkdk_ILQj2TFRHl{Ec$Qcl9HuENL$0TTPRBJi%KFCCrA#662puI~b0*5B5 zt~tCUXfFwfMK>vr z;WiPtT?B3$f!jw+P3hSY*`Au3A=rd%_<%1_KT)0D^b}c&yRNZy(Pu- zR&I@f+gjlE7O)B1s`%|qD^<^7^-qE>&4)vo6wj4XMEZ^19qL0h7?}iZK%UW1hjT@Y zOTl4a+84(ou1$g#^D^AE3fd2iDF1?vc=m^uybhkkXNQLT$-kg$p8dA+kKb*s`<4`W z64P;x0sn%Y^IglC>3qU%B5=Ei&6`#cZXaRu*Fj+mHenk+;H#1((|{@+#MQxxplz*! z^-x)k9=D0G@hkpSvB3v?RrQOZy_`|I5jYWa+>1srdTcWwG`lG%wgzADS==LzmSRTj z<98xx`!K_>SjX)l>hKu`+j=O17PQP;l}sN!Y6eHSzCDB}7S}!pql~*IXhF-oRmt?R zl_FfU*|H_FBZ|efPZ5ro>w*@v7O|pbPRmdl6e9!m3fhJEM9W-^nMs(n7?2lqWa86e zBMpk40eL~El`r~a4?_U*f)0%h7w;II7&F7!p;MKfqG2Fj3YAG9000HnNkld|Hrx!D3wpTu3p@It(YbkRPl66Q=}iXb=B+&m zy2+Xu!q}3ahcM|)4mk;0A-ni>P=)SRy1MsiVK1AR$PQNG)-|FcXg8Z4UF0>4YvwH} zETlCvksVxQM%uKBppUyiT9ZvJw{_ee!iUcgR@RDyNNZ0;&~H>YZG1C+w;8{z?<-LF zf=}1}I?G=OsxHPLtvzW$x7+Olu8FP1eldwcIJiv&geGjXSP38SRc2BVdYLJrj;hPM zIBzY$prSo#LAP41pE?i7q66t<7m!#41-FU7?ILj72;4rx=0zK9!6t0O2YeObK)oV# z42iO+Xir7ZFT})#>ttgqfZIplHWIjl}G< zkTFm^9i`uSGD}T3sAz9c&;<mLSRj-u=of-TLjy@^!=?k$nv4|H1acuzccQmGo zLrc&vzx?t}%pg@~seio;h!=&n*Wde-VF22K#$@{T_t6qGjEszoY{M~?_!(%s3PE%6 zH_?{1p!e_e3A<@hp8ul5;N0^)4IFGJWh_bYqgN`oV zFahs|hHOByB4_}anVES%&$7-JE_fYHsSssFb2MRw3KW`wc5LBO7IcwgeCIwoI=Y$L zpW_n7V_1G|Z5X?!2; zA&3A=D?m)WkdSNS9Jz-EdfQx`dl7UFo&$T3XX$zlbSoDHVTYc&JVL+6P`5P5Etjpx zHFEBg6|aKM1LHtujLsm@qZLUO&v#&9A&1B%a#|;%S_Pd4j*oCKqccL5bN#zO5|j1M zeEv9}V|(%npX1Y#v|KY0au4IJ8n_1MuVguR968~0d~)?J7VmQ847o!N0cw& \ 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",