Update 1.8.1 Anpassung der Barrierefreiheit
release-tag / release-image (push) Successful in 1m35s

This commit is contained in:
2026-07-23 06:18:10 +02:00
parent db817eabe4
commit 4439684e41
29 changed files with 350 additions and 82 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ PUBLIC_NAME=AI Usage Disclosure
CONTACT_URL=https://ai.trustednet.eu
SALES_URL=https://ai.trustednet.eu/product
DEFAULT_LANGUAGE=de
BULK_URL=http://localhost:8081
BULK_URL=http://localhost:8088
# Optional second container for batch rendering.
BULK_MAX_URLS=500
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 1.8.1 - 2026-07-23
- Added an accessible first-exposure embed with visible link text next to the SVG badge instead of relying on SVG/`alt` information alone.
- Added `accessibleText` and `embedHtml` to `GET /v1/render`; `embedHtml` combines the visible disclosure and inline JSON-LD in one copy/paste artifact.
- The normal generator now renders HTML/Markdown/JSON-LD through the core render endpoint and copies the complete HTML + JSON-LD embed by default.
- Bulk HTML exports now use `embedHtml`, so each subject receives visible disclosure markup and JSON-LD in one step.
- Flat SVG badges automatically choose black or white text per segment for at least 4.5:1 contrast; the old overlay gradient was removed so the computed contrast remains deterministic.
- Emoji/icon badges enforce at least 3:1 foreground/background contrast by falling back to black or white when a custom colour pair is insufficient; the generator shows a corresponding warning.
- Increased the recommended linked disclosure target to at least 36 CSS pixels high and added a visible text label in the generator preview.
- Adopted the supplied registry deployment: `compose.yaml` now uses `git.send.nrw/sendnrw/ai-disclosure-standard:latest` and `:bulk-latest`, and `.gitea/workflows/registry-bulk.yml` builds the separate bulk tags.
- Removed the superseded GHCR bulk workflow to avoid competing release pipelines.
## 1.8.0 - 2026-07-22
- Added the optional `ai-disclosure-bulk` container without accounts, database or separate legal decision logic.
+1 -1
View File
@@ -28,7 +28,7 @@ build-bulk:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure-bulk ./cmd/bulk
docker-build:
docker build -t ai-disclosure-standard:1.8.0-local .
docker build -t ai-disclosure-standard:1.8.1-local .
docker-build-bulk:
docker build -f Dockerfile.bulk -t ai-disclosure-bulk:1.0.0-local .
+23 -6
View File
@@ -1,11 +1,12 @@
# AI Disclosure Standard 1.8.0
# AI Disclosure Standard 1.8.1
Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
## Funktionen
- einzelne Deklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
- SVG-Badges, HTML-Erklärung und JSON-LD unter einem gemeinsamen Link;
- SVG-Badges, sichtbarer barrierearmer HTML-Hinweis und JSON-LD unter einem gemeinsamen Link;
- empfohlener Ein-Schritt-Embed: sichtbarer Hinweis + dekoratives SVG + inline JSON-LD in einem HTML-Snippet;
- Text, Titelbild, weitere Bilder, Recherche, Übersetzung, Audio, Video und Code getrennt erfassbar;
- automatisch erzeugte, professionell formulierte Zusammenfassung als Fließtext und strukturierte Tabelle;
- frei wählbare Nachweisgrundlage: Selbsterklärung, technisch protokolliert, signiert oder verifiziert;
@@ -37,7 +38,7 @@ Datenschutz: http://localhost:8080/datenschutz
Barrierefrei: http://localhost:8080/barrierefreiheit
Healthcheck: http://localhost:8080/healthz
Funktionen: http://localhost:8080/v1/capabilities
Bulk: http://localhost:8081/
Bulk (Compose-Port): http://localhost:8088/
```
Go lädt `.env` nicht selbst. Unter PowerShell kann die Datei vor dem Start in die Prozessumgebung übernommen werden oder Docker Compose mit `--env-file .env` verwendet werden.
@@ -46,9 +47,23 @@ Go lädt `.env` nicht selbst. Unter PowerShell kann die Datei vor dem Start in d
```powershell
Copy-Item .env.example .env
docker compose --env-file .env up -d --build
docker compose --env-file .env pull
docker compose --env-file .env up -d
```
Die bereitgestellte `compose.yaml` verwendet die Registry-Images `git.send.nrw/sendnrw/ai-disclosure-standard:latest` und `:bulk-latest`, bindet beide Dienste an das externe Netzwerk `proxy` und veröffentlicht den Bulk-Dienst zusätzlich auf Host-Port `8088`. Das Netzwerk `proxy` muss vor dem Start bereits existieren.
## Gitea Actions: Bulk-Image
Der Workflow `.gitea/workflows/registry-bulk.yml` baut `Dockerfile.bulk` unabhängig vom Hauptimage und veröffentlicht in der konfigurierten Registry unter anderem:
```text
git.send.nrw/sendnrw/<repository>:bulk-latest
git.send.nrw/sendnrw/<repository>:bulk-<version>
```
Er verwendet dieselbe Registry- und Secret-Konfiguration wie der vorhandene Hauptimage-Workflow (`DOCKER_USERNAME` / `DOCKER_PASSWORD`).
## Bulk-Generator
@@ -60,7 +75,9 @@ Typischer Ablauf:
2. **Als Bulk-Vorlage öffnen** auswählen.
3. Optional ein Website-Profil mit wiederkehrenden Angaben wie Autor, Impressums-/Verantwortlichkeits-URL und Beschwerdestelle im Browser speichern.
4. Absolute URLs oder relative Pfade einfügen. Für relative Pfade kann einmalig eine Basis-URL gesetzt werden.
5. HTML, Markdown, JSON-LD, JSONL oder CSV erzeugen und kopieren bzw. herunterladen.
5. Das komplette HTML (sichtbarer Hinweis + JSON-LD), Markdown, separates JSON-LD, JSONL oder CSV erzeugen und kopieren bzw. herunterladen.
Der HTML-Export verwendet standardmäßig `embedHtml` aus dem Core: Das SVG ist dekorativ (`alt=""`), der Link enthält unmittelbar sichtbaren Text und hat ein größeres Klickziel; direkt danach folgt das JSON-LD als `<script type="application/ld+json">`.
Der Bulk-Container ruft die eingegebenen Inhalts-URLs **nicht** ab. Website-Profile und gespeicherte Kennzeichnungsvorlagen werden ausschließlich im `localStorage` des Browsers gespeichert. URL-Listen und erzeugte Ergebnisse werden weder im Browser dauerhaft gespeichert noch serverseitig persistiert. Profile und Vorlagen können als JSON exportiert, importiert oder vollständig gelöscht werden.
@@ -68,7 +85,7 @@ Relevante Variablen:
| Variable | Standard | Bedeutung |
|---|---|---|
| `BULK_URL` | leer / in der Beispielkonfiguration `http://localhost:8081` | öffentliche URL des Bulk-Generators; aktiviert den Übergabe-Button im normalen Generator |
| `BULK_URL` | leer / in `.env.example` `http://localhost:8088` | öffentliche URL des Bulk-Generators; aktiviert den Übergabe-Button im normalen Generator |
| `CORE_INTERNAL_URL` | `http://app:8080` | feste interne Origin des Disclosure-Core; der Bulk-Dienst folgt keinen frei eingegebenen Ziel-URLs |
| `DISCLOSURE_BASE_URL` | `http://localhost:8080` | öffentliche Core-Origin für Links in der Bulk-Oberfläche |
| `GENERATOR_URL` | wie `DISCLOSURE_BASE_URL` | öffentlicher Link zurück zum normalen Generator |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -300,7 +300,7 @@
function buildExports(results) {
const successful = results.filter(item => item.ok && item.data);
const html = successful.map(item => `<!-- ${item.subject} -->\n${item.data.html}`).join('\n\n');
const html = successful.map(item => `<!-- ${item.subject} -->\n${item.data.embedHtml || item.data.html}`).join('\n\n');
const markdown = successful.map(item => `### ${item.subject}\n\n${item.data.markdown}`).join('\n\n');
const jsonArray = successful.map(item => item.data.jsonLd);
const json = JSON.stringify(jsonArray, null, 2);
+5 -3
View File
@@ -201,8 +201,8 @@
</div>
<div class="toolbar wrap export-buttons">
<button type="button" data-copy="html">HTML kopieren</button>
<button type="button" class="button secondary" data-download="html">HTML herunterladen</button>
<button type="button" data-copy="html">Komplettes HTML kopieren</button>
<button type="button" class="button secondary" data-download="html">HTML + JSON-LD herunterladen</button>
<button type="button" data-copy="markdown">Markdown kopieren</button>
<button type="button" class="button secondary" data-download="markdown">Markdown herunterladen</button>
<button type="button" data-copy="json">JSON-LD kopieren</button>
@@ -211,6 +211,8 @@
<button type="button" class="button secondary" data-download="csv">CSV herunterladen</button>
</div>
<p class="embed-note">Der HTML-Export ist die empfohlene Ein-Schritt-Integration: sichtbarer, tastaturerreichbarer Hinweis mit Text neben dem Badge plus eingebettetes JSON-LD. Das SVG ist dabei dekorativ; die zugängliche Bezeichnung stammt aus dem sichtbaren Linktext.</p>
<div class="result-table-wrap">
<table class="result-table">
<thead><tr><th>Inhalt</th><th>Status</th><th>Links</th></tr></thead>
@@ -219,7 +221,7 @@
</div>
<div class="output-grid">
<label>HTML<textarea id="output-html" rows="12" readonly></textarea></label>
<label>Komplettes HTML inkl. JSON-LD<textarea id="output-html" rows="12" readonly></textarea></label>
<label>Markdown<textarea id="output-markdown" rows="12" readonly></textarea></label>
<label>JSON-LD<textarea id="output-json" rows="12" readonly></textarea></label>
<label>CSV<textarea id="output-csv" rows="8" readonly></textarea></label>
+24 -19
View File
@@ -1,15 +1,9 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
image: ai-disclosure-standard:1.8.0-local
image: git.send.nrw/sendnrw/ai-disclosure-standard:latest
container_name: ai
env_file:
- .env
environment:
BULK_URL: ${BULK_URL:-http://localhost:8081}
ports:
- "8080:8080"
volumes:
- license-cache:/data
read_only: true
@@ -21,26 +15,30 @@ services:
- ALL
restart: unless-stopped
healthcheck:
test: ["CMD", "/ai-disclosure", "--healthcheck"]
test:
- CMD
- /ai-disclosure
- --healthcheck
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
networks:
- proxy
bulk:
build:
context: .
dockerfile: Dockerfile.bulk
image: ai-disclosure-bulk:1.0.0-local
image: git.send.nrw/sendnrw/ai-disclosure-standard:bulk-latest
container_name: ai-bulk
env_file:
- .env
environment:
CORE_INTERNAL_URL: http://app:8080
DISCLOSURE_BASE_URL: ${BASE_URL:-http://localhost:8080}
GENERATOR_URL: ${BASE_URL:-http://localhost:8080}
BULK_PUBLIC_NAME: AI Disclosure Bulk
BULK_PUBLIC_NAME: ${PUBLIC_NAME:-AI Disclosure Bulk}
BULK_MAX_URLS: ${BULK_MAX_URLS:-500}
BULK_WORKERS: ${BULK_WORKERS:-4}
ports:
- "8081:8081"
- 8088:8081
read_only: true
tmpfs:
- /tmp:size=8m,mode=1777
@@ -53,11 +51,18 @@ services:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "/ai-disclosure-bulk", "--healthcheck"]
test:
- CMD
- /ai-disclosure-bulk
- --healthcheck
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
networks:
- proxy
volumes:
license-cache:
license-cache: null
networks:
proxy:
external: true
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: app
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.0
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.1
imagePullPolicy: IfNotPresent
ports:
- name: http
+1 -1
View File
@@ -1,7 +1,7 @@
version: "3.9"
services:
app:
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.0
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.1
environment:
BASE_URL: https://ai.example.org
PUBLIC_NAME: AI Usage Disclosure
+1 -1
View File
@@ -1,6 +1,6 @@
# Mehrsprachige Hintergrundseite
Version 1.8.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
Version 1.8.1 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
## Routen
+6 -4
View File
@@ -42,10 +42,12 @@ Für solche sichtbaren Offenlegungen gilt nach Artikel 50 Absatz 5 und den Leitl
Daher gilt für die Einbettung dieses Projekts:
- Badge oder Klartext unmittelbar am betroffenen Inhalt platzieren;
- den empfohlenen `embedHtml`-Baustein oder eine gleichwertige Umsetzung unmittelbar am betroffenen Inhalt platzieren;
- nicht ausschließlich im Footer, Impressum oder auf einer erst später erreichbaren Unterseite;
- aussagekräftigen Alternativtext verwenden;
- bei der quadratischen Emoji-Variante zusätzlich einen unmittelbar verständlichen Text oder eine gleichwertig eindeutige sichtbare Kennzeichnung verwenden.
- sichtbaren, verständlichen Text als Bestandteil des Links bereitstellen, damit die Offenlegung nicht nur über eine Grafik wahrnehmbar ist;
- das Badge im empfohlenen Embed mit `alt=""`/`aria-hidden="true"` dekorativ behandeln, weil derselbe Bedeutungsgehalt bereits als sichtbarer Linktext vorhanden ist und sonst doppelt angesagt würde;
- bei einer alleinstehenden Badge-Grafik weiterhin einen aussagekräftigen Alternativtext bereitstellen;
- ausreichende Kontraste und ein ausreichend großes, tastaturerreichbares Linkziel sicherstellen. Der mitgelieferte Renderer wählt bei flachen Badges automatisch Schwarz/Weiß für mindestens 4,5:1 Textkontrast und korrigiert Emoji-/Icon-Farben unter 3:1.
Offizielle Quellen:
@@ -107,7 +109,7 @@ Anbieter generativer KI-Systeme müssen in den erfassten Fällen dafür sorgen,
Artikel 50 Absatz 2 nimmt Systeme aus, die lediglich Standard-Bearbeitungsfunktionen ausführen oder die vom Betreiber bereitgestellten Eingabedaten bzw. deren Semantik nicht wesentlich verändern. Ob diese Ausnahme tatsächlich greift, hängt von der konkreten Funktion und Veränderungswirkung ab; der sichtbare Generator kann diese technische Anbieterfrage nicht automatisch entscheiden.
Das von diesem Projekt erzeugte **JSON-LD ist ergänzende Dokumentation**. Es ist nicht als Ersatz für die technische Markierung gedacht, die der Anbieter des generativen KI-Systems in oder an der Ausgabe implementieren muss.
Das von diesem Projekt erzeugte **JSON-LD ist ergänzende Dokumentation**. Es ist nicht als Ersatz für die technische Markierung gedacht, die der Anbieter des generativen KI-Systems in oder an der Ausgabe implementieren muss. Der Endpunkt `/v1/render` kann es über `embedHtml` direkt zusammen mit der sichtbaren Offenlegung als `<script type="application/ld+json">` ausgeben; dadurch lässt sich die menschlich sichtbare und die ergänzende strukturierte Information in einem Integrationsschritt einbauen, ohne beide Pflichten begrifflich gleichzusetzen.
Wer selbst Anbieter eines generativen KI-Systems im Sinne des AI Act ist, muss deshalb zusätzlich eine geeignete Provider-Marking-Lösung umsetzen und dokumentieren. Der von der Kommission positiv bewertete Code of Practice kann hierfür als freiwilliger Compliance-Rahmen genutzt werden.
+2 -1
View File
@@ -71,7 +71,8 @@ Für ein erfasstes Verbraucherangebot sind unter anderem zu prüfen:
- Kontraste, Zoom und Reflow;
- verständliche Fehlermeldungen;
- Screenreader-Ausgabe;
- Alternativtexte eingebetteter Badges;
- sichtbarer Text neben eingebetteten Badges; bei dekorativen Badges im empfohlenen Embed `alt=""`, bei alleinstehenden Badge-Grafiken ein aussagekräftiger Alternativtext;
- Link-/Klickzielgröße und sichtbare Tastaturfokussierung;
- Barrierefreiheit des vollständigen Bestell- oder Vertragspfads;
- gesetzlich verlangte Informationen zur Barrierefreiheit.
+1 -1
View File
@@ -16,7 +16,7 @@ Initialization is performed in `internal/app/server.go`. The product ID and embe
```go
licenses := licenseclient.New(ctx, licenseclient.Config{
Product: "ai-disclosure-standard",
ClientVersion: "1.8.0",
ClientVersion: "1.8.1",
Token: cfg.LicenseToken,
TrustStore: trustStore,
BaseURL: cfg.BaseURL,
+1 -1
View File
@@ -119,7 +119,7 @@ Anfrage:
"baseUrl": "https://ai.example.org",
"host": "ai.example.org",
"instanceId": "production-eu-1",
"clientVersion": "1.8.0"
"clientVersion": "1.8.1"
}
```
+1 -1
View File
@@ -9,7 +9,7 @@ import (
const (
ProductID = "ai-disclosure-standard"
ProductVersion = "1.8.0"
ProductVersion = "1.8.1"
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
+23 -9
View File
@@ -467,7 +467,9 @@ type renderedArtifacts struct {
DeclarationURL string `json:"declarationUrl"`
BadgeURL string `json:"badgeUrl"`
ManifestURL string `json:"manifestUrl"`
AccessibleText string `json:"accessibleText"`
HTML string `json:"html"`
EmbedHTML string `json:"embedHtml"`
Markdown string `json:"markdown"`
JSONLD declaration.Declaration `json:"jsonLd"`
}
@@ -499,20 +501,32 @@ func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode()
assessment := assessLegalContext(d, locale)
alt := renderBadgeAlt(q, d, locale, assessment)
imageMarkup := fmt.Sprintf(`<a href="%s"><img src="%s" alt="%s"></a>`, html.EscapeString(declarationURL), html.EscapeString(badgeURL), html.EscapeString(alt))
htmlMarkup := imageMarkup
markdown := fmt.Sprintf(`[![%s](%s)](%s)`, markdownAlt(alt), badgeURL, declarationURL)
if assessment.RequiresDisclosure && theme == "emoji" {
htmlMarkup += " <span>" + html.EscapeString(assessment.BadgeMessage) + "</span>"
markdown += " **" + markdownText(assessment.BadgeMessage) + "**"
badgeText := renderBadgeAlt(q, d, locale, assessment)
detailsText := strings.TrimSpace(locale.Text["embed_details"])
if detailsText == "" {
detailsText = "AI usage details"
}
accessibleText := badgeText + " – " + detailsText
badgeHeight := 20
if theme == "emoji" {
badgeHeight = 48
}
htmlMarkup := fmt.Sprintf(`<p class="ai-disclosure" data-ai-disclosure><a class="ai-disclosure__link" href="%s" style="display:inline-flex;align-items:center;gap:.5rem;min-height:36px;padding:.25rem .375rem;max-width:100%%;color:inherit;text-decoration:underline;text-underline-offset:.15em"><img class="ai-disclosure__badge" src="%s" alt="" aria-hidden="true" style="height:%dpx;width:auto;max-width:100%%;flex:0 0 auto"><span class="ai-disclosure__text" style="line-height:1.35">%s</span></a></p>`,
html.EscapeString(declarationURL), html.EscapeString(badgeURL), badgeHeight, html.EscapeString(accessibleText))
markdown := fmt.Sprintf(`[![](%s) %s](%s)`, badgeURL, markdownText(accessibleText), declarationURL)
jsonLDBytes, err := json.MarshalIndent(d, "", " ")
if err != nil {
s.logger.Error("could not marshal render JSON-LD", "error", err)
s.problem(w, http.StatusInternalServerError, "render_failed", "Could not render JSON-LD.")
return
}
embedMarkup := htmlMarkup + "\n" + `<script type="application/ld+json">` + "\n" + string(jsonLDBytes) + "\n</script>"
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(renderedArtifacts{
Subject: d.Subject, DeclarationURL: declarationURL, BadgeURL: badgeURL, ManifestURL: manifestURL,
HTML: htmlMarkup, Markdown: markdown, JSONLD: d,
Subject: d.Subject, DeclarationURL: declarationURL, BadgeURL: badgeURL, ManifestURL: manifestURL, AccessibleText: accessibleText,
HTML: htmlMarkup, EmbedHTML: embedMarkup, Markdown: markdown, JSONLD: d,
})
}
+9 -1
View File
@@ -699,7 +699,9 @@ func TestRenderEndpointReturnsReusableArtifacts(t *testing.T) {
DeclarationURL string `json:"declarationUrl"`
BadgeURL string `json:"badgeUrl"`
ManifestURL string `json:"manifestUrl"`
AccessibleText string `json:"accessibleText"`
HTML string `json:"html"`
EmbedHTML string `json:"embedHtml"`
Markdown string `json:"markdown"`
JSONLD map[string]any `json:"jsonLd"`
}
@@ -709,7 +711,7 @@ func TestRenderEndpointReturnsReusableArtifacts(t *testing.T) {
if payload.Subject != "https://content.example/article" {
t.Fatalf("subject %q", payload.Subject)
}
for name, value := range map[string]string{"declaration": payload.DeclarationURL, "badge": payload.BadgeURL, "manifest": payload.ManifestURL, "html": payload.HTML, "markdown": payload.Markdown} {
for name, value := range map[string]string{"declaration": payload.DeclarationURL, "badge": payload.BadgeURL, "manifest": payload.ManifestURL, "accessibleText": payload.AccessibleText, "html": payload.HTML, "embedHtml": payload.EmbedHTML, "markdown": payload.Markdown} {
if value == "" {
t.Fatalf("%s is empty", name)
}
@@ -723,6 +725,12 @@ func TestRenderEndpointReturnsReusableArtifacts(t *testing.T) {
if !strings.Contains(payload.BadgeURL, "theme=mono") {
t.Fatalf("badge URL missing theme: %s", payload.BadgeURL)
}
if !strings.Contains(payload.HTML, `alt="" aria-hidden="true"`) || !strings.Contains(payload.HTML, "Details zur KI-Nutzung") {
t.Fatalf("accessible visible disclosure missing: %s", payload.HTML)
}
if !strings.Contains(payload.EmbedHTML, `<script type="application/ld+json">`) || !strings.Contains(payload.EmbedHTML, `https://content.example/article`) {
t.Fatalf("JSON-LD embed missing: %s", payload.EmbedHTML)
}
}
func TestGeneratorExposesBulkHandoffWhenConfigured(t *testing.T) {
+54 -5
View File
@@ -5,6 +5,8 @@ import (
"encoding/hex"
"fmt"
"html"
"math"
"strconv"
"strings"
"unicode/utf8"
)
@@ -69,15 +71,17 @@ func Render(o Options) ([]byte, string) {
if isHexColor(o.RightColor) {
rightColor = o.RightColor
}
leftText := bestTextColor(leftColor)
rightText := bestTextColor(rightColor)
title := html.EscapeString(label + ": " + message)
labelEsc := html.EscapeString(label)
messageEsc := html.EscapeString(message)
openLink, closeLink := svgLink(o.Link)
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" width="%d" height="20" viewBox="0 0 %d 20"><title>%s</title>%s<linearGradient id="s" x2="0" y2="100%%"><stop offset="0" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-opacity=".12"/></linearGradient><clipPath id="r"><rect width="%d" height="20" rx="%d" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="%d" height="20" fill="%s"/><rect x="%d" width="%d" height="20" fill="%s"/><rect width="%d" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text></g>%s</svg>`,
title, total, total, title, openLink, total, radius, leftWidth, leftColor, leftWidth, rightWidth, rightColor, total,
leftWidth/2, labelEsc, leftWidth/2, labelEsc, leftWidth+rightWidth/2, messageEsc, leftWidth+rightWidth/2, messageEsc, closeLink)
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" focusable="false" width="%d" height="20" viewBox="0 0 %d 20"><title>%s</title>%s<clipPath id="r"><rect width="%d" height="20" rx="%d" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="%d" height="20" fill="%s"/><rect x="%d" width="%d" height="20" fill="%s"/></g><g text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" font-weight="700"><text x="%d" y="14" fill="%s">%s</text><text x="%d" y="14" fill="%s">%s</text></g>%s</svg>`,
title, total, total, title, openLink, total, radius, leftWidth, leftColor, leftWidth, rightWidth, rightColor,
leftWidth/2, leftText, labelEsc, leftWidth+rightWidth/2, rightText, messageEsc, closeLink)
return withETag([]byte(svg))
}
@@ -94,11 +98,17 @@ func renderEmoji(o Options, label, message string) ([]byte, string) {
background = "#4a5568"
}
foreground := "#ffffff"
if isHexColor(o.RightColor) {
background = o.RightColor
}
if isHexColor(o.LeftColor) {
foreground = o.LeftColor
}
if isHexColor(o.RightColor) {
background = o.RightColor
// Emoji badges use the left colour as icon foreground and the right colour
// as background. Keep at least WCAG 2.2's 3:1 non-text contrast; if a
// custom pair falls below it, prefer the higher-contrast black/white icon.
if contrastRatio(foreground, background) < 3.0 {
foreground = bestTextColor(background)
}
title := html.EscapeString(label + ": " + message)
@@ -188,6 +198,45 @@ func truncate(s string, max int) string {
return string(r[:max-1]) + "…"
}
func bestTextColor(background string) string {
if contrastRatio("#000000", background) >= contrastRatio("#ffffff", background) {
return "#000000"
}
return "#ffffff"
}
func contrastRatio(foreground, background string) float64 {
fg, okFG := relativeLuminance(foreground)
bg, okBG := relativeLuminance(background)
if !okFG || !okBG {
return 1
}
if fg < bg {
fg, bg = bg, fg
}
return (fg + 0.05) / (bg + 0.05)
}
func relativeLuminance(hex string) (float64, bool) {
if !isHexColor(hex) {
return 0, false
}
channels := make([]float64, 3)
for i := 0; i < 3; i++ {
v, err := strconv.ParseUint(hex[1+i*2:3+i*2], 16, 8)
if err != nil {
return 0, false
}
c := float64(v) / 255.0
if c <= 0.04045 {
channels[i] = c / 12.92
} else {
channels[i] = math.Pow((c+0.055)/1.055, 2.4)
}
}
return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2], true
}
func isHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' {
return false
+31
View File
@@ -92,3 +92,34 @@ func TestEmojiIconMapping(t *testing.T) {
})
}
}
func TestFlatBadgeAutomaticallyChoosesContrastingText(t *testing.T) {
data, _ := Render(Options{Label: "KI-Nutzung", Message: "Teilweise", Extent: "partial", Theme: "color"})
body := string(data)
if !strings.Contains(body, `fill="#b7791f"`) {
t.Fatalf("expected partial background: %s", body)
}
if !strings.Contains(body, `fill="#000000">Teilweise</text>`) {
t.Fatalf("expected automatic black text on gold background: %s", body)
}
if ratio := contrastRatio("#000000", "#b7791f"); ratio < 4.5 {
t.Fatalf("contrast ratio %.2f is below 4.5", ratio)
}
}
func TestEmojiBadgeAdjustsInsufficientCustomForegroundContrast(t *testing.T) {
data, _ := Render(Options{
Label: "KI-Nutzung", Message: "Hinweis", Extent: "assisted", Theme: "emoji",
LeftColor: "#ffffff", RightColor: "#abcdef",
})
body := string(data)
if strings.Contains(body, `stroke="#ffffff"`) {
t.Fatalf("low-contrast custom foreground was not adjusted: %s", body)
}
if !strings.Contains(body, `stroke="#000000"`) {
t.Fatalf("expected automatic black icon foreground: %s", body)
}
if ratio := contrastRatio("#000000", "#abcdef"); ratio < 3 {
t.Fatalf("adjusted contrast ratio %.2f is below 3", ratio)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
bulkweb "github.com/b1tsblog/ai-disclosure-standard/bulkweb"
)
const Version = "1.0.0"
const Version = "1.0.1"
type Server struct {
cfg Config
+48
View File
@@ -144,6 +144,54 @@ func init() {
applyAssuranceTranslations()
applyThemeTranslations()
applyLegalGeneratorTranslations()
applyEmbedTranslations()
}
func applyEmbedTranslations() {
translations := map[string]map[string]string{
"de": {
"embed_details": "Details zur KI-Nutzung",
"embed_html_label": "Komplettes HTML inkl. JSON-LD",
"copy_html": "Komplettes HTML kopieren",
"embed_markdown_label": "Barrierearmes Markdown",
"embed_json_label": "JSON-LD",
"embed_rendering": "Einbettung wird erzeugt …",
"embed_render_error": "Einbettung konnte nicht erzeugt werden.",
"contrast_adjusted": "Der Kontrast der gewählten Emoji-Farben ist zu gering; das Symbol wird automatisch mit Schwarz oder Weiß dargestellt.",
},
"en": {
"embed_details": "AI usage details",
"embed_html_label": "Complete HTML incl. JSON-LD",
"copy_html": "Copy complete HTML",
"embed_markdown_label": "Accessible Markdown",
"embed_json_label": "JSON-LD",
"embed_rendering": "Rendering embed …",
"embed_render_error": "The embed could not be rendered.",
"contrast_adjusted": "The selected emoji colours have insufficient contrast; the icon will automatically use black or white.",
},
"fr": {"embed_details": "Détails sur l’utilisation de l’IA", "embed_html_label": "HTML complet avec JSON-LD", "embed_markdown_label": "Markdown accessible", "embed_json_label": "JSON-LD"},
"es": {"embed_details": "Detalles sobre el uso de IA", "embed_html_label": "HTML completo con JSON-LD", "embed_markdown_label": "Markdown accesible", "embed_json_label": "JSON-LD"},
"it": {"embed_details": "Dettagli sull’uso dell’IA", "embed_html_label": "HTML completo con JSON-LD", "embed_markdown_label": "Markdown accessibile", "embed_json_label": "JSON-LD"},
"nl": {"embed_details": "Details over AI-gebruik", "embed_html_label": "Volledige HTML met JSON-LD", "embed_markdown_label": "Toegankelijke Markdown", "embed_json_label": "JSON-LD"},
"pt": {"embed_details": "Detalhes sobre a utilização de IA", "embed_html_label": "HTML completo com JSON-LD", "embed_markdown_label": "Markdown acessível", "embed_json_label": "JSON-LD"},
"pl": {"embed_details": "Szczegóły użycia AI", "embed_html_label": "Pełny HTML z JSON-LD", "embed_markdown_label": "Dostępny Markdown", "embed_json_label": "JSON-LD"},
}
for code, values := range translations {
l := catalogs[code]
for key, value := range values {
l.Text[key] = value
}
if l.Text["embed_rendering"] == "" {
l.Text["embed_rendering"] = translations["en"]["embed_rendering"]
}
if l.Text["embed_render_error"] == "" {
l.Text["embed_render_error"] = translations["en"]["embed_render_error"]
}
if l.Text["contrast_adjusted"] == "" {
l.Text["contrast_adjusted"] = translations["en"]["contrast_adjusted"]
}
catalogs[code] = l
}
}
func applyLegalGeneratorTranslations() {
+2 -2
View File
@@ -183,13 +183,13 @@ func Build(lang, productName, baseURL, salesURL, contactURL string, prices Price
# Adjust BASE_URL and PUBLIC_NAME in .env
docker compose up -d --build
curl -fsS http://localhost:8080/readyz`},
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.8.0 .
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.8.1 .
docker run -d --name ai-disclosure \
-p 8080:8080 \
-e BASE_URL=https://ai.example.org \
-e PUBLIC_NAME="AI Usage Disclosure" \
--read-only --tmpfs /tmp \
ai-disclosure-standard:1.8.0`},
ai-disclosure-standard:1.8.1`},
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, Domain und TLS-Secret in deploy/kubernetes.yaml ersetzen
kubectl create secret generic ai-disclosure-license \
--from-literal=token='...'
+6 -4
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: AI Usage Disclosure API
version: 1.8.0
version: 1.8.1
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
servers:
- url: https://ai.example.org
@@ -132,7 +132,7 @@ paths:
/v1/render:
get:
summary: Render reusable embedding artifacts from declaration query parameters
description: Returns HTML, Markdown and the parsed JSON-LD declaration from the same core logic used by the normal generator. The optional bulk container calls this endpoint for each subject URL.
description: Returns accessible visible HTML, a one-step HTML embed with inline JSON-LD, Markdown and the parsed JSON-LD declaration from the same core logic used by the normal generator. The optional bulk container calls this endpoint for each subject URL.
parameters:
- {$ref: '#/components/parameters/preset'}
- {$ref: '#/components/parameters/extent'}
@@ -172,13 +172,15 @@ paths:
application/json:
schema:
type: object
required: [declarationUrl, badgeUrl, manifestUrl, html, markdown, jsonLd]
required: [declarationUrl, badgeUrl, manifestUrl, accessibleText, html, embedHtml, markdown, jsonLd]
properties:
subject: {type: string, format: uri}
declarationUrl: {type: string, format: uri}
badgeUrl: {type: string, format: uri}
manifestUrl: {type: string, format: uri}
html: {type: string}
accessibleText: {type: string}
html: {type: string, description: Visible accessible disclosure markup without JSON-LD}
embedHtml: {type: string, description: Recommended one-step embed containing the visible disclosure and inline JSON-LD}
markdown: {type: string}
jsonLd: {$ref: './schema/declaration.schema.json'}
"403": {$ref: '#/components/responses/ProRequired'}
+82 -11
View File
@@ -6,6 +6,8 @@
if (!form) return;
const $ = id => document.getElementById(id);
const ARTICLE_50_START = '2026-08-02';
let renderSequence = 0;
let renderTimer = 0;
const presets = {
// Human/editorial review is a factual workflow statement and must never be inferred.
'no-ai': {extent:'none', activities:'', review:'none'},
@@ -33,6 +35,80 @@
el.querySelectorAll?.('input,select,textarea').forEach(input => { input.disabled = hidden; });
}
function hexRGB(value) {
if (!/^#[0-9a-f]{6}$/i.test(value || '')) return null;
return [1, 3, 5].map(i => parseInt(value.slice(i, i + 2), 16) / 255);
}
function relativeLuminance(value) {
const rgb = hexRGB(value);
if (!rgb) return null;
const channels = rgb.map(c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(foreground, background) {
const a = relativeLuminance(foreground);
const b = relativeLuminance(background);
if (a == null || b == null) return null;
const lighter = Math.max(a, b);
const darker = Math.min(a, b);
return (lighter + 0.05) / (darker + 0.05);
}
function emojiBackground() {
if (articleMode()) return '#7c3aed';
const presetColors = {'no-ai':'#2f855a', research:'#2b6cb0', summary:'#b7791f', full:'#c53030'};
const extentColors = {none:'#2f855a', assisted:'#2b6cb0', partial:'#b7791f', mostly:'#c05621', full:'#c53030'};
return presetColors[$('preset').value] || extentColors[$('extent').value] || '#4a5568';
}
function updateContrastStatus(locale) {
const target = $('badge-contrast-status');
if (!target) return;
target.hidden = true;
target.textContent = '';
if ($('theme').value !== 'emoji') return;
const foreground = validValue('left-color') || '#ffffff';
const background = validValue('right-color') || emojiBackground();
const ratio = contrastRatio(foreground, background);
if (ratio != null && ratio < 3) {
target.textContent = locale.text.contrast_adjusted || 'The selected emoji colours have insufficient contrast; the icon will automatically use black or white.';
target.hidden = false;
}
}
function scheduleRenderedArtifacts(params, theme, locale) {
const sequence = ++renderSequence;
clearTimeout(renderTimer);
const rendering = locale.text.embed_rendering || 'Rendering embed …';
$('html-code').value = rendering;
$('markdown-code').value = rendering;
$('json-code').value = rendering;
renderTimer = setTimeout(async () => {
const renderParams = new URLSearchParams(params);
renderParams.set('theme', theme);
try {
const response = await fetch(`${base}/v1/render?${renderParams}`, {headers: {'Accept': 'application/json'}});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || `HTTP ${response.status}`);
if (sequence !== renderSequence) return;
$('html-code').value = payload.embedHtml || payload.html || '';
$('markdown-code').value = payload.markdown || '';
$('json-code').value = payload.jsonLd ? JSON.stringify(payload.jsonLd, null, 2) : '';
if (payload.accessibleText) $('preview-text').textContent = payload.accessibleText;
if (payload.declarationUrl) $('preview-link').href = payload.declarationUrl;
if (payload.badgeUrl) $('preview-badge').src = payload.badgeUrl;
} catch (error) {
if (sequence !== renderSequence) return;
const message = locale.text.embed_render_error || 'The embed could not be rendered.';
$('html-code').value = `${message} ${error.message}`;
$('markdown-code').value = message;
$('json-code').value = message;
}
}, 140);
}
function setArticleParams(params) {
params.set('mode', 'article');
const fields = {
@@ -410,27 +486,22 @@
badgeParams.set('theme', $('theme').value);
badgeParams.set('link', declarationURL);
const badgeURL = `${base}/v1/badge.svg?${badgeParams}`;
const manifestURL = `${base}/v1/declaration.json?${params}`;
const assessment = legalAssessment(locale);
renderAssessment(locale, assessment);
const alt = assessment.legal
const badgeText = assessment.legal
? assessment.badge
: (isArticle
? (locale.text.badge_article || 'Article transparency')
: ((locale.presets[preset] && locale.presets[preset].title) || locale.extents[$('extent').value] || 'AI usage disclosure'));
const accessibleText = `${badgeText} – ${locale.text.embed_details || 'AI usage details'}`;
$('preview-badge').src = badgeURL;
$('preview-badge').alt = alt;
$('preview-badge').alt = '';
$('preview-link').href = declarationURL;
const imageMarkup = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
$('html-code').value = assessment.legal && $('theme').value === 'emoji'
? `${imageMarkup} <span>${assessment.badge}</span>`
: imageMarkup;
$('markdown-code').value = assessment.legal && $('theme').value === 'emoji'
? `[![${alt}](${badgeURL})](${declarationURL}) **${assessment.badge}**`
: `[![${alt}](${badgeURL})](${declarationURL})`;
$('json-code').value = manifestURL;
$('preview-text').textContent = accessibleText;
updateContrastStatus(locale);
scheduleRenderedArtifacts(params, $('theme').value, locale);
const bulkLink = $('bulk-template-link');
if (bulkLink) {
bulkLink.hidden = !cfg.bulkURL;
+3
View File
@@ -73,3 +73,6 @@
input:required,select:required{border-left-width:3px}.article-component label[hidden]{display:none!important}
@media(max-width:760px){.form-step{padding:17px}.field-badge{margin-left:0;margin-top:3px}.conditional-panel{padding:15px}}
.legal-consequences{margin-top:16px;border:1px solid var(--line);border-radius:12px;background:#f7f4ee;padding:14px 16px}.legal-consequences summary{cursor:pointer;font-weight:850}.legal-consequences p{margin:.8rem 0 0;color:var(--muted);font-size:.9rem;line-height:1.55}.legal-consequences .form-warning{color:#5b3029}
/* Accessible first-exposure disclosure preview */
.disclosure-preview-link{display:inline-flex;align-items:center;gap:.55rem;min-height:36px;padding:4px 7px;max-width:100%;border-radius:7px;text-decoration:underline;text-underline-offset:.16em;line-height:1.35}.disclosure-preview-link img{flex:0 0 auto}.disclosure-preview-link span{font-weight:700}.contrast-status{margin:0;padding:12px 14px;border-left:4px solid #8a5a11;border-radius:10px;background:#fff4e5;color:#5b3b0b;font-size:.9rem;line-height:1.5}.code-tabs textarea#html-code,.code-tabs textarea#json-code{height:180px}
+1 -1
View File
@@ -158,7 +158,7 @@
</section>
</main>
<footer><span>{{.Background.Footer}} · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<footer><span>{{.Background.Footer}} · App 1.8.1 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>
+6 -5
View File
@@ -264,17 +264,18 @@
<label>{{index .Text "custom_right_color"}}
<input id="right-color" maxlength="7" pattern="#[0-9A-Fa-f]{6}" placeholder="#2b6cb0" {{if not .CustomBadge}}disabled{{end}}>
</label>
{{if .CustomBadge}}<p id="badge-contrast-status" class="contrast-status wide" role="status" aria-live="polite" hidden></p>{{end}}
</div>
</section>
{{end}}
<div class="preview-card">
<p class="eyebrow">{{index .Text "preview"}}</p>
<a id="preview-link" href="#"><img id="preview-badge" alt="{{index .Text "preview"}}"></a>
<a id="preview-link" class="disclosure-preview-link" href="#"><img id="preview-badge" alt="" aria-hidden="true"><span id="preview-text"></span></a>
<div class="code-tabs">
<label>HTML<textarea id="html-code" readonly></textarea></label>
<label>Markdown<textarea id="markdown-code" readonly></textarea></label>
<label>JSON-LD URL<textarea id="json-code" readonly></textarea></label>
<label>{{index .Text "embed_html_label"}}<textarea id="html-code" readonly></textarea></label>
<label>{{index .Text "embed_markdown_label"}}<textarea id="markdown-code" readonly></textarea></label>
<label>{{index .Text "embed_json_label"}}<textarea id="json-code" readonly></textarea></label>
</div>
<div class="toolbar wrap"><button id="copy-html" type="button">{{index .Text "copy_html"}}</button><a id="bulk-template-link" class="button secondary" href="#" target="_blank" rel="noopener" hidden>{{or (index .Text "open_bulk") "Bulk"}}</a></div>
<div class="notice compliance-notice"><strong>{{index .Text "ai_act_embed_label"}}</strong> {{index .Text "ai_act_embed_text"}}</div>
@@ -289,7 +290,7 @@
<article><p class="eyebrow">HA</p><h2>{{index .Text "api_stateless_title"}}</h2><p>{{index .Text "api_stateless_desc"}}</p></article>
</section>
</main>
<footer><span>AI Usage Disclosure · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<footer><span>AI Usage Disclosure · App 1.8.1 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script nonce="{{.CSPNonce}}">window.APP_CONFIG={{.AppConfig}};</script>
<script src="/static/app.js" defer></script>
</body>
+1 -1
View File
@@ -119,7 +119,7 @@
</section>
</main>
<footer><span>{{.Marketing.Footer}} · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<footer><span>{{.Marketing.Footer}} · App 1.8.1 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>