import { useEffect, useRef } from "react" import type { CSSProperties } from "react" import { addPropertyControls, ControlType, RenderTarget } from "framer" /** * Spurvik · EU AI Act Article 50 AI notice badge for Framer. * * Paste into Assets → Code → New Code File, then drag "SpurvikArticle50Badge" onto * the page (or into a chat section). On the published site it loads * https://spurvik.com/badge.js (MIT, zero cookies, no telemetry, Shadow DOM) and * renders the real notice in 24 EU languages. The Framer canvas shows a static preview. * * A visible notice supports your Art. 50 transparency work. It does not add * Art. 50(2) machine-readable marking and is not legal advice. */ type Notice = "interaction" | "generated" | "deepfake" | "public-interest" | "emotion-biometric" type Theme = "auto" | "dark" | "light" type Position = "inline" | "bottom-right" | "bottom-left" interface Props { systemName: string notice: Notice locale: string theme: Theme position: Position attribution: boolean style?: CSSProperties } interface SpurvikBadgeApi { create: (options: { systemName?: string notice?: Notice locale?: string theme?: Theme position?: Position attribution?: boolean }) => HTMLElement } const BADGE_SRC = "https://spurvik.com/badge.js" const LOCALES = [ "bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr", "ga", "hr", "hu", "it", "lt", "lv", "mt", "nl", "pl", "pt", "ro", "sk", "sl", "sv", ] const PREVIEW_HEADLINE: Record = { interaction: "You are interacting with an AI system", generated: "AI-generated", deepfake: "This content has been artificially generated or manipulated.", "public-interest": "This text was artificially generated or manipulated…", "emotion-biometric": "You are exposed to an emotion recognition or biometric categorisation system.", } function badgeApi(): SpurvikBadgeApi | undefined { return (window as unknown as { SpurvikBadge?: SpurvikBadgeApi }).SpurvikBadge } let loading: Promise | null = null function loadBadge(): Promise { const ready = badgeApi() if (ready) return Promise.resolve(ready) if (loading) return loading loading = new Promise((resolve, reject) => { const existing = document.querySelector(`script[src="${BADGE_SRC}"]`) const script = existing ?? document.createElement("script") script.addEventListener( "load", () => { const api = badgeApi() if (api) resolve(api) else reject(new Error("badge.js loaded but window.SpurvikBadge is missing")) }, { once: true } ) script.addEventListener( "error", () => { loading = null reject(new Error(`Could not load ${BADGE_SRC}`)) }, { once: true } ) if (!existing) { script.src = BADGE_SRC script.async = true script.dataset.spurvikLoader = "framer" document.head.appendChild(script) } }) return loading } /** * @framerSupportedLayoutWidth auto * @framerSupportedLayoutHeight auto */ export default function SpurvikArticle50Badge(props: Props) { const { systemName = "Support Assistant", notice = "interaction", locale = "auto", theme = "dark", position = "inline", attribution = true, style, } = props const slot = useRef(null) const onCanvas = RenderTarget.current() === RenderTarget.canvas useEffect(() => { if (onCanvas) return let host: HTMLElement | null = null let cancelled = false loadBadge() .then((api) => { if (cancelled) return host = api.create({ systemName: systemName.trim() || undefined, notice, locale: locale === "auto" ? undefined : locale, theme, position, attribution, }) if (position === "inline") slot.current?.appendChild(host) else document.body.appendChild(host) }) .catch((error: unknown) => { console.error("[spurvik-framer-badge]", error) }) return () => { cancelled = true host?.remove() } }, [onCanvas, systemName, notice, locale, theme, position, attribution]) if (onCanvas) { const light = theme === "light" const name = systemName.trim() return (
{PREVIEW_HEADLINE[notice] ?? PREVIEW_HEADLINE.interaction} {name ? · {name} : null} EU AI Act Art. 50 {attribution ? ( Art. 50 · Spurvik ) : null} {position !== "inline" ? ( (floats {position} when published) ) : null}
) } // data-spurvik-mounted tells badge.js's auto-mount to leave this slot alone // (no duplicate floating badge); the effect above renders into it instead. return (
) } function pill(light: boolean): CSSProperties { return { display: "inline-flex", alignItems: "center", gap: 8, padding: "6px 12px", borderRadius: 9999, fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", fontSize: 12, lineHeight: 1.35, background: light ? "rgba(248, 250, 252, 0.96)" : "rgba(11, 16, 28, 0.92)", color: light ? "#0F172A" : "#F8FAFC", border: `1px solid ${light ? "rgba(15, 23, 42, 0.14)" : "rgba(16, 185, 129, 0.38)"}`, boxShadow: "0 8px 24px rgba(0,0,0,0.22)", whiteSpace: "nowrap", } } const dot: CSSProperties = { width: 7, height: 7, borderRadius: 9999, background: "#10B981", boxShadow: "0 0 8px #10B981", flexShrink: 0, } const tag: CSSProperties = { fontSize: 10, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", color: "#10B981", padding: "1px 6px", borderRadius: 999, background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.28)", } addPropertyControls(SpurvikArticle50Badge, { systemName: { type: ControlType.String, title: "System name", defaultValue: "Support Assistant", placeholder: "Your AI system", }, notice: { type: ControlType.Enum, title: "Notice", options: ["interaction", "generated", "deepfake", "public-interest", "emotion-biometric"], optionTitles: [ "AI interaction (50(1))", "AI-generated label", "Deep fake (50(4))", "Public-interest text (50(4))", "Emotion / biometric (50(3))", ], defaultValue: "interaction", }, locale: { type: ControlType.Enum, title: "Language", options: ["auto", ...LOCALES], optionTitles: ["Auto (page lang)", ...LOCALES.map((code) => code.toUpperCase())], defaultValue: "auto", }, theme: { type: ControlType.Enum, title: "Theme", options: ["auto", "dark", "light"], optionTitles: ["Auto", "Dark", "Light"], defaultValue: "dark", displaySegmentedControl: true, }, position: { type: ControlType.Enum, title: "Position", options: ["inline", "bottom-right", "bottom-left"], optionTitles: ["Inline", "Bottom right", "Bottom left"], defaultValue: "inline", }, attribution: { type: ControlType.Boolean, title: "Spurvik link", description: "Off = Pro white-label (€19/mo).", defaultValue: true, enabledTitle: "Show", disabledTitle: "Hide", }, })