post_content : ''; $has = has_shortcode( $content, 'aicl_ai_notice' ) || has_shortcode( $content, 'aicl_disclosure' ); if ( ! $auto && ! $has ) { return; } wp_enqueue_style( 'aicl-notice', AICL_URL . 'assets/css/notice.css', array(), AICL_VERSION ); if ( ! $auto ) { return; } wp_enqueue_script( 'aicl-notice', AICL_URL . 'assets/js/notice.js', array(), AICL_VERSION, true ); wp_add_inline_script( 'aicl-notice', 'window.AICL_NOTICE=' . wp_json_encode( $this->config() ), 'before' ); } /** * The front-end config passed to notice.js. * * @since 1.1.0 * * @return array Disclosure text, corner position, and the chat-widget selectors to watch. */ protected function config() { return array( 'text' => $this->text(), 'position' => (string) AICL_Plugin::opt( 'chatbot_notice_position' ), 'selectors' => $this->selectors(), ); } /** * Render the [aicl_ai_notice] inline disclosure notice. * * @since 1.1.0 * * @param array|string $atts Shortcode attributes: 'text' overrides the configured disclosure. * @return string The notice HTML. */ public function shortcode( $atts ) { $atts = shortcode_atts( array( 'text' => '' ), $atts, 'aicl_ai_notice' ); $text = ( '' !== trim( (string) $atts['text'] ) ) ? (string) $atts['text'] : $this->text(); wp_enqueue_style( 'aicl-notice', AICL_URL . 'assets/css/notice.css', array(), AICL_VERSION ); return '' . '' . '' . esc_html( $text ) . ''; } /** * Render the [aicl_disclosure] AI-content disclosure notice. * * A reusable, server-rendered notice for placing an AI disclosure anywhere shortcodes run * (posts, pages, widgets, block templates), covering AI-generated/edited/assisted content * (Article 50(2)) as well as chatbot interaction (Article 50(1)). Falls back to a per-type default. * * @since 1.2.6 * * @param array|string $atts Attributes: type (ai-generated|ai-edited|ai-assisted|chatbot), * style (inline|badge|banner), and an optional text override. * @return string The disclosure HTML. */ public function disclosure( $atts ) { $atts = shortcode_atts( array( 'type' => 'ai-generated', 'style' => 'inline', 'text' => '', 'bg' => '', 'color' => '', 'mark' => 'aim', 'logo' => '', ), $atts, 'aicl_disclosure' ); $type = sanitize_key( (string) $atts['type'] ); $style = in_array( (string) $atts['style'], array( 'inline', 'badge', 'banner' ), true ) ? (string) $atts['style'] : 'inline'; $defaults = array( 'ai-generated' => __( 'This content was generated by AI.', 'aim-transparency' ), 'ai-edited' => __( 'This content was edited with AI.', 'aim-transparency' ), 'ai-assisted' => __( 'This content was produced with AI assistance.', 'aim-transparency' ), 'chatbot' => $this->text(), ); $text = ( '' !== trim( (string) $atts['text'] ) ) ? (string) $atts['text'] : ( isset( $defaults[ $type ] ) ? $defaults[ $type ] : $defaults['ai-generated'] ); wp_enqueue_style( 'aicl-notice', AICL_URL . 'assets/css/notice.css', array(), AICL_VERSION ); $tag = ( 'banner' === $style ) ? 'div' : 'span'; $style_attr = self::colour_style( $atts['bg'], $atts['color'] ); return '<' . $tag . ' class="aicl-ai-notice aicl-ai-notice-' . esc_attr( $style ) . '"' . $style_attr . ' role="note">' . self::mark_html( $atts['mark'], $type, $atts['bg'] ) . self::logo_html( $atts['logo'] ) . '' . esc_html( $text ) . ''; } /** * The leading mark on a notice. * * Three options: the built-in AIM glyph, the European Commission's official * AI-content icon, or none at all. * * The EU icon set labels AI-generated *media*, which is the Article 50(2) * duty, and there is no official icon for the Article 50(1) chatbot case. A * chatbot notice therefore falls back to the AIM glyph rather than borrowing * an icon that means something else. * * @since 1.2.7 * * @param string $mark Requested mark: aim, eu or none. * @param string $type Disclosure type the notice is rendering. * @param string $bg Requested background, used to pick the icon variant. * @return string Mark markup, or an empty string. */ private static function mark_html( $mark, $type, $bg ) { $mark = in_array( (string) $mark, array( 'aim', 'eu', 'none' ), true ) ? (string) $mark : 'aim'; if ( 'none' === $mark ) { return ''; } /* The EU set has two shapes. `basic` is a square mark; `generated` and `modified` are wide lockups that already spell out "AI GENERATED" and "AI MODIFIED" in the artwork. A notice already carries that sentence in text, and squeezing a 3:1 lockup into a glyph slot would distort official artwork, so the mark always uses the square one. */ $eu_types = array( 'ai-generated', 'ai-edited', 'ai-assisted' ); if ( 'eu' === $mark && in_array( $type, $eu_types, true ) ) { $bg_hex = sanitize_hex_color( (string) $bg ); $variant = ( $bg_hex && self::luminance( $bg_hex ) > 0.5 ) ? 'black' : 'white'; $file = 'basic-' . $variant . '.svg'; return ''; } return ''; } /** * An optional site logo, rendered beside the mark rather than instead of it. * * The mark is what identifies the notice as an AI disclosure at a glance, so * a brand logo is additive: replacing the mark would turn a disclosure into * branding, and a blank image would remove it altogether. * * @since 1.2.7 * * @param string $logo Absolute URL of the logo image. * @return string Logo markup, or an empty string. */ private static function logo_html( $logo ) { $url = esc_url_raw( trim( (string) $logo ) ); if ( ! $url || ! preg_match( '~\.(png|jpe?g|gif|webp|svg)$~i', wp_parse_url( $url, PHP_URL_PATH ) ) ) { return ''; } return ''; } /** * Builds the inline colour style for a notice, but only when the pair is * legible. * * A disclosure that cannot be read is not a disclosure, so a combination * below the WCAG AA 4.5:1 ratio is dropped and the default near-black pill * is used instead. This is deliberate: the shortcode should not be able to * hide the notice it exists to show. * * @since 1.2.7 * * @param string $bg Requested background colour, any CSS hex form. * @param string $text Requested text colour, any CSS hex form. * @return string A ` style="..."` attribute, or an empty string. */ private static function colour_style( $bg, $text ) { $bg = sanitize_hex_color( (string) $bg ); $text = sanitize_hex_color( (string) $text ); if ( ! $bg && ! $text ) { return ''; } $bg_final = $bg ? $bg : '#111111'; $text_final = $text ? $text : '#ffffff'; if ( self::contrast( $bg_final, $text_final ) < 4.5 ) { return ''; } $css = 'background:' . $bg_final . ';color:' . $text_final . ';'; return ' style="' . esc_attr( $css ) . '"'; } /** * WCAG relative-luminance contrast ratio between two hex colours. * * @since 1.2.7 * * @param string $a First colour, as #rgb or #rrggbb. * @param string $b Second colour, as #rgb or #rrggbb. * @return float Ratio between 1 and 21. */ private static function contrast( $a, $b ) { $la = self::luminance( $a ); $lb = self::luminance( $b ); $hi = max( $la, $lb ); $lo = min( $la, $lb ); return ( $hi + 0.05 ) / ( $lo + 0.05 ); } /** * Relative luminance of a hex colour, per WCAG 2.x. * * @since 1.2.7 * * @param string $hex Colour as #rgb or #rrggbb. * @return float Luminance between 0 and 1. */ private static function luminance( $hex ) { $hex = ltrim( (string) $hex, '#' ); if ( 3 === strlen( $hex ) ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } $out = 0.0; $mul = array( 0.2126, 0.7152, 0.0722 ); foreach ( array( 0, 2, 4 ) as $i => $offset ) { $c = hexdec( substr( $hex, $offset, 2 ) ) / 255; $c = ( $c <= 0.03928 ) ? ( $c / 12.92 ) : pow( ( $c + 0.055 ) / 1.055, 2.4 ); $out += $c * $mul[ $i ]; } return $out; } /** * The disclosure text, falling back to the built-in default when the option is empty. * * @since 1.1.0 * * @return string */ protected function text() { $text = trim( (string) AICL_Plugin::opt( 'chatbot_notice_text' ) ); return '' !== $text ? $text : __( 'You are chatting with an AI assistant, not a human.', 'aim-transparency' ); } /** * The built-in chat-widget selectors plus any the deployer added. * * @since 1.1.0 * * @return string[] CSS selectors whose presence indicates a chat widget is on the page. */ protected function selectors() { $presets = array( '#tidio-chat', '.crisp-client', '#intercom-container', '#hubspot-messages-iframe-container', '.drift-frame-controller', '#chat-widget-container', 'df-messenger', '#tawkchat-container', '#chatbase-bubble-button', '#zsiq_float', ); $custom = preg_split( '/[\r\n,]+/', (string) AICL_Plugin::opt( 'chatbot_notice_selectors' ) ); $custom = is_array( $custom ) ? array_filter( array_map( 'trim', $custom ) ) : array(); return array_values( array_unique( array_merge( $presets, $custom ) ) ); } }