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 @@