From 06574c5cfd93ce0e866738b334155f18f47a0906 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 08:53:37 +0000 Subject: [PATCH] feat(ai-act-kompass): anti-AI print design and company personalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full redesign: paper tone, serif typography, hairlines, rectangular stamp badges, mono labels — technical print look instead of SaaS style, no gradients/shadows/emoji - Company profile settings (name, address, contact person, email, phone, website, logo upload as local data URL) persisted in localStorage and included in JSON backup (schema v2, v1 imports still supported) - Print/PDF output via letterhead print view (window.print): logo, company name and contact data in header, contact line in footer; applies to risk report, action plan, Annex IV skeleton and inventory - Company block also embedded in generated Markdown headers - Minimal auditable Markdown renderer (no external parser), HTML-escaped - 8 new tests (32 total) Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv --- ai-act-kompass/src/engine/documents.test.ts | 25 +- ai-act-kompass/src/engine/documents.ts | 55 +++- ai-act-kompass/src/engine/types.ts | 13 + ai-act-kompass/src/print/print.ts | 112 +++++++ ai-act-kompass/src/print/render.test.ts | 42 +++ ai-act-kompass/src/print/render.ts | 125 ++++++++ ai-act-kompass/src/store/storage.ts | 68 ++++- ai-act-kompass/src/styles.css | 309 +++++++++++++------- ai-act-kompass/src/ui/App.tsx | 20 +- ai-act-kompass/src/ui/Dashboard.tsx | 39 ++- ai-act-kompass/src/ui/SettingsView.tsx | 115 +++++++- ai-act-kompass/src/ui/SystemDetail.tsx | 19 +- 12 files changed, 792 insertions(+), 150 deletions(-) create mode 100644 ai-act-kompass/src/print/print.ts create mode 100644 ai-act-kompass/src/print/render.test.ts create mode 100644 ai-act-kompass/src/print/render.ts diff --git a/ai-act-kompass/src/engine/documents.test.ts b/ai-act-kompass/src/engine/documents.test.ts index ef8b036..8b14691 100644 --- a/ai-act-kompass/src/engine/documents.test.ts +++ b/ai-act-kompass/src/engine/documents.test.ts @@ -3,7 +3,7 @@ import { CONTENT_PACK } from '../content/pack'; import { classify } from './classify'; import { actionPlan, annexIVSkeleton, inventoryReport, riskReport } from './documents'; import { deriveObligations } from './obligations'; -import type { AISystem, AssessmentAnswers } from './types'; +import type { AISystem, AssessmentAnswers, CompanyProfile } from './types'; const answers: AssessmentAnswers = { isAISystem: true, @@ -63,4 +63,27 @@ describe('documents', () => { expect(md).toContain('| System |'); expect(md).toContain('Registrierte KI-Systeme: **1**'); }); + + it('übernimmt das Firmenprofil in den Berichtskopf', () => { + const profile: CompanyProfile = { + name: 'Muster GmbH', + street: 'Musterstraße 1', + zipCity: '60311 Frankfurt', + contactPerson: 'Max Mustermann', + email: 'compliance@muster.de', + phone: '+49 69 000000', + website: 'www.muster.de', + logoDataUrl: '', + }; + const md = riskReport(system, CONTENT_PACK, TODAY, profile); + expect(md).toContain('| **Unternehmen** | Muster GmbH |'); + expect(md).toContain('compliance@muster.de'); + expect(md).toContain('| **Ansprechpartner** | Max Mustermann |'); + expect(inventoryReport([system], CONTENT_PACK, TODAY, profile)).toContain('Muster GmbH · Stand'); + }); + + it('lässt den Berichtskopf ohne Profil unverändert', () => { + const md = riskReport(system, CONTENT_PACK, TODAY); + expect(md).not.toContain('**Unternehmen**'); + }); }); diff --git a/ai-act-kompass/src/engine/documents.ts b/ai-act-kompass/src/engine/documents.ts index 8e5d65d..937654a 100644 --- a/ai-act-kompass/src/engine/documents.ts +++ b/ai-act-kompass/src/engine/documents.ts @@ -1,4 +1,4 @@ -import type { AISystem, ContentPack, Obligation } from './types'; +import type { AISystem, CompanyProfile, ContentPack, Obligation } from './types'; const RISK_LABELS: Record = { 'out-of-scope': 'Nicht anwendbar (keine KI i. S. d. Art. 3 Nr. 1)', @@ -31,12 +31,32 @@ function formatDate(iso: string): string { return d && m && y ? `${d}.${m}.${y}` : iso; } -function header(title: string, system: AISystem, pack: ContentPack, today: string): string { +/** Kontaktzeile aus dem Firmenprofil (leer, wenn kein Profil gepflegt). */ +function companyRows(profile: CompanyProfile | undefined): readonly string[] { + if (!profile || profile.name.trim() === '') return []; + const contact = [profile.street, profile.zipCity, profile.email, profile.phone, profile.website] + .filter((p) => p.trim() !== '') + .join(' · '); + return [ + `| **Unternehmen** | ${profile.name} |`, + ...(contact ? [`| **Kontakt** | ${contact} |`] : []), + ...(profile.contactPerson.trim() ? [`| **Ansprechpartner** | ${profile.contactPerson} |`] : []), + ]; +} + +function header( + title: string, + system: AISystem, + pack: ContentPack, + today: string, + profile?: CompanyProfile, +): string { return [ `# ${title}`, '', `| | |`, `|---|---|`, + ...companyRows(profile), `| **KI-System** | ${system.name} |`, `| **Zweckbestimmung** | ${system.purpose || '—'} |`, `| **Hersteller/Anbieter** | ${system.vendor || '—'} |`, @@ -57,9 +77,15 @@ function header(title: string, system: AISystem, pack: ContentPack, today: strin * @param system - Bewertetes KI-System * @param pack - Regulatorischer Content-Pack * @param today - Stichtag im ISO-Format + * @param profile - Firmenprofil für den Berichtskopf (optional) */ -export function riskReport(system: AISystem, pack: ContentPack, today: string): string { - const lines = [header('Risikoklassifizierung nach EU AI Act', system, pack, today)]; +export function riskReport( + system: AISystem, + pack: ContentPack, + today: string, + profile?: CompanyProfile, +): string { + const lines = [header('Risikoklassifizierung nach EU AI Act', system, pack, today, profile)]; lines.push('## Ergebnis', ''); lines.push(`**${RISK_LABELS[system.classification.riskClass] ?? system.classification.riskClass}**`, ''); @@ -92,14 +118,16 @@ export function riskReport(system: AISystem, pack: ContentPack, today: string): * @param obligations - Anwendbare Pflichten * @param pack - Regulatorischer Content-Pack * @param today - Stichtag im ISO-Format + * @param profile - Firmenprofil für den Berichtskopf (optional) */ export function actionPlan( system: AISystem, obligations: readonly Obligation[], pack: ContentPack, today: string, + profile?: CompanyProfile, ): string { - const lines = [header('Maßnahmenplan EU AI Act', system, pack, today)]; + const lines = [header('Maßnahmenplan EU AI Act', system, pack, today, profile)]; if (obligations.length === 0) { lines.push('Für dieses System bestehen keine umsetzbaren Pflichten (verboten oder außerhalb des Anwendungsbereichs).'); @@ -130,10 +158,18 @@ export function actionPlan( * @param system - Bewertetes KI-System * @param pack - Regulatorischer Content-Pack * @param today - Stichtag im ISO-Format + * @param profile - Firmenprofil für den Berichtskopf (optional) */ -export function annexIVSkeleton(system: AISystem, pack: ContentPack, today: string): string { +export function annexIVSkeleton( + system: AISystem, + pack: ContentPack, + today: string, + profile?: CompanyProfile, +): string { const todo = '_[AUSFÜLLEN]_'; - const lines = [header('Technische Dokumentation (Annex IV) — Grundgerüst', system, pack, today)]; + const lines = [ + header('Technische Dokumentation (Annex IV) — Grundgerüst', system, pack, today, profile), + ]; lines.push( '## 1. Allgemeine Beschreibung des KI-Systems', @@ -188,16 +224,19 @@ export function annexIVSkeleton(system: AISystem, pack: ContentPack, today: stri * @param systems - Alle registrierten KI-Systeme * @param pack - Regulatorischer Content-Pack * @param today - Stichtag im ISO-Format + * @param profile - Firmenprofil für den Berichtskopf (optional) */ export function inventoryReport( systems: readonly AISystem[], pack: ContentPack, today: string, + profile?: CompanyProfile, ): string { + const company = profile && profile.name.trim() !== '' ? `${profile.name} · ` : ''; const lines = [ '# KI-Inventar (AI Asset Register)', '', - `Stand: ${formatDate(today)} · Content-Pack v${pack.version}`, + `${company}Stand: ${formatDate(today)} · Content-Pack v${pack.version}`, '', DISCLAIMER, '', diff --git a/ai-act-kompass/src/engine/types.ts b/ai-act-kompass/src/engine/types.ts index a1fc17b..5cf1208 100644 --- a/ai-act-kompass/src/engine/types.ts +++ b/ai-act-kompass/src/engine/types.ts @@ -81,6 +81,19 @@ export interface AISystem { readonly obligationStatus: Readonly>; } +/** Firmenprofil für personalisierte Berichte (Briefkopf, PDF-Druck). */ +export interface CompanyProfile { + readonly name: string; + readonly street: string; + readonly zipCity: string; + readonly contactPerson: string; + readonly email: string; + readonly phone: string; + readonly website: string; + /** Logo als Data-URL (bleibt lokal im Browser), leer = kein Logo. */ + readonly logoDataUrl: string; +} + /** Eintrag im regulatorischen Content-Pack (Checklisten-Item mit Rechtsgrundlage). */ export interface ContentItem { readonly id: string; diff --git a/ai-act-kompass/src/print/print.ts b/ai-act-kompass/src/print/print.ts new file mode 100644 index 0000000..981807d --- /dev/null +++ b/ai-act-kompass/src/print/print.ts @@ -0,0 +1,112 @@ +import type { CompanyProfile } from '../engine/types'; +import { renderMarkdown } from './render'; + +/** Maskiert HTML-Sonderzeichen für den Briefkopf. */ +function esc(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +/** Baut den Briefkopf (Logo, Firmenname, Kontaktdaten) als HTML. */ +function letterhead(profile: CompanyProfile): string { + const addressLines = [profile.street, profile.zipCity].filter((l) => l.trim() !== ''); + const contactLines = [ + profile.contactPerson, + profile.email, + profile.phone, + profile.website, + ].filter((l) => l.trim() !== ''); + + if (profile.name.trim() === '' && profile.logoDataUrl === '' && contactLines.length === 0) { + return ''; + } + + const logo = profile.logoDataUrl + ? `` + : ''; + + return ` +
+
+
${esc(profile.name)}
+
${addressLines.map(esc).join('
')}
+
${contactLines.map(esc).join(' · ')}
+
+ ${logo} +
`; +} + +/** Fußzeile mit Kontaktdaten für jede gedruckte Seite. */ +function footer(profile: CompanyProfile): string { + const parts = [profile.name, profile.street, profile.zipCity, profile.email, profile.phone] + .filter((p) => p.trim() !== '') + .map(esc); + return parts.length > 0 ? `
${parts.join(' · ')}
` : ''; +} + +const PRINT_CSS = ` + @page { size: A4; margin: 22mm 20mm; } + * { box-sizing: border-box; } + body { + font-family: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif; + color: #14120e; margin: 0; font-size: 11.5pt; line-height: 1.55; background: #fff; + } + .letterhead { display: flex; justify-content: space-between; align-items: flex-start; + border-bottom: 2.5pt solid #14120e; padding-bottom: 10pt; margin-bottom: 18pt; } + .lh-name { font-size: 15pt; font-weight: 700; letter-spacing: 0.02em; } + .lh-lines { font-size: 9pt; color: #444; margin-top: 3pt; + font-family: ui-monospace, 'Courier New', monospace; } + .lh-logo { max-height: 52pt; max-width: 160pt; object-fit: contain; } + h1 { font-size: 17pt; margin: 0 0 12pt; line-height: 1.25; + border-bottom: 1pt solid #14120e; padding-bottom: 6pt; } + h2 { font-size: 13pt; margin: 16pt 0 6pt; } + h3 { font-size: 11.5pt; margin: 12pt 0 4pt; } + p { margin: 0 0 8pt; } + ul { margin: 0 0 8pt; padding-left: 16pt; } + li { margin-bottom: 3pt; } + blockquote { margin: 10pt 0; padding: 6pt 10pt; border-left: 2.5pt solid #a3271e; + font-size: 9.5pt; color: #444; } + table { width: 100%; border-collapse: collapse; margin: 6pt 0 12pt; font-size: 9.5pt; } + th { text-align: left; border-bottom: 1.5pt solid #14120e; padding: 4pt 6pt; + font-family: ui-monospace, 'Courier New', monospace; font-size: 8pt; + text-transform: uppercase; letter-spacing: 0.06em; } + td { border-bottom: 0.5pt solid #999; padding: 4pt 6pt; vertical-align: top; } + em { color: #a3271e; font-style: italic; } + .pagefoot { margin-top: 24pt; padding-top: 6pt; border-top: 0.75pt solid #14120e; + font-size: 7.5pt; color: #555; font-family: ui-monospace, 'Courier New', monospace; } +`; + +/** + * Öffnet ein Dokument als druckfertige Ansicht mit Firmen-Briefkopf und + * startet den Druckdialog (PDF via "Als PDF speichern" des Browsers). + * Alles läuft lokal — keine Server-Roundtrips. + * + * @param title - Fenstertitel (wird auch Standard-PDF-Dateiname) + * @param markdown - Vom Dokumenten-Generator erzeugtes Markdown + * @param profile - Firmenprofil für Briefkopf und Fußzeile + */ +export function printDocument(title: string, markdown: string, profile: CompanyProfile): void { + const win = window.open('', '_blank', 'width=900,height=1000'); + if (!win) { + window.alert('Popup blockiert — bitte Popups für diese Seite erlauben, um drucken zu können.'); + return; + } + win.document.write(` + + + +${esc(title)} + + + +${letterhead(profile)} +${renderMarkdown(markdown)} +${footer(profile)} + + +`); + win.document.close(); +} diff --git a/ai-act-kompass/src/print/render.test.ts b/ai-act-kompass/src/print/render.test.ts new file mode 100644 index 0000000..c6baf53 --- /dev/null +++ b/ai-act-kompass/src/print/render.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { renderMarkdown } from './render'; + +describe('renderMarkdown', () => { + it('rendert Überschriften aller Ebenen', () => { + const html = renderMarkdown('# H1\n\n## H2\n\n### H3'); + expect(html).toContain('

H1

'); + expect(html).toContain('

H2

'); + expect(html).toContain('

H3

'); + }); + + it('rendert Tabellen mit Kopfzeile', () => { + const html = renderMarkdown('| Pflicht | Frist |\n|---|---|\n| QMS | 2027 |'); + expect(html).toContain('Pflicht'); + expect(html).toContain('QMS'); + expect(html).not.toContain('---'); + }); + + it('rendert Meta-Tabellen mit leerer Kopfzeile', () => { + const html = renderMarkdown('| | |\n|---|---|\n| **KI-System** | Chatbot |'); + expect(html).toContain('KI-System'); + expect(html).toContain('Chatbot'); + }); + + it('rendert Listen und Blockquotes', () => { + const html = renderMarkdown('- Punkt eins\n- Punkt zwei\n\n> Hinweis'); + expect(html).toContain('
  • Punkt eins
  • '); + expect(html).toContain('
    Hinweis
    '); + }); + + it('rendert Inline-Formatierung', () => { + const html = renderMarkdown('Das ist **wichtig** und _offen_.'); + expect(html).toContain('wichtig'); + expect(html).toContain('offen'); + }); + + it('maskiert HTML in Eingaben (kein Injection über Systemnamen)', () => { + const html = renderMarkdown('# '); + expect(html).not.toContain('