Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions admin/modules/settings/includes/class-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -351,6 +357,7 @@ public static function get_excludes() {
'banner_title_tag',
'preference_title_tag',
'category_title_tag',
'mobile_layout',
'sites',
'custom_rules',
'target_regions',
Expand Down Expand Up @@ -505,6 +512,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':
Expand Down
21 changes: 21 additions & 0 deletions admin/views/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,27 @@
</div>
</div>

<div class="faz-card">
<div class="faz-card-header">
<h3><?php esc_html_e( 'Mobile Layout', 'faz-cookie-manager' ); ?></h3>
</div>
<div class="faz-card-body">
<div class="faz-form-group">
<label for="faz-mobile-layout"><?php esc_html_e( 'Banner layout on phones', 'faz-cookie-manager' ); ?></label>
<select id="faz-mobile-layout" class="faz-select" data-path="banner_control.mobile_layout" aria-describedby="faz-mobile-layout-help" style="width:auto;max-width:280px;">
<option value="comfortable" selected><?php esc_html_e( 'Comfortable (default) — buttons stacked', 'faz-cookie-manager' ); ?></option>
<option value="compact"><?php esc_html_e( 'Compact — buttons on one row', 'faz-cookie-manager' ); ?></option>
</select>
<div class="faz-help" id="faz-mobile-layout-help">
<?php esc_html_e( 'Below 440px the comfortable layout gives each button its own full-width row, which makes the banner about 44% of a 390px screen. The compact layout puts them on a shared row and brings the same banner to about 29%. Desktop is not affected.', 'faz-cookie-manager' ); ?>
</div>
</div>
<p class="faz-help">
<?php esc_html_e( 'Compact keeps every button at least 44px tall so it stays comfortable to tap, and keeps the accept and reject buttons exactly the same size as each other, as equal prominence requires. On screens narrower than 360px accept and reject stay side by side and the customise button moves to its own row.', 'faz-cookie-manager' ); ?>
</p>
</div>
</div>

<div class="faz-card">
<div class="faz-card-header">
<h3><?php esc_html_e( 'Script Blocking', 'faz-cookie-manager' ); ?></h3>
Expand Down
135 changes: 133 additions & 2 deletions frontend/class-frontend.php
Original file line number Diff line number Diff line change
Expand Up @@ -6759,10 +6759,22 @@ 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.
$cache_key = 'faz_boosted_css_v4_' . FAZ_VERSION . '_' . $layout . '_' . md5( $raw_css );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
Expand Down Expand Up @@ -6826,12 +6838,131 @@ 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%).
*
* Three 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. `min-height: 44px` keeps the tap target at the size a finger needs.
* Shrinking the buttons to fit is exactly the wrong trade.
* 3. Under 360px three buttons no longer fit side by side. Accept and reject
* stay paired on their own row and "customise" - the longest label, and
* the one that takes no part in the equal-prominence comparison - drops
* to a row of its own. The pair that matters stays equal.
*
* Emitted AFTER boost_css_specificity(), so the selectors are written with
* their `#faz-consent` prefix already in place and need no `!important`:
* they are specificity 1-1-0 against the template's own 1-1-0 rules and win
* on document order. Measured against a live 1.32.0 site at 320, 360, 375,
* 390 and 430 CSS pixels.
*
* @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){'
. '#faz-consent .faz-notice-btn-wrapper{'
. 'flex-direction:row;flex-wrap:wrap;gap:8px;margin-top:12px;'
. '}'
. '#faz-consent .faz-notice-btn-wrapper .faz-btn{'
. 'flex:1 1 0;width:auto;min-width:0;margin:0;'
. 'min-height:44px;font-size:13px;padding:8px 4px;white-space:nowrap;'
. '}'
// Accept first, reject immediately beside it: the two compared
// options sit together and read as one pair.
. '#faz-consent .faz-notice-btn-wrapper .faz-btn-accept{order:1;margin-top:0;}'
. '#faz-consent .faz-notice-btn-wrapper .faz-btn-reject{order:2;}'
. '#faz-consent .faz-notice-btn-wrapper .faz-btn-customize{order:3;}'
// The Do-Not-Sell control is a flex child of this same wrapper, and it
// is the one button here that carries no `order` of its own — not in
// the template's mobile rules either, so at order:0 it already sorts
// ahead of Accept. Stacked full-width that is only odd; on a single
// row it breaks. It would have to share the first line with the
// accept/reject pair, and at 360px their 40% bases leave it almost
// nothing to grow into: it collapses to a few pixels while the
// inherited `white-space:nowrap` spills its label across Accept.
//
// It is not one of the compared options — EDPB 03/2022 equal
// prominence governs accept against reject — so it takes a full-width
// row of its own, last, where it cannot compete with the pair and
// cannot be squeezed. This is reached in normal operation, not only
// by hand: Geo_Runtime turns donotSell on for a US visitor even when
// applicableLaw is 'gdpr', and class-template.php then keeps the
// button precisely because its status is true.
//
// Matched by data attribute because the two variants share nothing
// else: the shortcode emits either `.faz-btn.faz-btn-do-not-sell` or a
// bare `<a>` with no class at all.
//
// `white-space` is reset because the nowrap above is aimed at the
// three short labels sharing a tight row, where a wrap would make one
// button two lines tall and break the pair's symmetry. This control
// has the row to itself and the longest label of the four — "Do Not
// Sell or Share My Personal Information" does not fit 270px at 13px,
// so inheriting nowrap made it overflow its own full-width box. The
// label is statutory wording that cannot be shortened, so the box has
// to give.
. '#faz-consent .faz-notice-btn-wrapper [data-faz-tag="donotsell-button"]{'
. 'order:4;flex:1 1 100%;width:100%;white-space:normal;'
. '}'
. '}'
. '@media (max-width:360px){'
// 40% rather than calc(50% - 4px): the exact value lands on the
// width budget to the pixel, and one sub-pixel of rounding pushes
// the second button onto its own row. A basis under half leaves
// flex-grow to fill the row and cannot round into a wrap.
. '#faz-consent .faz-notice-btn-wrapper .faz-btn-accept,'
. '#faz-consent .faz-notice-btn-wrapper .faz-btn-reject{flex:1 1 40%;}'
. '#faz-consent .faz-notice-btn-wrapper .faz-btn-customize{flex:1 1 100%;}'
Comment thread
pullfrog[bot] marked this conversation as resolved.
. '}';
}

/**
* Prefix all CSS selectors with #faz-consent to boost specificity above
* page-builder rules (Elementor, Divi, Beaver Builder).
Expand Down
Loading
Loading