diff --git a/admin/modules/settings/includes/class-settings.php b/admin/modules/settings/includes/class-settings.php index 4158814a..ece0ac15 100644 --- a/admin/modules/settings/includes/class-settings.php +++ b/admin/modules/settings/includes/class-settings.php @@ -131,6 +131,12 @@ public function get_defaults() { 'banner_title_tag' => 'h2', 'preference_title_tag' => 'h2', 'category_title_tag' => 'h3', + // Phone layout. 'comfortable' is the shipped behaviour: below + // 440px the notice buttons stack into one full-width row each. + // 'compact' lays them out on a shared row so the banner stops + // claiming ~44% of a 390px viewport. Default is the existing + // behaviour, so no installed site changes appearance on update. + 'mobile_layout' => 'comfortable', 'subdomain_sharing' => false, 'hide_from_bots' => true, 'gtm_datalayer' => false, @@ -359,11 +365,13 @@ public static function get_excludes() { 'banner_title_tag', 'preference_title_tag', 'category_title_tag', - // Same reason: a scalar whose default is a string. Without this - // entry an array-valued payload would recurse against the string - // default, store an empty array, and leave configured_path() casting - // an array to a string. The whitelist in sanitize_option() handles - // every shape and settles on 'widget'. + // Same reason, for two more scalars whose default is a string. + // Without an entry here an array-valued payload would recurse against + // the string default and store an empty array, which then reaches a + // reader expecting a string — configured_path() was casting an array. + // The whitelists in sanitize_option() handle every shape and settle on + // 'comfortable' for the layout and 'widget' for the withdrawal path. + 'mobile_layout', 'withdrawal_path', 'sites', 'custom_rules', @@ -519,6 +527,17 @@ public static function sanitize_option( $option, $value, $group = '' ) { $value = is_string( $value ) ? strtolower( trim( $value ) ) : ''; $value = in_array( $value, $allowed, true ) ? $value : ( 'category_title_tag' === $option ? 'h3' : 'h2' ); break; + case 'mobile_layout': + // Whitelist, never a pass-through. The value never reaches + // markup — it selects which stylesheet compact_mobile_css() + // returns and forms part of the banner-template cache key — so + // the risk is not injection but an unrecognised string becoming + // a third layout that nothing defines. Collapsing anything + // unexpected to 'comfortable' keeps the stored value inside the + // two layouts that exist. + $value = is_string( $value ) ? strtolower( trim( $value ) ) : ''; + $value = in_array( $value, array( 'comfortable', 'compact' ), true ) ? $value : 'comfortable'; + break; case 'status': case 'subdomain_sharing': case 'uet_consent_mode': diff --git a/admin/views/settings.php b/admin/views/settings.php index 0bea4dd1..42e877ae 100644 --- a/admin/views/settings.php +++ b/admin/views/settings.php @@ -285,6 +285,27 @@ +
+
+

+
+
+
+ + +
+ +
+
+

+ +

+
+
+

diff --git a/frontend/class-frontend.php b/frontend/class-frontend.php index 9ba8d30b..5cde5b4a 100755 --- a/frontend/class-frontend.php +++ b/frontend/class-frontend.php @@ -6770,10 +6770,24 @@ private function get_boosted_css() { */ public static function prepare_banner_styles( $raw_css ) { $raw_css = is_string( $raw_css ) ? $raw_css : ''; + $layout = self::get_mobile_layout(); // Keep an explicit pipeline revision in addition to FAZ_VERSION so a // development deploy cannot reuse CSS assembled before utility rules were // added. Release builds also invalidate through the version as usual. - $cache_key = 'faz_boosted_css_v2_' . FAZ_VERSION . '_' . md5( $raw_css ); + // + // The mobile layout is part of the key because it changes the assembled + // CSS without changing $raw_css: keyed on the template alone, toggling + // the setting would keep serving the previously cached stylesheet for a + // day and the setting would look broken. It is a site-wide value, not a + // per-visitor one, so it stays safe under Cache Compatibility Mode. + // v4: the compact layout gained a full-width row for the Do-Not-Sell + // control. That rule is appended after $raw_css is hashed, so without + // this bump an install that had already cached v3 within this same + // plugin version would keep serving the stylesheet in which the control + // collapses — the revision is here for exactly this case. + // v5: the compact labels wrap instead of clipping, the buttons follow + // DOM order and the classic chevron keeps its padding. Same reason. + $cache_key = 'faz_boosted_css_v5_' . FAZ_VERSION . '_' . $layout . '_' . md5( $raw_css ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; @@ -6837,12 +6851,168 @@ public static function prepare_banner_styles( $raw_css ) { . '.faz-cookie-settings-btn:focus-visible{' . 'outline:2px solid var(--faz-accept-button-background-color,#1863dc);outline-offset:2px;' . '}'; - $css = $css_reset . $css . $css_fixes . $css_settings_btn; + $css = $css_reset . $css . $css_fixes . $css_settings_btn . self::compact_mobile_css( $layout ); set_transient( $cache_key, $css, DAY_IN_SECONDS ); return $css; } + /** + * Current phone layout for the consent notice. + * + * Static because prepare_banner_styles() is reached both from the frontend + * render and from the banner REST endpoint, neither of which shares an + * instance. + * + * @since 1.34.0 + * @return string Either 'comfortable' or 'compact'; never anything else. + */ + private static function get_mobile_layout() { + $settings = get_option( 'faz_settings', array() ); + $value = ''; + if ( is_array( $settings ) && isset( $settings['banner_control']['mobile_layout'] ) ) { + $value = $settings['banner_control']['mobile_layout']; + } + $value = is_string( $value ) ? strtolower( trim( $value ) ) : ''; + + // Re-check here rather than trusting the stored value. The sanitiser + // already whitelists it on save, but this string decides which CSS is + // emitted, and a row written before the sanitiser existed - or by hand - + // must not reach the stylesheet. + return 'compact' === $value ? 'compact' : 'comfortable'; + } + + /** + * Compact phone layout for the notice buttons. + * + * Below 440px the shipped templates give each notice button its own + * full-width row, which on a 390x844 screen makes the notice 372px tall - + * about 44% of the viewport, and past half of it on a 375x667 phone. Laying + * the buttons out on a shared row brings the same notice to 244px (29%). + * + * Five constraints shape the rules below, and none of them is cosmetic: + * + * 1. `flex: 1 1 0` makes the accept and reject buttons exactly as wide as + * each other because the same layout pass sizes them, not because two + * widths were written to match. EDPB Guidelines 03/2022 require the + * accept and reject options to carry equal prominence, so equality has to + * survive translation into any language - which hardcoded widths do not. + * 2. Labels wrap instead of being cut. Three buttons on a 361-440px row get + * about 100px each, and translated labels do not fit that on one line + * ("Az összes elfogadása", "Alles accepteren"). With `nowrap` the accept + * label was clipped while the shorter reject label was not - two boxes of + * equal size that no longer looked equal. Wrapping keeps every word + * visible, and `align-items: stretch` on the row gives every button the + * height of the tallest one, so a two-line accept never stands taller + * than a one-line reject. + * 3. `min-height: 44px` keeps the tap target at the size a finger needs. + * Shrinking the buttons to fit is exactly the wrong trade. + * 4. Visual order is DOM order. Every template writes the buttons as + * [customise][reject][accept], and the template's own phone rules + * reorder them with `order`, which moves the boxes but not the Tab + * sequence: on a single row that made keyboard focus run right to left + * (WCAG 2.4.3). Stacked one per row the mismatch is only odd; side by side + * it is backwards. Rewriting the DOM would change every other layout, so + * compact resets `order` instead and lets the markup decide. Accept and + * reject stay adjacent either way, which is what keeps them reading as + * one pair of equal options. + * 5. Under 360px three buttons no longer fit side by side. Accept and reject + * stay paired on their own row and "customise" - the one control that + * takes no part in the equal-prominence comparison - takes a row of its + * own. Since it comes first in the markup, that row sits above the pair. + * + * Emitted AFTER boost_css_specificity(), so the selectors are written with + * their `#faz-consent` prefix already in place and need no `!important`: + * `#faz-consent .faz-notice-btn-wrapper .faz-btn` is specificity 1-2-0, equal + * to the template's boosted `.faz-notice-btn-wrapper .faz-btn-*` phone rules + * and above its 1-1-0 ones, and wins the ties on document order. The RTL and + * classic rules below carry the extra class those template rules carry. + * + * @since 1.34.0 + * @param string $layout Resolved layout, already whitelisted. + * @return string CSS, or an empty string for the default layout. + */ + private static function compact_mobile_css( $layout ) { + if ( 'compact' !== $layout ) { + return ''; + } + + return '@media (max-width:440px){' + // `stretch`, not the templates' `center`: every button in a row takes + // the height of the tallest, so a label that wraps to two lines makes + // its partner two lines tall as well instead of standing out. + . '#faz-consent .faz-notice-btn-wrapper{' + . 'flex-direction:row;flex-wrap:wrap;align-items:stretch;gap:8px;margin-top:12px;' + . '}' + // `order:0` hands the sequence back to the markup (constraint 4). It + // also overrides the `.faz-btn-accept{margin-top:16px}` the box and + // banner templates give the button they used to sort first. + // + // inline-flex centres a wrapped label vertically in a stretched box + // for both variants the shortcode can emit: a