From 41efdf58eea12b48fde12aa35b752bbaa3269a9c Mon Sep 17 00:00:00 2001 From: Job Sunday Owonubi Date: Mon, 27 Jul 2026 16:29:10 +0100 Subject: [PATCH] Add EU AI-disclosure label to the popup Adds the European Commission's official AI-labelling icons (AI, AI GENERATED, AI MODIFIED) and suggests the one matching a session's composition, as an informational line under the popup breakdown. - src/assets/eu-icons: the 12 official Commission SVGs (3 marks in black/white, solid and 50% transparent), free to use, no attribution required. Ship via the existing recursive src/assets copy. - src/lib/eu-ai-label.js: maps a session's kept AI share to the right mark. AI GENERATED only at effectively 100% AI (no human-created elements), AI MODIFIED for a genuine human/AI mix, basic AI for minimal use, none when no AI was recorded. - popup: the suggested label is derived from the same rounded percentages the breakdown bars display, so it can never contradict them (an AI bar of 0% shows no label). Hovering the row explains why that label was suggested. Informational only: nothing is written to the log or auto-applied. The author decides whether an AI Act Article 50 disclosure actually applies. Includes unit tests for the classification thresholds and icon paths. --- .gitignore | 3 + src/assets/eu-icons/ai-black-transparent.svg | 20 +++ src/assets/eu-icons/ai-black.svg | 19 ++ .../ai-generated-black-transparent.svg | 31 ++++ src/assets/eu-icons/ai-generated-black.svg | 30 ++++ .../ai-generated-white-transparent.svg | 32 ++++ src/assets/eu-icons/ai-generated-white.svg | 31 ++++ .../ai-modified-black-transparent.svg | 30 ++++ src/assets/eu-icons/ai-modified-black.svg | 29 ++++ .../ai-modified-white-transparent.svg | 31 ++++ src/assets/eu-icons/ai-modified-white.svg | 30 ++++ src/assets/eu-icons/ai-white-transparent.svg | 21 +++ src/assets/eu-icons/ai-white.svg | 20 +++ src/lib/eu-ai-label.js | 163 ++++++++++++++++++ src/popup/popup.css | 51 ++++++ src/popup/popup.html | 12 ++ src/popup/popup.js | 66 +++++++ tests/eu-ai-label.test.js | 161 +++++++++++++++++ 18 files changed, 780 insertions(+) create mode 100644 src/assets/eu-icons/ai-black-transparent.svg create mode 100644 src/assets/eu-icons/ai-black.svg create mode 100644 src/assets/eu-icons/ai-generated-black-transparent.svg create mode 100644 src/assets/eu-icons/ai-generated-black.svg create mode 100644 src/assets/eu-icons/ai-generated-white-transparent.svg create mode 100644 src/assets/eu-icons/ai-generated-white.svg create mode 100644 src/assets/eu-icons/ai-modified-black-transparent.svg create mode 100644 src/assets/eu-icons/ai-modified-black.svg create mode 100644 src/assets/eu-icons/ai-modified-white-transparent.svg create mode 100644 src/assets/eu-icons/ai-modified-white.svg create mode 100644 src/assets/eu-icons/ai-white-transparent.svg create mode 100644 src/assets/eu-icons/ai-white.svg create mode 100644 src/lib/eu-ai-label.js create mode 100644 tests/eu-ai-label.test.js diff --git a/.gitignore b/.gitignore index 1138a66..89cc2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Build/packaging artifacts +colophon-*.zip diff --git a/src/assets/eu-icons/ai-black-transparent.svg b/src/assets/eu-icons/ai-black-transparent.svg new file mode 100644 index 0000000..ece2eae --- /dev/null +++ b/src/assets/eu-icons/ai-black-transparent.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-black.svg b/src/assets/eu-icons/ai-black.svg new file mode 100644 index 0000000..19eefd5 --- /dev/null +++ b/src/assets/eu-icons/ai-black.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-generated-black-transparent.svg b/src/assets/eu-icons/ai-generated-black-transparent.svg new file mode 100644 index 0000000..d268294 --- /dev/null +++ b/src/assets/eu-icons/ai-generated-black-transparent.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-generated-black.svg b/src/assets/eu-icons/ai-generated-black.svg new file mode 100644 index 0000000..a099767 --- /dev/null +++ b/src/assets/eu-icons/ai-generated-black.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-generated-white-transparent.svg b/src/assets/eu-icons/ai-generated-white-transparent.svg new file mode 100644 index 0000000..4ff9978 --- /dev/null +++ b/src/assets/eu-icons/ai-generated-white-transparent.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-generated-white.svg b/src/assets/eu-icons/ai-generated-white.svg new file mode 100644 index 0000000..d1540d5 --- /dev/null +++ b/src/assets/eu-icons/ai-generated-white.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-modified-black-transparent.svg b/src/assets/eu-icons/ai-modified-black-transparent.svg new file mode 100644 index 0000000..53393bf --- /dev/null +++ b/src/assets/eu-icons/ai-modified-black-transparent.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-modified-black.svg b/src/assets/eu-icons/ai-modified-black.svg new file mode 100644 index 0000000..2ba345f --- /dev/null +++ b/src/assets/eu-icons/ai-modified-black.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-modified-white-transparent.svg b/src/assets/eu-icons/ai-modified-white-transparent.svg new file mode 100644 index 0000000..3ed4b64 --- /dev/null +++ b/src/assets/eu-icons/ai-modified-white-transparent.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-modified-white.svg b/src/assets/eu-icons/ai-modified-white.svg new file mode 100644 index 0000000..98908fe --- /dev/null +++ b/src/assets/eu-icons/ai-modified-white.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-white-transparent.svg b/src/assets/eu-icons/ai-white-transparent.svg new file mode 100644 index 0000000..f0cf361 --- /dev/null +++ b/src/assets/eu-icons/ai-white-transparent.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/eu-icons/ai-white.svg b/src/assets/eu-icons/ai-white.svg new file mode 100644 index 0000000..0988a55 --- /dev/null +++ b/src/assets/eu-icons/ai-white.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/lib/eu-ai-label.js b/src/lib/eu-ai-label.js new file mode 100644 index 0000000..b47f13a --- /dev/null +++ b/src/lib/eu-ai-label.js @@ -0,0 +1,163 @@ +/** + * eu-ai-label.js — Map a Colophon session onto the EU's AI-disclosure icons. + * + * Source of the icons and the rules they encode: + * https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content + * + * The Commission publishes three marks (each in black/white × solid/50%- + * transparent). Their meanings, and how a Colophon process log maps onto them: + * + * 'ai' — the basic mark. AI was involved somewhere in producing the + * content, without asserting how much. Our safe default when + * AI was used but we can't justify a stronger claim. + * 'ai-generated' — the content is *fully* AI-generated, i.e. no human-authored + * material beyond the prompt. + * 'ai-modified' — pre-existing human-written content was *partially* altered + * using AI. + * + * IMPORTANT — what this function is and isn't: + * This returns a SUGGESTION for the author, computed from what Colophon + * observed. It is not a legal determination. Article 50 disclosure duties + * attach to particular content types (deepfakes; public-interest text without + * human editorial review) and carry exemptions (artistic/satirical works, + * law enforcement). Colophon cannot know whether a given document falls in + * scope — only the author can. So the UI must present this as "suggested + * label", always overridable, never auto-applied. + * + * Licensing: the Commission makes these icons freely usable by anyone, with no + * attribution required. Code of Practice signatories must additionally follow + * the official placement specification. + */ + +/** The three EU label kinds, plus 'none' when no AI was recorded. */ +export const EU_LABEL = { + NONE: 'none', + AI: 'ai', + AI_GENERATED: 'ai-generated', + AI_MODIFIED: 'ai-modified', +} + +/** + * Fraction of kept AI characters at or above which we suggest "AI GENERATED". + * + * The Commission's guidance defines this mark as content "fully generated by AI + * with no human-created elements or human editorial control (apart from + * prompting)" — i.e. effectively 100% AI. We therefore set this very high + * rather than at a loose "mostly AI" level: suggesting "fully AI generated" for + * a document a human partly wrote would be a false disclosure, and the correct + * mark in that case is AI MODIFIED. The small tolerance below 1.0 absorbs + * measurement noise (whitespace, counting drift), not real human authorship. + */ +export const FULLY_AI_THRESHOLD = 0.995 + +/** + * Below this fraction of AI characters we don't claim "modified" — a stray + * accepted phrase in an otherwise human document gets the neutral basic mark. + */ +export const MODIFIED_MIN_THRESHOLD = 0.02 + +/** + * Decide which EU icon a session suggests. + * + * @param {object} stats + * @param {number} stats.humanChars characters attributable to the author + * @param {number} stats.aiChars AI characters retained in the document + * @returns {{label: string, reason: string, aiShare: number}} + */ +export function classifyEuLabel({ humanChars = 0, aiChars = 0 } = {}) { + const total = humanChars + aiChars + if (total <= 0 || aiChars <= 0) { + return { label: EU_LABEL.NONE, reason: 'No AI contribution recorded.', aiShare: 0 } + } + + const aiShare = aiChars / total + + if (aiShare >= FULLY_AI_THRESHOLD) { + return { + label: EU_LABEL.AI_GENERATED, + reason: 'Effectively all recorded content came from AI output.', + aiShare, + } + } + + if (aiShare >= MODIFIED_MIN_THRESHOLD) { + return { + label: EU_LABEL.AI_MODIFIED, + reason: 'Human-written content was partially produced or altered using AI.', + aiShare, + } + } + + return { + label: EU_LABEL.AI, + reason: 'AI was used, but its contribution to the final text is minimal.', + aiShare, + } +} + +/** + * Derive {humanChars, aiChars} from a session's event list. + * + * AI characters count only what was actually KEPT: rejected suggestions + * contribute nothing, and where a similarity_score is available (how much of + * the AI's wording survived the author's edits) we scale by it rather than + * crediting the AI with text the author rewrote. + */ +export function summariseSession(events = []) { + let humanChars = 0 + let aiChars = 0 + + for (const e of events) { + const m = e?.meta ?? {} + if (e?.type === 'edit') { + // Only human-sourced edits count toward authored characters. + if ((m.source ?? 'human') === 'human') { + humanChars += Math.max(0, m.char_delta ?? 0) + } + } else if (e?.type === 'ai_interaction' || e?.type === 'ai_suggestion') { + if (m.acceptance === 'rejected') continue + const kept = m.ai_chars ?? 0 + if (kept <= 0) continue + // similarity_score (0-1) tells us how much of the AI wording survived. + const survived = typeof m.similarity_score === 'number' + ? Math.max(0, Math.min(1, m.similarity_score)) + : 1 + aiChars += kept * survived + } + } + + return { humanChars: Math.round(humanChars), aiChars: Math.round(aiChars) } +} + +/** Convenience: classify straight from an event list. */ +export function classifySessionEvents(events = []) { + return classifyEuLabel(summariseSession(events)) +} + +/** + * Asset path for a label, resolved against the packaged EU icon set. + * + * @param {string} label one of EU_LABEL + * @param {object} [opts] + * @param {'black'|'white'} [opts.colour='black'] + * @param {boolean} [opts.transparent=false] use the 50%-transparency variant + * @returns {string|null} extension-relative path, or null for EU_LABEL.NONE + */ +export function euIconPath(label, { colour = 'black', transparent = false } = {}) { + if (!label || label === EU_LABEL.NONE) return null + const suffix = transparent ? `${colour}-transparent` : colour + return `assets/eu-icons/${label}-${suffix}.svg` +} + +/** + * Human-readable label text — used for alt text / plain-language labelling, + * which the Commission explicitly encourages for accessibility. + */ +export function euLabelText(label) { + switch (label) { + case EU_LABEL.AI_GENERATED: return 'AI generated' + case EU_LABEL.AI_MODIFIED: return 'AI modified' + case EU_LABEL.AI: return 'AI' + default: return '' + } +} diff --git a/src/popup/popup.css b/src/popup/popup.css index 0581de1..cea4980 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -232,6 +232,57 @@ svg { min-height: 25px; } +/* EU AI-disclosure label — a quiet informational row under the bars. */ +.eu-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 12px 16px 0; + padding-top: 12px; + border-top: 1px solid var(--border-color); + /* Hover reveals the plain-language "why" via the title attribute. */ + cursor: help; +} + +.eu-label__caption { + font-size: 12px; + color: var(--text-secondary); +} + +.eu-label__value { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.eu-label__icon { + height: 20px; + width: auto; + display: block; +} + +/* Visually-hidden text label kept for screen readers / plain-language + accessibility (the Commission encourages an accompanying text label). */ +.eu-label__text { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Visible muted state when a session has no AI contribution. */ +.eu-label__none { + font-size: 12px; + color: var(--text-secondary); + font-style: italic; +} + .metric span { color: var(--text-primary); font-size: 12px; diff --git a/src/popup/popup.html b/src/popup/popup.html index a1268f1..e54571b 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -60,6 +60,18 @@

Breakdown

80% + + +
diff --git a/src/popup/popup.js b/src/popup/popup.js index ff492c0..ef2b3ca 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -16,6 +16,7 @@ */ import { exportTwff } from '../lib/export.js' +import { classifyEuLabel, euIconPath, euLabelText, EU_LABEL } from '../lib/eu-ai-label.js' const $ = id => document.getElementById(id) @@ -185,6 +186,71 @@ function renderScores(session) { setScore('ai', ai) setScore('source', source) renderSummaryBanner(hasData) + // Derive the EU label from the SAME rounded percentages the bars display, so + // the suggestion can never contradict what the user sees (e.g. "AI 0%" must + // not produce an "AI" label). + renderEuLabel({ hasSession: !!session, own, ai, source }) +} + +// Informational EU AI-disclosure label. Suggests the European Commission's +// AI-labelling icon that matches this session's composition. It is a +// suggestion only — Colophon can't know whether a document falls within AI Act +// Article 50 scope, so the author decides whether to apply it. Never written to +// the log or auto-applied here. +function renderEuLabel({ hasSession, own, ai, source }) { + const wrap = $('eu-label') + if (!wrap) return + const icon = $('eu-label-icon') + const text = $('eu-label-text') + const none = $('eu-label-none') + + // Only show the row once a session exists; before that the popup is the + // static dashboard and a disclosure suggestion would be meaningless. + if (!hasSession) { + wrap.hidden = true + return + } + wrap.hidden = false + + // Classify from the displayed percentages: aiShare = ai / (own + ai + source) + // matches the AI bar exactly, so label and bars always agree. + const { label, aiShare } = classifyEuLabel({ humanChars: own + source, aiChars: ai }) + const aiPct = Math.round(aiShare * 100) + + wrap.title = euLabelReason(label, aiPct) + + if (label === EU_LABEL.NONE) { + // Session recorded, but no AI in the breakdown — muted "no label" state + // rather than hiding, so the feature stays discoverable. + if (icon) icon.hidden = true + if (text) text.textContent = '' + if (none) none.hidden = false + return + } + + if (none) none.hidden = true + const path = euIconPath(label) // extension-relative, e.g. assets/eu-icons/ai-modified-black.svg + if (icon && path) { + icon.src = `../${path}` + icon.alt = euLabelText(label) + icon.hidden = false + } + if (text) text.textContent = euLabelText(label) +} + +// Plain-language "why" for the hover tooltip on the suggested-label row. +function euLabelReason(label, aiPct) { + const tail = ' This is a suggestion from your recorded session — you decide whether it applies.' + switch (label) { + case EU_LABEL.AI_GENERATED: + return `Suggested: "AI generated". Nearly all recorded activity (${aiPct}%) came from AI, with little or no writing of your own.` + tail + case EU_LABEL.AI_MODIFIED: + return `Suggested: "AI modified". AI made up ${aiPct}% of recorded activity, mixed with your own writing.` + tail + case EU_LABEL.AI: + return `Suggested: "AI". AI was involved but made up only ${aiPct}% of recorded activity.` + tail + default: + return 'No AI contribution recorded in this session, so no EU AI label is suggested.' + } } // Single filled path (not stroke-based, since .leaf-mark svg forces diff --git a/tests/eu-ai-label.test.js b/tests/eu-ai-label.test.js new file mode 100644 index 0000000..fc6cbd2 --- /dev/null +++ b/tests/eu-ai-label.test.js @@ -0,0 +1,161 @@ +/** + * eu-ai-label.test.js — EU AI-disclosure icon classification + * (src/lib/eu-ai-label.js) + * + * These thresholds decide which disclosure a user is shown, so the edges matter: + * over-claiming ("fully AI generated" for a mostly-human doc) is a false + * disclosure, and under-claiming defeats the purpose. + */ + +import { describe, it, expect } from 'vitest' +import { + EU_LABEL, + classifyEuLabel, + summariseSession, + classifySessionEvents, + euIconPath, + euLabelText, + FULLY_AI_THRESHOLD, +} from '../src/lib/eu-ai-label.js' + +describe('classifyEuLabel', () => { + it('returns NONE when no AI was used', () => { + const r = classifyEuLabel({ humanChars: 1000, aiChars: 0 }) + expect(r.label).toBe(EU_LABEL.NONE) + expect(r.aiShare).toBe(0) + }) + + it('returns NONE for an empty session', () => { + expect(classifyEuLabel({}).label).toBe(EU_LABEL.NONE) + }) + + it('returns AI_GENERATED only when there is effectively no human authorship', () => { + expect(classifyEuLabel({ humanChars: 0, aiChars: 500 }).label).toBe(EU_LABEL.AI_GENERATED) + }) + + it('does NOT claim fully-AI when a human wrote a real share (EU: "no human-created elements")', () => { + // 5% human authorship — the EU mark requires no human-created elements, + // so this must fall back to AI MODIFIED rather than over-claiming. + expect(classifyEuLabel({ humanChars: 50, aiChars: 950 }).label).toBe(EU_LABEL.AI_MODIFIED) + // 1% human is still real authorship, not measurement noise. + expect(classifyEuLabel({ humanChars: 10, aiChars: 990 }).label).toBe(EU_LABEL.AI_MODIFIED) + }) + + it('absorbs sub-noise human counts into AI_GENERATED (documented tolerance)', () => { + // 0.5% (a stray keystroke or counting drift) is within FULLY_AI_THRESHOLD's + // deliberate tolerance — not treated as genuine human authorship. + expect(classifyEuLabel({ humanChars: 5, aiChars: 995 }).label).toBe(EU_LABEL.AI_GENERATED) + }) + + it('returns AI_MODIFIED for a genuine human/AI mix', () => { + expect(classifyEuLabel({ humanChars: 700, aiChars: 300 }).label).toBe(EU_LABEL.AI_MODIFIED) + expect(classifyEuLabel({ humanChars: 500, aiChars: 500 }).label).toBe(EU_LABEL.AI_MODIFIED) + }) + + it('returns the basic AI mark when the AI contribution is minimal', () => { + // 1% AI — real, but too small to claim the document was "AI modified" + expect(classifyEuLabel({ humanChars: 9900, aiChars: 100 }).label).toBe(EU_LABEL.AI) + // 0.1% — likewise just the neutral mark + expect(classifyEuLabel({ humanChars: 99900, aiChars: 100 }).label).toBe(EU_LABEL.AI) + // 5% clears MODIFIED_MIN_THRESHOLD, so it is a genuine AI-modified doc + expect(classifyEuLabel({ humanChars: 950, aiChars: 50 }).label).toBe(EU_LABEL.AI_MODIFIED) + }) + + it('does not over-claim: a mostly-human doc is never AI_GENERATED', () => { + const r = classifyEuLabel({ humanChars: 400, aiChars: 600 }) + expect(r.label).not.toBe(EU_LABEL.AI_GENERATED) + }) + + it('respects the FULLY_AI_THRESHOLD boundary', () => { + const total = 1000 + const justUnder = classifyEuLabel({ + humanChars: total - Math.ceil(total * FULLY_AI_THRESHOLD) + 1, + aiChars: Math.ceil(total * FULLY_AI_THRESHOLD) - 1, + }) + expect(justUnder.label).toBe(EU_LABEL.AI_MODIFIED) + }) +}) + +describe('summariseSession', () => { + it('ignores rejected AI suggestions entirely', () => { + const events = [ + { type: 'edit', meta: { char_delta: 100, source: 'human' } }, + { type: 'ai_interaction', meta: { acceptance: 'rejected', ai_chars: 500 } }, + ] + expect(summariseSession(events)).toEqual({ humanChars: 100, aiChars: 0 }) + }) + + it('counts accepted AI characters', () => { + const events = [ + { type: 'edit', meta: { char_delta: 100, source: 'human' } }, + { type: 'ai_interaction', meta: { acceptance: 'fully_accepted', ai_chars: 200 } }, + ] + expect(summariseSession(events)).toEqual({ humanChars: 100, aiChars: 200 }) + }) + + it('scales AI characters by similarity_score when the author reworded', () => { + // AI produced 200 chars but only half its wording survived + const events = [ + { type: 'ai_interaction', meta: { acceptance: 'modified', ai_chars: 200, similarity_score: 0.5 } }, + ] + expect(summariseSession(events).aiChars).toBe(100) + }) + + it('does not count AI-sourced edits as human characters', () => { + const events = [ + { type: 'edit', meta: { char_delta: 300, source: 'ai' } }, + { type: 'edit', meta: { char_delta: 100, source: 'human' } }, + ] + expect(summariseSession(events).humanChars).toBe(100) + }) + + it('ignores deletions (negative deltas) rather than subtracting', () => { + const events = [{ type: 'edit', meta: { char_delta: -50, source: 'human' } }] + expect(summariseSession(events).humanChars).toBe(0) + }) + + it('handles an empty/undefined event list safely', () => { + expect(summariseSession()).toEqual({ humanChars: 0, aiChars: 0 }) + expect(summariseSession([])).toEqual({ humanChars: 0, aiChars: 0 }) + }) +}) + +describe('classifySessionEvents end-to-end', () => { + it('labels a human doc with one accepted suggestion as AI_MODIFIED', () => { + const events = [ + { type: 'edit', meta: { char_delta: 800, source: 'human' } }, + { type: 'ai_interaction', meta: { acceptance: 'fully_accepted', ai_chars: 200 } }, + ] + expect(classifySessionEvents(events).label).toBe(EU_LABEL.AI_MODIFIED) + }) + + it('labels a doc with only rejected suggestions as NONE', () => { + const events = [ + { type: 'edit', meta: { char_delta: 800, source: 'human' } }, + { type: 'ai_interaction', meta: { acceptance: 'rejected', ai_chars: 900 } }, + ] + expect(classifySessionEvents(events).label).toBe(EU_LABEL.NONE) + }) +}) + +describe('euIconPath / euLabelText', () => { + it('resolves packaged icon paths', () => { + expect(euIconPath(EU_LABEL.AI)).toBe('assets/eu-icons/ai-black.svg') + expect(euIconPath(EU_LABEL.AI_GENERATED, { colour: 'white' })) + .toBe('assets/eu-icons/ai-generated-white.svg') + expect(euIconPath(EU_LABEL.AI_MODIFIED, { colour: 'black', transparent: true })) + .toBe('assets/eu-icons/ai-modified-black-transparent.svg') + }) + + it('returns null for NONE (nothing to display)', () => { + expect(euIconPath(EU_LABEL.NONE)).toBeNull() + expect(euIconPath(null)).toBeNull() + }) + + it('provides plain-language text for accessibility/alt text', () => { + expect(euLabelText(EU_LABEL.AI_GENERATED)).toBe('AI generated') + expect(euLabelText(EU_LABEL.AI_MODIFIED)).toBe('AI modified') + expect(euLabelText(EU_LABEL.AI)).toBe('AI') + expect(euLabelText(EU_LABEL.NONE)).toBe('') + }) +})