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 ''
. 'AI'
. '' . 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 ) . '' . $tag . '>';
}
/**
* 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 'AI';
}
/**
* 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 ) ) );
}
}