, including the name of the person * holding editorial responsibility. That name is compliance evidence, not page content. * * The review date is stamped from the meta hooks rather than one save path, because the * classic box, the block editor's REST write and a programmatic update are three ways in, * and the meta itself is the only place all three pass through. * * @since 2.1.0 * * @return void */ public static function boot() { add_action( 'init', array( __CLASS__, 'register_meta' ) ); add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_box' ) ); add_action( 'save_post', array( __CLASS__, 'save_meta_box' ), 10, 2 ); add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'block_assets' ) ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'front_assets' ) ); add_filter( 'the_content', array( __CLASS__, 'disclose' ), 998 ); add_shortcode( 'aicl_text_disclosure', array( __CLASS__, 'shortcode' ) ); foreach ( self::post_types() as $post_type ) { add_filter( "rest_prepare_{$post_type}", array( __CLASS__, 'filter_rest_meta' ), 10, 3 ); } add_action( 'added_post_meta', array( __CLASS__, 'sync_review_date' ), 10, 4 ); add_action( 'updated_post_meta', array( __CLASS__, 'sync_review_date' ), 10, 4 ); } /** * Removes this feature's meta from REST responses for readers who cannot edit the post. * * The rule is that the API says exactly what the page says, and nothing more. * * The review flag, the named person and the review date are always removed. They are the * site's internal evidence for the exemption, they never render anywhere, and the name in * particular is personal data that an owner would not expect a compliance plugin to publish. * * The source type is kept only where the post actually discloses, since in that case the page * states it outright and the JSON-LD repeats it. Where the post is exempt, or the disclosure is * switched off, the page says nothing and neither should this. That is the one point where text * differs from images: a flagged image always badges, so `_aicl_source_type` is public on every * post, while an exempt article is one the law does not ask to speak. * * @since 2.1.0 * * @param WP_REST_Response $response The response being prepared. * @param WP_Post $post The post it describes. * @param WP_REST_Request $request The request being served. * @return WP_REST_Response The response, with the private fields stripped where appropriate. */ public static function filter_rest_meta( $response, $post, $request ) { unset( $request ); if ( ! isset( $response->data['meta'] ) || ! is_array( $response->data['meta'] ) ) { return $response; } if ( current_user_can( 'edit_post', $post->ID ) ) { return $response; } foreach ( array( self::META_REVIEWED, self::META_EDITOR, self::META_REVIEWED_ON ) as $key ) { unset( $response->data['meta'][ $key ] ); } if ( ! self::discloses_on_page( $post->ID ) ) { unset( $response->data['meta'][ self::META_TYPE ] ); } return $response; } /** * Keeps the review date in step with the review flag. * * Recorded on the first transition to reviewed and left alone afterwards, so editing a post * later does not silently restate when it was checked. Clearing the flag clears the date, * because a date with no review behind it is worse than no date. * * @since 2.1.0 * * @param int $meta_id The meta row ID. Unused. * @param int $post_id The post the meta belongs to. * @param string $meta_key The meta key that changed. * @param mixed $value The new value. * @return void */ public static function sync_review_date( $meta_id, $post_id, $meta_key, $value ) { unset( $meta_id ); if ( self::META_REVIEWED !== $meta_key ) { return; } if ( ! $value ) { delete_post_meta( $post_id, self::META_REVIEWED_ON ); return; } if ( '' === (string) get_post_meta( $post_id, self::META_REVIEWED_ON, true ) ) { update_post_meta( $post_id, self::META_REVIEWED_ON, gmdate( 'Y-m-d' ) ); } } /** * Makes sure the disclosure has its stylesheet even when the badge is switched off. * * The badge's own enqueue returns early on `badge_enabled`, so a site that discloses text * but shows no image badges would otherwise render this unstyled. Same handle, so nothing * loads twice when the badge is on. * * @since 2.1.0 * * @return void */ public static function front_assets() { if ( ! AICL_Plugin::opt( 'text_disclosure' ) || AICL_Plugin::opt( 'badge_enabled' ) ) { return; } if ( ! is_singular() || ! self::discloses_on_page( get_queried_object_id() ) ) { return; } $css_path = AICL_DIR . 'assets/css/badge.css'; $version = file_exists( $css_path ) ? filemtime( $css_path ) : AICL_VERSION; wp_enqueue_style( 'aicl-badge', AICL_URL . 'assets/css/badge.css', array(), $version ); } /** * Returns the source types that can describe text. * * Only the AI tokens apply. The other four describe how a file came to exist (a camera * captured it, a person modelled it, a formula drew it, several non-AI parts were * combined) and none of that means anything for a paragraph. Derived from the plugin's * own list rather than copied, so a token added there is covered here the same day. * * @since 2.1.0 * * @return string[] The source-type tokens valid for text. */ public static function types() { return AICL_Plugin::ai_types(); } /** * Returns true when the token is one this class accepts for text. * * @since 2.1.0 * * @param string $token The source-type token to test. * @return bool True when the token describes AI involvement in text. */ public static function is_valid_type( $token ) { return is_string( $token ) && in_array( $token, self::types(), true ); } /** * Returns the post types that carry a text-disclosure control. * * Posts and pages by default. Filterable because "published to inform the public" is a * description of intent, not of post type, and a site may well do its reporting in a * custom type. * * @since 2.1.0 * * @return string[] Post type names. */ public static function post_types() { /** * Filters the post types offered a text-disclosure control. * * @since 2.1.0 * * @param string[] $post_types Post type names. */ return (array) apply_filters( 'aicl_text_post_types', array( 'post', 'page' ) ); } /** * Registers the four meta keys against every eligible post type. * * `show_in_rest` is what lets the block editor read and write these through the entity * store, so the panel needs no endpoint of its own and the values save with the post * like any other field. * * @since 2.1.0 * * @return void */ public static function register_meta() { $auth = function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }; $keys = array( self::META_TYPE => array( 'string', array( __CLASS__, 'sanitize_type' ) ), self::META_REVIEWED => array( 'boolean', 'rest_sanitize_boolean' ), self::META_EDITOR => array( 'string', 'sanitize_text_field' ), self::META_REVIEWED_ON => array( 'string', array( __CLASS__, 'sanitize_date' ) ), ); foreach ( self::post_types() as $post_type ) { foreach ( $keys as $key => $spec ) { register_post_meta( $post_type, $key, array( 'type' => $spec[0], 'single' => true, 'show_in_rest' => true, 'sanitize_callback' => $spec[1], 'auth_callback' => $auth, ) ); } } } /** * Reduces any input to a token this class accepts, or to an empty string. * * @since 2.1.0 * * @param string $value The submitted source-type token. * @return string A valid text source type, or '' when the input is not one. */ public static function sanitize_type( $value ) { $value = sanitize_text_field( (string) $value ); return self::is_valid_type( $value ) ? $value : ''; } /** * Reduces any input to a Y-m-d date, or to an empty string. * * @since 2.1.0 * * @param string $value The submitted date. * @return string A Y-m-d date, or '' when the input is not one. */ public static function sanitize_date( $value ) { $value = sanitize_text_field( (string) $value ); return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ? $value : ''; } /** * Returns the recorded state of a post's text. * * A translated post that carries no record of its own inherits the one from the post it was * translated from. The wpml-config.xml copy rules only run at the moment a translation is * created, so a post translated before its text was marked, or a site with field sync switched * off, would otherwise disclose nothing in that language while disclosing correctly in the * original. Images have had this fallback since 2.1.0; text did not, and the failure it prevents * is the same one. * * The whole record is taken from the source, never a field at a time. Reading the source's type * beside the translation's own review flag would describe a post that does not exist, and the * human-review exemption is exactly the claim that must not be assembled from two places. * * @since 2.1.0 * * @param int $post_id The post to read. * @return array{type:string,reviewed:bool,editor:string,reviewed_on:string} The stored values. */ public static function record( $post_id ) { $post_id = (int) $post_id; $type = (string) get_post_meta( $post_id, self::META_TYPE, true ); if ( ! self::is_valid_type( $type ) ) { $origin = AICL_Plugin::origin_post( $post_id ); if ( $origin ) { return self::stored_record( $origin ); } } return self::stored_record( $post_id ); } /** * Reads one post's own stored text record, with no translation fallback. * * @since 2.1.5 * * @param int $post_id The post to read. * @return array{type:string,reviewed:bool,editor:string,reviewed_on:string} The stored values. */ protected static function stored_record( $post_id ) { $post_id = (int) $post_id; $type = (string) get_post_meta( $post_id, self::META_TYPE, true ); return array( 'type' => self::is_valid_type( $type ) ? $type : '', 'reviewed' => (bool) get_post_meta( $post_id, self::META_REVIEWED, true ), 'editor' => trim( (string) get_post_meta( $post_id, self::META_EDITOR, true ) ), 'reviewed_on' => (string) get_post_meta( $post_id, self::META_REVIEWED_ON, true ), ); } /** * Returns true when the site has said it publishes on matters of public interest. * * Stored as '' until answered, so "not yet asked" stays distinguishable from "no". The * readiness view needs that difference; a compliance tool that cannot tell an unanswered * question from a negative answer is guessing. * * @since 2.1.0 * * @return bool True when the Article 50(4) text duty can apply to this site. */ public static function in_public_interest() { return '1' === (string) AICL_Plugin::opt( 'public_interest' ); } /** * Works out where a post stands under Article 50(4). * * The order matters and mirrors the Regulation. The duty attaches only to AI-generated or * AI-manipulated text published to inform the public on matters of public interest. It is * then lifted where the content has had human review or editorial control *and* a natural * or legal person holds editorial responsibility. Both limbs, not either: a post whose * author ticked "reviewed" and named nobody has met half a test, and half a test is not an * exemption, so it still discloses. * * @since 2.1.0 * * @param int $post_id The post to assess. * @return string One of 'none', 'recorded', 'discloses', 'incomplete' or 'exempt'. */ public static function status( $post_id ) { $record = self::record( $post_id ); if ( '' === $record['type'] ) { return 'none'; } if ( ! self::in_public_interest() ) { return 'recorded'; } if ( ! $record['reviewed'] ) { return 'discloses'; } return '' === $record['editor'] ? 'incomplete' : 'exempt'; } /** * Returns true when Article 50(4) *obliges* a disclosure on this text. * * The legal question only. Whether a line actually appears on the page is * `discloses_on_page()`, which is a broader question, because an owner may disclose * without being made to. * * @since 2.1.0 * * @param int $post_id The post to assess. * @return bool True when Article 50(4) requires a disclosure on this text. */ public static function requires_disclosure( $post_id ) { return in_array( self::status( $post_id ), array( 'discloses', 'incomplete' ), true ); } /** * Returns true when a disclosure line should be printed for this post. * * Deliberately wider than the legal duty. Marking a post as AI-written is an instruction, * not a request for an assessment, and a plugin named for transparency should not swallow * it because the Regulation happens not to compel it on this site. The law sets a floor. * * So: anything marked is disclosed while the setting is on, and the one case that stays * silent is the post the law itself excuses, where a person reviewed the text and someone * holds editorial responsibility for it. `public_interest` still decides whether any of * this is an *obligation*, which is what the report and the Overview speak to. * * @since 2.1.0 * * @param int $post_id The post to assess. * @return bool True when the visible line belongs on this post. */ public static function discloses_on_page( $post_id ) { if ( ! AICL_Plugin::opt( 'text_disclosure' ) ) { return false; } return in_array( self::status( $post_id ), array( 'discloses', 'incomplete', 'recorded' ), true ); } /** * Returns the sentence shown to the visitor. * * "Generated" and "manipulated" are the Regulation's own two verbs, and they are not * interchangeable: text written by a machine and text a machine rewrote are different * claims. The token already records which, so the sentence should say which. * * @since 2.1.0 * * @param int $post_id The post being disclosed. * @return string The disclosure sentence, already translated. */ public static function disclosure_text( $post_id ) { $custom = trim( (string) AICL_Plugin::opt( 'text_disclosure_text' ) ); if ( '' !== $custom ) { return $custom; } $record = self::record( $post_id ); if ( 'compositeWithTrainedAlgorithmicMedia' === $record['type'] ) { return __( 'This text was artificially manipulated using AI.', 'aim-transparency' ); } return __( 'This text was artificially generated using AI.', 'aim-transparency' ); } /** * Adds the disclosure to the post body on the front end. * * Singular main-query views only. An archive or a related-posts widget runs `the_content` * too, and a disclosure repeated down a listing page stops reading as a disclosure and * starts reading as furniture. Article 50(5) asks for it at first exposure, clearly and * distinguishably, which is the single post. * * A line the owner placed with the shortcode wins. The shortcode runs while `the_content` * is being filtered and this filter sits at 998, so an explicit placement has already * registered by the time we get here and a second copy would be noise. * * @since 2.1.0 * * @param string $content The post content. * @return string The content, with the disclosure prepended, appended or both. */ public static function disclose( $content ) { if ( is_admin() || ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } if ( ! AICL_Plugin::opt( 'text_disclosure' ) ) { return $content; } $post_id = get_the_ID(); if ( ! $post_id || ! self::discloses_on_page( $post_id ) ) { return $content; } if ( ! empty( self::$placed[ $post_id ] ) ) { return $content; } $notice = self::markup( $post_id ); $position = (string) AICL_Plugin::opt( 'text_disclosure_position' ); if ( 'below' === $position ) { return $content . $notice; } if ( 'both' === $position ) { return $notice . $content . $notice; } return $notice . $content; } /** * Posts whose disclosure has already been printed by the shortcode this request. * * @since 2.1.0 * @var array */ protected static $placed = array(); /** * Renders the disclosure wherever the owner puts `[aicl_text_disclosure]`. * * For a theme or a layout builder that wants the line somewhere the automatic position * cannot reach, under the byline rather than above the body, say. Placing it by hand * suppresses the automatic copy for that post, so the two cannot both appear. * * It obeys the same rules as the automatic line and prints nothing when no disclosure is * due. A shortcode that rendered regardless would let a page state that its text was * AI-written when the record says otherwise, which is the one thing this plugin must not do. * * @since 2.1.0 * * @param array $atts Shortcode attributes. `id` targets a specific post. * @return string The disclosure markup, or an empty string. */ public static function shortcode( $atts ) { $atts = shortcode_atts( array( 'id' => 0 ), $atts, 'aicl_text_disclosure' ); $post_id = (int) $atts['id']; if ( ! $post_id ) { $post_id = (int) get_the_ID(); } if ( ! $post_id ) { return ''; } self::$placed[ $post_id ] = true; if ( ! self::discloses_on_page( $post_id ) ) { return ''; } return self::markup( $post_id ); } /** * Builds the disclosure markup for a post. * * Custom colours ride on the element as CSS custom properties rather than through a * stylesheet the plugin has to print in the head. The disclosure is already part of the * content, so keeping its one variable piece with it means no second enqueue, nothing to * invalidate, and a theme override that only has to beat one selector. * * Custom mark colours are held to a 4.5:1 contrast floor and fall back to the inherited * outline when they miss it. An unreadable chip on a disclosure is worse than a plain one, * because it still looks like the site disclosed something. * * Corner radii are presets rather than free numbers: three choices a person can picture beat a * pixel field they have to guess at, and they cannot produce a shape that looks broken. * * @since 2.1.0 * * @param int $post_id The post being disclosed. * @return string The disclosure paragraph. */ public static function markup( $post_id ) { $style = (string) AICL_Plugin::opt( 'text_disclosure_style' ); $classes = 'aicl-text-disclosure aicl-text-disclosure--' . sanitize_html_class( $style ); $scale = array( 0 => '0', 1 => '4px', 2 => '999px', ); $marks = array( 0 => '0', 1 => '3px', 2 => '999px', ); $radius = (int) AICL_Plugin::opt( 'text_disclosure_radius' ); $mark_radius = (int) AICL_Plugin::opt( 'text_disclosure_mark_radius' ); $vars = array( '--aicl-td-radius:' . ( isset( $scale[ $radius ] ) ? $scale[ $radius ] : '4px' ), '--aicl-td-mark-radius:' . ( isset( $marks[ $mark_radius ] ) ? $marks[ $mark_radius ] : '3px' ), ); if ( 'custom' === $style ) { $background = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_bg' ) ); $foreground = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_color' ) ); if ( $background && $foreground ) { $vars[] = '--aicl-td-bg:' . $background; $vars[] = '--aicl-td-fg:' . $foreground; } } $mark = ''; if ( AICL_Plugin::opt( 'text_disclosure_mark' ) ) { $label = trim( (string) AICL_Plugin::opt( 'text_disclosure_mark_text' ) ); if ( '' === $label ) { $label = __( 'AI', 'aim-transparency' ); } $mark_style = (string) AICL_Plugin::opt( 'text_disclosure_mark_style' ); $mark_classes = 'aicl-text-disclosure__mark aicl-text-disclosure__mark--' . sanitize_html_class( $mark_style ); if ( 'custom' === $mark_style ) { $mark_bg = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_mark_bg' ) ); $mark_fg = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_mark_color' ) ); if ( $mark_bg && $mark_fg && AICL_Notice::contrast( $mark_bg, $mark_fg ) >= 4.5 ) { $vars[] = '--aicl-td-mark-bg:' . $mark_bg; $vars[] = '--aicl-td-mark-fg:' . $mark_fg; } else { $mark_classes = 'aicl-text-disclosure__mark aicl-text-disclosure__mark--outline'; } } if ( 2 === $mark_radius ) { $mark_classes .= ' aicl-text-disclosure__mark--round'; } $mark = sprintf( '', esc_attr( $mark_classes ), esc_html( $label ) ); } /* Built last, because the mark may have added its own colours to $vars above. */ $inline = ' style="' . esc_attr( implode( ';', $vars ) ) . '"'; return sprintf( '

%3$s%4$s

', esc_attr( $classes ), $inline, // Already escaped above; built only from sanitize_hex_color output. $mark, // Already escaped above. esc_html( self::disclosure_text( $post_id ) ) ); } /** * Returns the schema.org node describing this post's text, or an empty array. * * Same shape as the ImageObject nodes the front end already emits, because * `digitalSourceType` is defined for CreativeWork generally and was never image-only. * A machine reading the page should not have to learn a second convention. * * @since 2.1.0 * * @param int $post_id The post to describe. * @return array The JSON-LD node, or an empty array when there is nothing to declare. */ public static function jsonld_node( $post_id ) { if ( ! self::discloses_on_page( $post_id ) ) { return array(); } $record = self::record( $post_id ); return array( '@type' => 'Article', 'url' => get_permalink( $post_id ), 'headline' => get_the_title( $post_id ), 'creditText' => AICL_Plugin::label_for( $record['type'] ), 'additionalProperty' => array( '@type' => 'PropertyValue', 'propertyID' => 'IPTC:DigitalSourceType', 'value' => AICL_Plugin::uri_for( $record['type'] ), ), ); } /** * Registers the classic-editor meta box. * * This cannot ride along on the featured-image control the way the image flag does. * That one hangs off `admin_post_thumbnail_html`, so it only exists when a featured * image is set, and text provenance has to be recordable on a post with no image. * * Flagged as a back-compat box so the block editor hides it. The block panel in text-block.js is * the same control, and without the flag both render and a post shows two "AI content disclosure" * panels. Classic still gets this one, since nothing else draws it there. * * @since 2.1.0 * * @return void */ public static function add_meta_box() { foreach ( self::post_types() as $post_type ) { add_meta_box( 'aicl-text-disclosure', __( 'AI content disclosure', 'aim-transparency' ), array( __CLASS__, 'render_meta_box' ), $post_type, 'side', 'default', array( '__back_compat_meta_box' => true ) ); } } /** * Renders the classic-editor meta box. * * @since 2.1.0 * * @param WP_Post $post The post being edited. * @return void */ public static function render_meta_box( $post ) { $record = self::record( $post->ID ); $scope = self::in_public_interest(); wp_nonce_field( 'aicl_text_save', 'aicl_text_nonce' ); echo '

'; echo ''; if ( ! $scope ) { echo '

' . esc_html__( 'Article 50(4) does not require this, because the site has not said it publishes on matters of public interest. The disclosure still shows, because you asked for it.', 'aim-transparency' ) . '

'; return; } echo '

'; echo '

'; echo '

'; echo '

' . esc_html__( 'Required for the exemption. Review alone is half the test.', 'aim-transparency' ) . '

'; } /** * Saves the classic-editor meta box. * * The review date is stamped here rather than collected, because the useful date is when * the claim was made, not one the author can type. * * @since 2.1.0 * * @param int $post_id The post being saved. * @param WP_Post $post The post object. * @return void */ public static function save_meta_box( $post_id, $post ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( ! in_array( $post->post_type, self::post_types(), true ) ) { return; } if ( ! isset( $_POST['aicl_text_nonce'] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_POST['aicl_text_nonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'aicl_text_save' ) || ! current_user_can( 'edit_post', $post_id ) ) { return; } $type = isset( $_POST['aicl_text_type'] ) ? self::sanitize_type( sanitize_text_field( wp_unslash( $_POST['aicl_text_type'] ) ) ) : ''; if ( '' === $type ) { delete_post_meta( $post_id, self::META_TYPE ); delete_post_meta( $post_id, self::META_REVIEWED ); delete_post_meta( $post_id, self::META_EDITOR ); delete_post_meta( $post_id, self::META_REVIEWED_ON ); return; } update_post_meta( $post_id, self::META_TYPE, $type ); $reviewed = isset( $_POST['aicl_text_reviewed'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['aicl_text_reviewed'] ) ); $editor = isset( $_POST['aicl_text_editor'] ) ? sanitize_text_field( wp_unslash( $_POST['aicl_text_editor'] ) ) : ''; /* The review date follows from this write. sync_review_date() handles it for every path. */ update_post_meta( $post_id, self::META_REVIEWED, $reviewed ? 1 : 0 ); update_post_meta( $post_id, self::META_EDITOR, $reviewed ? $editor : '' ); } /** * Enqueues the block-editor panel and hands it the same vocabulary the classic box uses. * * Option help is one plain line each, in the words an author would use rather than the * Regulation's, and travels through wp_localize_script so it resolves from the .mo rather * than the JED. * * The inline style exists because the editor's own TextControl applies text-transform to * every label it renders. The editorial-responsibility label is a full sentence, not the * two-word field name that treatment suits, so the override is scoped to this panel. * * @since 2.1.0 * * @return void */ public static function block_assets() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! in_array( $screen->post_type, self::post_types(), true ) ) { return; } wp_enqueue_script( 'aicl-text-block', AICL_URL . 'assets/js/text-block.js', array( 'wp-plugins', 'wp-element', 'wp-data', 'wp-components', 'wp-i18n', 'wp-editor' ), AICL_VERSION, true ); $help = array( 'trainedAlgorithmicMedia' => __( 'Written by AI', 'aim-transparency' ), 'compositeWithTrainedAlgorithmicMedia' => __( 'You wrote it, AI rewrote it', 'aim-transparency' ), 'basicAI' => __( 'AI involved, unspecified', 'aim-transparency' ), ); $options = array(); foreach ( self::types() as $token ) { $options[] = array( 'value' => $token, 'label' => AICL_Plugin::label_for( $token ), 'desc' => isset( $help[ $token ] ) ? $help[ $token ] : '', ); } wp_localize_script( 'aicl-text-block', 'AICL_TEXT', array( 'options' => $options, 'publicInterest' => self::in_public_interest() ? 1 : 0, 'metaType' => self::META_TYPE, 'metaReviewed' => self::META_REVIEWED, 'metaEditor' => self::META_EDITOR, 'noneDesc' => __( 'No AI in the text', 'aim-transparency' ), 'noneLabel' => __( 'Not flagged', 'aim-transparency' ), ) ); wp_set_script_translations( 'aicl-text-block', 'aim-transparency', AICL_DIR . 'languages' ); wp_register_style( 'aicl-text-block', false, array(), AICL_VERSION ); wp_enqueue_style( 'aicl-text-block' ); wp_add_inline_style( 'aicl-text-block', '.aicl-editor-field .components-base-control__label{text-transform:none;letter-spacing:normal;font-size:12px;}' // Gutenberg ships these controls with no bottom margin, so the review // checkbox and the editor field collided: the checkbox label wrapped // straight into "Who holds editorial responsibility?". . '.aicl-text-panel .components-checkbox-control{margin-bottom:14px;}' . '.aicl-text-panel .components-checkbox-control__label{line-height:1.4;}' . '.aicl-editor-field{margin-top:4px;}' . '.aicl-editor-field .components-base-control__help{margin-top:6px;line-height:1.5;}' ); } }