feat(ai-act-kompass): anti-AI print design and company personalization

- 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
This commit is contained in:
Claude 2026-07-04 08:53:37 +00:00
parent fb2da0db00
commit 06574c5cfd
No known key found for this signature in database
12 changed files with 792 additions and 150 deletions

View File

@ -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**');
});
});

View File

@ -1,4 +1,4 @@
import type { AISystem, ContentPack, Obligation } from './types';
import type { AISystem, CompanyProfile, ContentPack, Obligation } from './types';
const RISK_LABELS: Record<string, string> = {
'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,
'',

View File

@ -81,6 +81,19 @@ export interface AISystem {
readonly obligationStatus: Readonly<Record<string, ObligationStatus>>;
}
/** 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;

View File

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/** 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
? `<img class="lh-logo" src="${profile.logoDataUrl}" alt="Logo">`
: '';
return `
<header class="letterhead">
<div class="lh-company">
<div class="lh-name">${esc(profile.name)}</div>
<div class="lh-lines">${addressLines.map(esc).join('<br>')}</div>
<div class="lh-lines">${contactLines.map(esc).join(' · ')}</div>
</div>
${logo}
</header>`;
}
/** 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 ? `<footer class="pagefoot">${parts.join(' · ')}</footer>` : '';
}
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(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>${esc(title)}</title>
<style>${PRINT_CSS}</style>
</head>
<body>
${letterhead(profile)}
${renderMarkdown(markdown)}
${footer(profile)}
<script>window.addEventListener('load', function () { window.print(); });</script>
</body>
</html>`);
win.document.close();
}

View File

@ -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>H1</h1>');
expect(html).toContain('<h2>H2</h2>');
expect(html).toContain('<h3>H3</h3>');
});
it('rendert Tabellen mit Kopfzeile', () => {
const html = renderMarkdown('| Pflicht | Frist |\n|---|---|\n| QMS | 2027 |');
expect(html).toContain('<th>Pflicht</th>');
expect(html).toContain('<td>QMS</td>');
expect(html).not.toContain('---');
});
it('rendert Meta-Tabellen mit leerer Kopfzeile', () => {
const html = renderMarkdown('| | |\n|---|---|\n| **KI-System** | Chatbot |');
expect(html).toContain('<strong>KI-System</strong>');
expect(html).toContain('<td>Chatbot</td>');
});
it('rendert Listen und Blockquotes', () => {
const html = renderMarkdown('- Punkt eins\n- Punkt zwei\n\n> Hinweis');
expect(html).toContain('<li>Punkt eins</li>');
expect(html).toContain('<blockquote>Hinweis</blockquote>');
});
it('rendert Inline-Formatierung', () => {
const html = renderMarkdown('Das ist **wichtig** und _offen_.');
expect(html).toContain('<strong>wichtig</strong>');
expect(html).toContain('<em>offen</em>');
});
it('maskiert HTML in Eingaben (kein Injection über Systemnamen)', () => {
const html = renderMarkdown('# <script>alert(1)</script>');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
});

View File

@ -0,0 +1,125 @@
/**
* Minimaler Markdown-Renderer für die intern erzeugten Dokumente.
*
* Unterstützt genau die Konstrukte, die der Dokumenten-Generator ausgibt:
* Überschriften (#/##/###), Tabellen, Listen, Blockquotes, Absätze sowie
* **fett** und _kursiv_. Kein externer Parser bewusst klein und auditierbar.
*/
/** Maskiert HTML-Sonderzeichen. */
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/** Wandelt Inline-Markdown (**fett**, _kursiv_) in HTML um. */
function inline(text: string): string {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>');
}
function isTableRow(line: string): boolean {
return line.trimStart().startsWith('|');
}
function isSeparatorRow(line: string): boolean {
return /^\s*\|[\s\-|:]+\|\s*$/.test(line);
}
function splitCells(line: string): readonly string[] {
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map((c) => c.trim());
}
function renderTable(rows: readonly string[]): string {
const [first, second] = rows;
const hasHeader = first !== undefined && second !== undefined && isSeparatorRow(second);
const bodyRows = hasHeader ? rows.slice(2) : rows.filter((r) => !isSeparatorRow(r));
const parts: string[] = ['<table>'];
if (hasHeader) {
const headerCells = splitCells(first)
.map((c) => `<th>${inline(c)}</th>`)
.join('');
parts.push(`<thead><tr>${headerCells}</tr></thead>`);
}
parts.push('<tbody>');
for (const row of bodyRows) {
const cells = splitCells(row)
.map((c) => `<td>${inline(c)}</td>`)
.join('');
parts.push(`<tr>${cells}</tr>`);
}
parts.push('</tbody></table>');
return parts.join('');
}
/**
* Rendert die intern erzeugten Markdown-Dokumente als HTML.
*
* @param markdown - Vom Dokumenten-Generator erzeugtes Markdown
* @returns HTML-Fragment (ohne umgebendes Dokument)
*/
export function renderMarkdown(markdown: string): string {
const lines = markdown.split('\n');
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? '';
if (line.trim() === '') {
i += 1;
continue;
}
if (line.startsWith('### ')) {
out.push(`<h3>${inline(line.slice(4))}</h3>`);
i += 1;
continue;
}
if (line.startsWith('## ')) {
out.push(`<h2>${inline(line.slice(3))}</h2>`);
i += 1;
continue;
}
if (line.startsWith('# ')) {
out.push(`<h1>${inline(line.slice(2))}</h1>`);
i += 1;
continue;
}
if (line.startsWith('> ')) {
const quote: string[] = [];
while (i < lines.length && (lines[i] ?? '').startsWith('> ')) {
quote.push(inline((lines[i] ?? '').slice(2)));
i += 1;
}
out.push(`<blockquote>${quote.join('<br>')}</blockquote>`);
continue;
}
if (line.startsWith('- ')) {
const items: string[] = [];
while (i < lines.length && (lines[i] ?? '').startsWith('- ')) {
items.push(`<li>${inline((lines[i] ?? '').slice(2))}</li>`);
i += 1;
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
if (isTableRow(line)) {
const rows: string[] = [];
while (i < lines.length && isTableRow(lines[i] ?? '')) {
rows.push(lines[i] ?? '');
i += 1;
}
out.push(renderTable(rows));
continue;
}
out.push(`<p>${inline(line)}</p>`);
i += 1;
}
return out.join('\n');
}

View File

@ -1,13 +1,36 @@
import type { AISystem } from '../engine/types';
import type { AISystem, CompanyProfile } from '../engine/types';
const STORAGE_KEY = 'ai-act-kompass/systems/v1';
const PROFILE_KEY = 'ai-act-kompass/profile/v1';
/** Format der Export-/Import-Datei. */
export interface ExportFile {
readonly app: 'ai-act-kompass';
readonly schemaVersion: 1;
readonly schemaVersion: 1 | 2;
readonly exportedAt: string;
readonly systems: readonly AISystem[];
/** Ab schemaVersion 2 enthalten. */
readonly profile?: CompanyProfile;
}
/** Ergebnis eines Imports (Profil optional, ältere Backups haben keins). */
export interface ImportResult {
readonly systems: readonly AISystem[];
readonly profile: CompanyProfile | null;
}
/** Leeres Firmenprofil (Default). */
export function emptyProfile(): CompanyProfile {
return {
name: '',
street: '',
zipCity: '',
contactPerson: '',
email: '',
phone: '',
website: '',
logoDataUrl: '',
};
}
/**
@ -33,30 +56,56 @@ export function saveSystems(systems: readonly AISystem[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(systems));
}
/** Lädt das Firmenprofil aus dem lokalen Speicher (fail-safe). */
export function loadProfile(): CompanyProfile {
try {
const raw = localStorage.getItem(PROFILE_KEY);
if (!raw) return emptyProfile();
const parsed: unknown = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null
? { ...emptyProfile(), ...(parsed as Partial<CompanyProfile>) }
: emptyProfile();
} catch {
return emptyProfile();
}
}
/** Persistiert das Firmenprofil im lokalen Speicher des Browsers. */
export function saveProfile(profile: CompanyProfile): void {
localStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
}
/**
* Serialisiert alle Daten als Export-Datei (JSON) für Backup/Umzug.
*
* @param systems - Zu exportierende Systeme
* @param profile - Firmenprofil
* @param exportedAt - Zeitstempel im ISO-Format
*/
export function exportData(systems: readonly AISystem[], exportedAt: string): string {
export function exportData(
systems: readonly AISystem[],
profile: CompanyProfile,
exportedAt: string,
): string {
const file: ExportFile = {
app: 'ai-act-kompass',
schemaVersion: 1,
schemaVersion: 2,
exportedAt,
systems,
profile,
};
return JSON.stringify(file, null, 2);
}
/**
* Liest eine Export-Datei ein und validiert das Grundformat.
* Versteht schemaVersion 1 (ohne Profil) und 2 (mit Profil).
*
* @param json - Inhalt der Export-Datei
* @returns Systeme aus der Datei
* @returns Systeme und ggf. Firmenprofil aus der Datei
* @throws Error bei unbekanntem oder defektem Format
*/
export function importData(json: string): readonly AISystem[] {
export function importData(json: string): ImportResult {
const parsed: unknown = JSON.parse(json);
if (
typeof parsed !== 'object' ||
@ -66,10 +115,15 @@ export function importData(json: string): readonly AISystem[] {
) {
throw new Error('Unbekanntes Dateiformat — keine gültige AI-Act-Kompass-Exportdatei.');
}
return (parsed as ExportFile).systems;
const file = parsed as ExportFile;
return {
systems: file.systems,
profile: file.profile ? { ...emptyProfile(), ...file.profile } : null,
};
}
/** Löscht alle lokal gespeicherten Daten unwiderruflich. */
export function clearAll(): void {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(PROFILE_KEY);
}

View File

@ -1,185 +1,286 @@
/*
* AI-Act-Kompass Anti-AI-Design.
* Gestaltungsprinzip: technisches Druckwerk statt SaaS-Dashboard.
* Papierton, Serifenschrift, Hairlines, rechteckige "Stempel"
* keine Verläufe, keine Schatten, keine Rundungen, keine Emojis.
*/
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--ink: #1a2233;
--muted: #5b6577;
--line: #e3e6ec;
--accent: #1f4fd8;
--accent-ink: #ffffff;
--danger: #b3261e;
--warn: #9a6700;
--ok: #1a7f37;
--radius: 10px;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--paper: #f4f1ea;
--panel: #fdfcf8;
--ink: #14120e;
--muted: #6b675e;
--line: #14120e;
--hairline: #c9c4b8;
--accent: #a3271e;
--ok: #2d5d3a;
--warn: #8a6d1a;
--serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New', monospace;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
}
.layout { display: flex; min-height: 100vh; }
/* ── Seitenleiste: Kolumnentitel eines Fachblatts ─────────────────── */
.sidebar {
width: 240px;
width: 250px;
flex-shrink: 0;
background: #101a33;
color: #d7deee;
padding: 20px 14px;
background: var(--paper);
border-right: 2px solid var(--ink);
padding: 26px 18px;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar .brand {
font-size: 18px;
font-size: 21px;
font-weight: 700;
color: #fff;
margin-bottom: 2px;
letter-spacing: 0.01em;
border-bottom: 3px double var(--ink);
padding-bottom: 12px;
}
.sidebar .brand small { display: block; font-size: 11px; font-weight: 400; color: #8fa0c4; margin-top: 4px; }
.sidebar nav { display: flex; flex-direction: column; gap: 2px; margin-top: 18px; }
.sidebar .brand small {
display: block;
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-top: 8px;
}
.sidebar nav { display: flex; flex-direction: column; margin-top: 22px; }
.sidebar button {
text-align: left;
background: none;
border: none;
color: #c3cde3;
padding: 9px 12px;
border-radius: 8px;
font-size: 14px;
border-bottom: 1px solid var(--hairline);
color: var(--ink);
padding: 12px 4px;
font-family: var(--serif);
font-size: 15px;
cursor: pointer;
}
.sidebar button:hover { background: rgba(255, 255, 255, 0.08); }
.sidebar button.active { background: var(--accent); color: #fff; }
.sidebar .foot { margin-top: auto; font-size: 11px; color: #7787ab; line-height: 1.5; }
.sidebar button:hover { color: var(--accent); }
.sidebar button.active {
font-weight: 700;
border-left: 4px solid var(--accent);
padding-left: 10px;
}
.sidebar .foot {
margin-top: auto;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
line-height: 1.8;
border-top: 1px solid var(--ink);
padding-top: 12px;
}
.main { flex: 1; padding: 28px 36px; max-width: 1040px; }
.main { flex: 1; padding: 32px 40px; max-width: 1060px; }
h1 { font-size: 22px; margin: 0 0 4px; }
h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
h2 { font-size: 16px; margin: 22px 0 10px; }
.sub { color: var(--muted); font-size: 14px; margin: 0 0 20px; }
.sub { color: var(--muted); font-size: 15px; margin: 0 0 22px; font-style: italic; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px 20px;
margin-bottom: 16px;
border: 1px solid var(--ink);
padding: 20px 22px;
margin-bottom: 18px;
}
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
.stat { text-align: left; }
.stat .num { font-size: 28px; font-weight: 700; }
.stat .lbl { font-size: 13px; color: var(--muted); }
/* ── Kennzahlen ───────────────────────────────────────────────────── */
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0; }
.grid .stat { padding: 0 18px; border-left: 1px solid var(--hairline); }
.grid .stat:first-child { border-left: none; padding-left: 0; }
.stat .num { font-size: 34px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat .lbl {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
margin-top: 2px;
}
/* ── Tabellen ─────────────────────────────────────────────────────── */
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); }
th {
text-align: left;
font-family: var(--mono);
color: var(--ink);
font-weight: 600;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
border-bottom: 2px solid var(--ink);
}
th, td { padding: 10px; }
td { border-bottom: 1px solid var(--hairline); vertical-align: top; }
tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: #f0f3fa; }
tr.clickable:hover td { background: #efe9dc; }
/* ── Risiko-Stempel ───────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
padding: 2px 9px;
border: 1.5px solid currentColor;
font-family: var(--mono);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
white-space: nowrap;
background: transparent;
}
.badge.high { background: #fde8e7; color: var(--danger); }
.badge.prohibited { background: var(--danger); color: #fff; }
.badge.limited { background: #fff3d6; color: var(--warn); }
.badge.minimal { background: #e2f5e8; color: var(--ok); }
.badge.out-of-scope { background: #e8eaf0; color: var(--muted); }
.badge.status-open { background: #fde8e7; color: var(--danger); }
.badge.status-in-progress { background: #fff3d6; color: var(--warn); }
.badge.status-done { background: #e2f5e8; color: var(--ok); }
.badge.status-not-applicable { background: #e8eaf0; color: var(--muted); }
.badge.high { color: var(--accent); }
.badge.prohibited { color: var(--panel); background: var(--accent); border-color: var(--accent); }
.badge.limited { color: var(--warn); }
.badge.minimal { color: var(--ok); }
.badge.out-of-scope { color: var(--muted); }
/* ── Buttons: Stempelkissen-Optik ─────────────────────────────────── */
button.primary, button.ghost, button.danger {
border-radius: 8px;
padding: 9px 16px;
font-size: 14px;
font-family: var(--mono);
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
border: 1px solid transparent;
border: 1.5px solid var(--ink);
background: var(--panel);
color: var(--ink);
}
button.primary { background: var(--accent); color: var(--accent-ink); }
button.primary:hover { filter: brightness(1.1); }
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
button.ghost { background: #fff; border-color: var(--line); color: var(--ink); }
button.primary { background: var(--ink); color: var(--paper); }
button.primary:hover { background: var(--accent); border-color: var(--accent); }
button.primary:disabled { opacity: 0.4; cursor: not-allowed; }
button.ghost:hover { border-color: var(--accent); color: var(--accent); }
button.danger { background: #fff; border-color: var(--danger); color: var(--danger); }
button.danger { border-color: var(--accent); color: var(--accent); }
button.danger:hover { background: var(--accent); color: var(--paper); }
/* ── Formulare ────────────────────────────────────────────────────── */
label.field { display: block; margin-bottom: 14px; font-size: 14px; }
label.field span { display: block; font-weight: 600; margin-bottom: 5px; }
label.field span {
display: block;
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 5px;
}
input[type='text'], select, textarea {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: 8px;
border: 1px solid var(--ink);
font-size: 14px;
font-family: inherit;
font-family: var(--serif);
background: #fff;
color: var(--ink);
border-radius: 0;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 22px; }
.logo-row { display: flex; align-items: center; gap: 16px; margin-top: 6px; flex-wrap: wrap; }
.logo-preview {
max-height: 56px;
max-width: 180px;
object-fit: contain;
border: 1px solid var(--hairline);
padding: 6px;
background: #fff;
}
.check {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 11px 13px;
border: 1px solid var(--hairline);
border-left: 3px solid var(--ink);
margin-bottom: 8px;
cursor: pointer;
font-size: 14px;
font-size: 14.5px;
background: var(--panel);
}
.check:hover { border-color: var(--accent); }
.check input { margin-top: 3px; }
.check .ref { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; }
.wizard-steps { display: flex; gap: 6px; margin-bottom: 18px; flex-wrap: wrap; }
.wizard-steps .step {
font-size: 12px;
padding: 5px 11px;
border-radius: 99px;
background: #e8eaf0;
.check:hover { border-left-color: var(--accent); }
.check input { margin-top: 4px; accent-color: var(--accent); }
.check .ref {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
margin-top: 3px;
}
.wizard-steps .step.current { background: var(--accent); color: #fff; }
.wizard-steps .step.done { background: #cdd8f6; color: var(--accent); }
/* ── Wizard ───────────────────────────────────────────────────────── */
.wizard-steps { display: flex; gap: 0; margin-bottom: 20px; flex-wrap: wrap; border: 1px solid var(--ink); }
.wizard-steps .step {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 7px 12px;
border-right: 1px solid var(--hairline);
color: var(--muted);
background: var(--panel);
}
.wizard-steps .step:last-child { border-right: none; }
.wizard-steps .step.current { background: var(--ink); color: var(--paper); }
.wizard-steps .step.done { color: var(--ink); font-weight: 700; }
.wizard-nav { display: flex; justify-content: space-between; margin-top: 20px; }
.reasons li { margin-bottom: 6px; font-size: 14px; }
.reasons li { margin-bottom: 7px; font-size: 14.5px; }
.hint { font-size: 13px; color: var(--muted); }
.disclaimer {
font-size: 12px;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
border-left: 3px solid var(--warn);
padding: 8px 12px;
background: #fffaf0;
border-radius: 0 8px 8px 0;
margin-top: 20px;
border-left: 3px solid var(--accent);
padding: 9px 13px;
background: var(--panel);
margin-top: 22px;
}
/* ── Fristen ──────────────────────────────────────────────────────── */
.deadline-list { list-style: none; padding: 0; margin: 0; }
.deadline-list li {
display: flex;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid var(--line);
font-size: 14px;
gap: 14px;
padding: 10px 0;
border-bottom: 1px solid var(--hairline);
font-size: 14.5px;
align-items: baseline;
}
.deadline-list li:last-child { border-bottom: none; }
.deadline-list .date { font-weight: 700; white-space: nowrap; min-width: 92px; }
.deadline-list .past { color: var(--muted); }
.deadline-list .date {
font-family: var(--mono);
font-weight: 700;
font-size: 13px;
white-space: nowrap;
min-width: 96px;
}
.deadline-list .past { color: var(--muted); text-decoration: line-through; }
/* ── Dokumentvorschau ─────────────────────────────────────────────── */
.doc-preview {
background: #0f172a;
color: #dbe4f5;
border-radius: 8px;
padding: 16px;
font-size: 12.5px;
background: #fff;
color: var(--ink);
border: 1px solid var(--ink);
padding: 18px;
font-family: var(--mono);
font-size: 12px;
overflow-x: auto;
white-space: pre-wrap;
max-height: 420px;
@ -187,4 +288,4 @@ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); ou
}
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 16px; }
.empty { text-align: center; color: var(--muted); padding: 40px 0; font-size: 14px; }
.empty { text-align: center; color: var(--muted); padding: 44px 0; font-size: 14.5px; font-style: italic; }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import type { AISystem } from '../engine/types';
import { loadSystems, saveSystems } from '../store/storage';
import type { AISystem, CompanyProfile } from '../engine/types';
import { loadProfile, loadSystems, saveProfile, saveSystems } from '../store/storage';
import { Dashboard } from './Dashboard';
import { SettingsView } from './SettingsView';
import { SystemDetail } from './SystemDetail';
@ -15,12 +15,17 @@ type View =
/** App-Shell: Navigation, State-Verwaltung und Persistenz. */
export function App(): JSX.Element {
const [systems, setSystems] = useState<readonly AISystem[]>(() => loadSystems());
const [profile, setProfile] = useState<CompanyProfile>(() => loadProfile());
const [view, setView] = useState<View>({ kind: 'dashboard' });
useEffect(() => {
saveSystems(systems);
}, [systems]);
useEffect(() => {
saveProfile(profile);
}, [profile]);
const findSystem = (id: string): AISystem | undefined => systems.find((s) => s.id === id);
const upsert = (system: AISystem): void => {
@ -39,7 +44,7 @@ export function App(): JSX.Element {
<div className="layout">
<aside className="sidebar">
<div className="brand">
🧭 AI-Act-Kompass
AI-Act-Kompass
<small>EU AI Act Compliance für KMU · local-first</small>
</div>
<nav>
@ -76,6 +81,7 @@ export function App(): JSX.Element {
{view.kind === 'dashboard' && (
<Dashboard
systems={systems}
profile={profile}
onOpen={(id) => setView({ kind: 'detail', systemId: id })}
onNew={() => setView({ kind: 'wizard', systemId: null })}
/>
@ -101,6 +107,7 @@ export function App(): JSX.Element {
return (
<SystemDetail
system={system}
profile={profile}
onUpdate={upsert}
onReassess={() => setView({ kind: 'wizard', systemId: system.id })}
onDelete={() => {
@ -116,7 +123,12 @@ export function App(): JSX.Element {
{view.kind === 'settings' && (
<SettingsView
systems={systems}
onImport={(imported) => setSystems(imported)}
profile={profile}
onProfileChange={setProfile}
onImport={(imported, importedProfile) => {
setSystems(imported);
if (importedProfile) setProfile(importedProfile);
}}
onClear={() => setSystems([])}
/>
)}

View File

@ -1,16 +1,18 @@
import { CONTENT_PACK } from '../content/pack';
import { inventoryReport } from '../engine/documents';
import type { AISystem } from '../engine/types';
import type { AISystem, CompanyProfile } from '../engine/types';
import { printDocument } from '../print/print';
import { downloadText, RiskBadge, ROLE_LABELS, todayISO } from './common';
interface DashboardProps {
readonly systems: readonly AISystem[];
readonly profile: CompanyProfile;
readonly onOpen: (id: string) => void;
readonly onNew: () => void;
}
/** Übersichtsseite: Kennzahlen, Fristen und das KI-Inventar. */
export function Dashboard({ systems, onOpen, onNew }: DashboardProps): JSX.Element {
export function Dashboard({ systems, profile, onOpen, onNew }: DashboardProps): JSX.Element {
const today = todayISO();
const count = (risk: string): number =>
systems.filter((s) => s.classification.riskClass === risk).length;
@ -69,14 +71,31 @@ export function Dashboard({ systems, onOpen, onNew }: DashboardProps): JSX.Eleme
<h2 style={{ margin: 0 }}>KI-Inventar</h2>
<div className="toolbar" style={{ margin: 0 }}>
{systems.length > 0 && (
<>
<button
className="ghost"
onClick={() =>
downloadText(`ki-inventar-${today}.md`, inventoryReport(systems, CONTENT_PACK, today))
printDocument(
`ki-inventar-${today}`,
inventoryReport(systems, CONTENT_PACK, today, profile),
profile,
)
}
>
Inventar exportieren
Inventar drucken / PDF
</button>
<button
className="ghost"
onClick={() =>
downloadText(
`ki-inventar-${today}.md`,
inventoryReport(systems, CONTENT_PACK, today, profile),
)
}
>
.md
</button>
</>
)}
<button className="primary" onClick={onNew}>
+ System bewerten

View File

@ -1,35 +1,128 @@
import { useRef, useState } from 'react';
import { CONTENT_PACK } from '../content/pack';
import type { AISystem } from '../engine/types';
import type { AISystem, CompanyProfile } from '../engine/types';
import { clearAll, exportData, importData } from '../store/storage';
import { downloadText, todayISO } from './common';
interface SettingsViewProps {
readonly systems: readonly AISystem[];
readonly onImport: (systems: readonly AISystem[]) => void;
readonly profile: CompanyProfile;
readonly onProfileChange: (profile: CompanyProfile) => void;
readonly onImport: (systems: readonly AISystem[], profile: CompanyProfile | null) => void;
readonly onClear: () => void;
}
/** Einstellungen: Datensicherung, Import, Löschen, Content-Pack-Info. */
export function SettingsView({ systems, onImport, onClear }: SettingsViewProps): JSX.Element {
const MAX_LOGO_BYTES = 300_000;
const PROFILE_FIELDS: readonly { key: keyof CompanyProfile; label: string; placeholder: string }[] = [
{ key: 'name', label: 'Firmenname', placeholder: 'Muster GmbH' },
{ key: 'street', label: 'Straße & Hausnummer', placeholder: 'Musterstraße 1' },
{ key: 'zipCity', label: 'PLZ & Ort', placeholder: '60311 Frankfurt am Main' },
{ key: 'contactPerson', label: 'Ansprechpartner', placeholder: 'Max Mustermann' },
{ key: 'email', label: 'E-Mail', placeholder: 'compliance@muster.de' },
{ key: 'phone', label: 'Telefon', placeholder: '+49 69 000000' },
{ key: 'website', label: 'Website', placeholder: 'www.muster.de' },
];
/** Einstellungen: Firmenprofil (Briefkopf), Datensicherung, Löschen, Content-Pack-Info. */
export function SettingsView({
systems,
profile,
onProfileChange,
onImport,
onClear,
}: SettingsViewProps): JSX.Element {
const fileInput = useRef<HTMLInputElement>(null);
const logoInput = useRef<HTMLInputElement>(null);
const [message, setMessage] = useState<string>('');
const [logoMessage, setLogoMessage] = useState<string>('');
const handleImport = async (file: File): Promise<void> => {
try {
const imported = importData(await file.text());
onImport(imported);
setMessage(`${imported.length} System(e) importiert.`);
const result = importData(await file.text());
onImport(result.systems, result.profile);
setMessage(`Import abgeschlossen: ${result.systems.length} System(e)${result.profile ? ' inkl. Firmenprofil' : ''}.`);
} catch (err) {
setMessage(`⚠️ Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
setMessage(`Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
}
};
const handleLogo = (file: File): void => {
if (!file.type.startsWith('image/')) {
setLogoMessage('Bitte eine Bilddatei wählen (PNG, JPG oder SVG).');
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
if (dataUrl.length > MAX_LOGO_BYTES * 1.4) {
setLogoMessage('Logo zu groß — bitte eine Datei unter 300 KB verwenden.');
return;
}
onProfileChange({ ...profile, logoDataUrl: dataUrl });
setLogoMessage('Logo übernommen.');
};
reader.readAsDataURL(file);
};
return (
<div>
<h1>Einstellungen</h1>
<p className="sub">Datenhoheit: Alles liegt lokal in diesem Browser kein Cloud-Sync, keine Telemetrie.</p>
<div className="panel">
<h2 style={{ marginTop: 0 }}>Firmenprofil (Briefkopf für Berichte & PDF-Druck)</h2>
<p className="hint">
Diese Angaben erscheinen im Kopf und in der Fußzeile aller generierten und gedruckten
Dokumente. Das Logo bleibt lokal gespeichert und verlässt das Gerät nicht.
</p>
<div className="profile-grid">
{PROFILE_FIELDS.map((f) => (
<label key={f.key} className="field">
<span>{f.label}</span>
<input
type="text"
value={profile[f.key]}
placeholder={f.placeholder}
onChange={(e) => onProfileChange({ ...profile, [f.key]: e.target.value })}
/>
</label>
))}
</div>
<div className="logo-row">
{profile.logoDataUrl ? (
<img className="logo-preview" src={profile.logoDataUrl} alt="Firmenlogo" />
) : (
<span className="hint">Kein Logo hinterlegt.</span>
)}
<div className="toolbar" style={{ margin: 0 }}>
<button className="ghost" onClick={() => logoInput.current?.click()}>
Logo hochladen
</button>
{profile.logoDataUrl && (
<button
className="danger"
onClick={() => onProfileChange({ ...profile, logoDataUrl: '' })}
>
Logo entfernen
</button>
)}
</div>
<input
ref={logoInput}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleLogo(file);
e.target.value = '';
}}
/>
</div>
{logoMessage && <p className="hint">{logoMessage}</p>}
</div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>Regulatorischer Content-Pack</h2>
<p style={{ fontSize: 14 }}>
@ -52,7 +145,7 @@ export function SettingsView({ systems, onImport, onClear }: SettingsViewProps):
onClick={() =>
downloadText(
`ai-act-kompass-backup-${todayISO()}.json`,
exportData(systems, new Date().toISOString()),
exportData(systems, profile, new Date().toISOString()),
'application/json',
)
}
@ -79,14 +172,14 @@ export function SettingsView({ systems, onImport, onClear }: SettingsViewProps):
<div className="panel">
<h2 style={{ marginTop: 0 }}>Alle Daten löschen</h2>
<p className="hint">Entfernt alle erfassten KI-Systeme unwiderruflich aus diesem Browser.</p>
<p className="hint">Entfernt alle erfassten KI-Systeme und das Firmenprofil unwiderruflich aus diesem Browser.</p>
<button
className="danger"
onClick={() => {
if (window.confirm('Wirklich alle lokalen Daten unwiderruflich löschen?')) {
clearAll();
onClear();
setMessage('Alle Daten gelöscht.');
setMessage('Alle Daten gelöscht.');
}
}}
>

View File

@ -2,11 +2,13 @@ import { useMemo, useState } from 'react';
import { CONTENT_PACK } from '../content/pack';
import { actionPlan, annexIVSkeleton, riskReport } from '../engine/documents';
import { deriveObligations } from '../engine/obligations';
import type { AISystem, ObligationStatus } from '../engine/types';
import type { AISystem, CompanyProfile, ObligationStatus } from '../engine/types';
import { printDocument } from '../print/print';
import { downloadText, RiskBadge, ROLE_LABELS, todayISO } from './common';
interface SystemDetailProps {
readonly system: AISystem;
readonly profile: CompanyProfile;
readonly onUpdate: (system: AISystem) => void;
readonly onReassess: () => void;
readonly onDelete: () => void;
@ -25,6 +27,7 @@ type DocKind = 'report' | 'plan' | 'annexIV';
/** Detailansicht eines Systems: Klassifizierung, Pflichten-Checkliste, Dokumente. */
export function SystemDetail({
system,
profile,
onUpdate,
onReassess,
onDelete,
@ -50,17 +53,17 @@ export function SystemDetail({
report: {
label: 'Risikobericht',
file: `risikobericht-${system.name}-${today}.md`,
render: () => riskReport(system, CONTENT_PACK, today),
render: () => riskReport(system, CONTENT_PACK, today, profile),
},
plan: {
label: 'Maßnahmenplan',
file: `massnahmenplan-${system.name}-${today}.md`,
render: () => actionPlan(system, obligations, CONTENT_PACK, today),
render: () => actionPlan(system, obligations, CONTENT_PACK, today, profile),
},
annexIV: {
label: 'Annex-IV-Dokumentation',
file: `annex-iv-${system.name}-${today}.md`,
render: () => annexIVSkeleton(system, CONTENT_PACK, today),
render: () => annexIVSkeleton(system, CONTENT_PACK, today, profile),
},
};
@ -150,9 +153,15 @@ export function SystemDetail({
</button>
<button
className="primary"
onClick={() => printDocument(docs[kind].file.replace(/\.md$/, ''), docs[kind].render(), profile)}
>
Drucken / PDF
</button>
<button
className="ghost"
onClick={() => downloadText(docs[kind].file, docs[kind].render())}
>
.md
.md
</button>
</span>
))}