| @@ -22,8 +22,26 @@ | ||
| 22 | 22 | */ |
| 23 | 23 | class AtomicStyles { |
| 24 | 24 | |
| 25 | 25 | /** |
| 26 | + * Revision of the CSS this compiler emits. Bump it whenever the emitted | |
| 27 | + * output changes for the same stored styles. | |
| 28 | + * | |
| 29 | + * Front-end pages do not compile per request: each page is baked once | |
| 30 | + * into an uploads stylesheet, and Assets::build_revision() decides when | |
| 31 | + * that file is stale. It was keyed on ABLOCKS_VERSION alone, so an | |
| 32 | + * emission change with no version bump never reached pages baked before | |
| 33 | + * it. The centring-margin fix (emitted_value() / flex_child_css()) showed | |
| 34 | + * up in the editor, which compiles live, while every existing page kept | |
| 35 | + * serving the old `margin-left:auto` and its wide flex-row gap. | |
| 36 | + * | |
| 37 | + * 2: centring margins resolved per parent layout. | |
| 38 | + * 3: a bucket that sets a border width/colour but no type keeps the type it | |
| 39 | + * inherits instead of `solid`. | |
| 40 | + */ | |
| 41 | + const OUTPUT_REVISION = 3; | |
| 42 | + | |
| 43 | + /** | |
| 26 | 44 | * Compile a full bucket tree for one selector base into CSS. |
| 27 | 45 | * |
| 28 | 46 | * The stored state key IS the pseudo-selector, so it is appended directly; |
| 29 | 47 | * the breakpoint comes from the bucket's device via the one media-query |
| @@ -52,11 +70,14 @@ | ||
| 52 | 70 | $rules = []; |
| 53 | 71 | $devices = Helper::get_responsive_devices(); |
| 54 | 72 | $has_styles = StyleBuckets::has_schema_version( $styles ); |
| 55 | 73 | |
| 56 | - foreach ( $devices as $device ) { | |
| 74 | + $devices = array_values( $devices ); | |
| 75 | + | |
| 76 | + foreach ( $devices as $index => $device ) { | |
| 57 | 77 | $media = Helper::breakpoint_media_query( $device ); |
| 58 | 78 | $bucket_key = StyleBuckets::device_bucket_key( $device ); |
| 79 | + $cascade = self::cascade_devices( $devices, $index ); | |
| 59 | 80 | |
| 60 | 81 | foreach ( StyleBuckets::state_keys() as $state ) { |
| 61 | 82 | $declarations = []; |
| 62 | 83 | |
| @@ -62,10 +83,14 @@ | ||
| 62 | 83 | |
| 63 | 84 | if ( $has_styles ) { |
| 64 | 85 | $props = StyleBuckets::read_bucket( $styles, $bucket_key, $state ); |
| 65 | 86 | if ( ! empty( $props ) ) { |
| 66 | - $declarations = self::apply_background_reset( | |
| 67 | - self::state_declarations( $props ), | |
| 87 | + // The border type in force from the rest of the cascade, | |
| 88 | + // so a bucket that only changes width/colour keeps it | |
| 89 | + // rather than falling back to `solid`. | |
| 90 | + $inherited_style = StyleBuckets::inherited_prop( $styles, $bucket_key, $state, 'borderStyle', $cascade ); | |
| 91 | + $declarations = self::apply_background_reset( | |
| 92 | + self::state_declarations( $props, is_scalar( $inherited_style ) ? (string) $inherited_style : '' ), | |
| 68 | 93 | '' === $state && '' === $bucket_key |
| 69 | 94 | ); |
| 70 | 95 | } |
| 71 | 96 | } |
| @@ -100,8 +125,35 @@ | ||
| 100 | 125 | return $rules; |
| 101 | 126 | } |
| 102 | 127 | |
| 103 | 128 | /** |
| 129 | + * The devices a device's rules cascade from on the page: those that also | |
| 130 | + * apply at its width and are emitted before it, widest-first, ending at the | |
| 131 | + * device itself. Mirror of the JS `cascadeDevices()` (atomic-shared/hash.js), | |
| 132 | + * which builds it from `devicesApplyingAt( representativeWidth() )`. | |
| 133 | + * | |
| 134 | + * @param array $devices Ordered device list (widest-first, base first). | |
| 135 | + * @param int $index The device's position in it. | |
| 136 | + * @return array Devices to inherit from. | |
| 137 | + */ | |
| 138 | + private static function cascade_devices( $devices, $index ) { | |
| 139 | + $own = (array) $devices[ $index ]; | |
| 140 | + // A device's representative width: its upper bound, else its lower | |
| 141 | + // bound, else "very wide" for the base device. | |
| 142 | + $width = ! empty( $own['max'] ) ? (int) $own['max'] : ( ! empty( $own['min'] ) ? (int) $own['min'] : 99999 ); | |
| 143 | + | |
| 144 | + $out = []; | |
| 145 | + foreach ( array_slice( $devices, 0, $index + 1 ) as $device ) { | |
| 146 | + list( $min, $max ) = Helper::breakpoint_bounds( $device ); | |
| 147 | + if ( ( $min > 0 && $width < $min ) || ( $max > 0 && $width > $max ) ) { | |
| 148 | + continue; | |
| 149 | + } | |
| 150 | + $out[] = $device; | |
| 151 | + } | |
| 152 | + return $out; | |
| 153 | + } | |
| 154 | + | |
| 155 | + /** | |
| 104 | 156 | * FNV-1a 32-bit over the normalised rule list. |
| 105 | 157 | * |
| 106 | 158 | * Deliberately not md5: the editor has to compute the identical hash at save |
| 107 | 159 | * time, and FNV-1a is a handful of lines in both languages rather than a |
| @@ -159,8 +211,45 @@ | ||
| 159 | 211 | |
| 160 | 212 | return [ 'class' => $class, 'css' => self::rules_to_css( $rules, '.' . $class . '.' . $class ) ]; |
| 161 | 213 | } |
| 162 | 214 | |
| 215 | + /** | |
| 216 | + * Point a block's saved markup at its current style class. | |
| 217 | + * | |
| 218 | + * The editor stamps `ablocks-s-{hash}` into the markup at save time, but the | |
| 219 | + * front end emits rules for the hash of what the compiler produces NOW. When | |
| 220 | + * the compiled output changes for the same stored styles (OUTPUT_REVISION), | |
| 221 | + * a post saved before carries a class no rule targets any more and the | |
| 222 | + * block renders unstyled until someone re-saves it. Only the block's own | |
| 223 | + * opening tag is touched — inner blocks are rendered, and fixed, on their | |
| 224 | + * own — and only when it lacks the current class. | |
| 225 | + * | |
| 226 | + * @param string $content The block's saved markup. | |
| 227 | + * @param array $styles The block's styles attribute. | |
| 228 | + * @param array $alignment The block's alignment attribute. | |
| 229 | + * @return string The markup, with a stale style class replaced. | |
| 230 | + */ | |
| 231 | + public static function refresh_style_class( $content, $styles, $alignment = [] ) { | |
| 232 | + if ( ! is_string( $content ) || false === strpos( $content, 'ablocks-s-' ) ) { | |
| 233 | + return $content; | |
| 234 | + } | |
| 235 | + if ( ! preg_match( '/^\s*<[a-zA-Z][^>]*>/', $content, $tag ) ) { | |
| 236 | + return $content; | |
| 237 | + } | |
| 238 | + $open = $tag[0]; | |
| 239 | + if ( ! preg_match( '/(?<=[\s"\'])ablocks-s-[0-9a-z]+(?=[\s"\'])/', $open, $stale ) ) { | |
| 240 | + return $content; | |
| 241 | + } | |
| 242 | + | |
| 243 | + $current = self::style_class( $styles, $alignment ); | |
| 244 | + if ( '' === $current || preg_match( '/(?<=[\s"\'])' . preg_quote( $current, '/' ) . '(?=[\s"\'])/', $open ) ) { | |
| 245 | + return $content; | |
| 246 | + } | |
| 247 | + | |
| 248 | + $fixed = preg_replace( '/(?<=[\s"\'])' . preg_quote( $stale[0], '/' ) . '(?=[\s"\'])/', $current, $open, 1 ); | |
| 249 | + return substr_replace( $content, $fixed, strpos( $content, $open ), strlen( $open ) ); | |
| 250 | + } | |
| 251 | + | |
| 163 | 252 | /** Render a normalised rule list against a selector base. */ |
| 164 | 253 | public static function rules_to_css( $rules, $selector_base ) { |
| 165 | 254 | $css = ''; |
| 166 | 255 | foreach ( $rules as $rule ) { |
| @@ -171,16 +260,183 @@ | ||
| 171 | 260 | // — scalar, range, colour, typography, effect, overlay — passes |
| 172 | 261 | // through one guard on its way out. This runs after style_hash() |
| 173 | 262 | // has already read $rules, so the hash the editor writes into |
| 174 | 263 | // the markup is unaffected. |
| 175 | - $declarations .= Helper::esc_css_value( $pair[0] ) . ':' . Helper::esc_css_value( $pair[1] ) . ';'; | |
| 264 | + $declarations .= Helper::esc_css_value( $pair[0] ) . ':' . Helper::esc_css_value( self::emitted_value( $pair[0], $pair[1] ) ) . ';'; | |
| 176 | 265 | } |
| 177 | 266 | $body = $selector_base . $state . '{' . $declarations . '}'; |
| 267 | + | |
| 268 | + // A wrapping container's own children must size from their content, | |
| 269 | + // or the line can never be over-subscribed and `flex-wrap` never | |
| 270 | + // breaks one. That is a statement about THIS container's children, | |
| 271 | + // so it is emitted as a child rule here rather than as an inherited | |
| 272 | + // custom property: a custom property inherits down the whole tree, | |
| 273 | + // so a wrapping container silently re-sized the children of every | |
| 274 | + // non-wrapping container nested inside it — measured, a | |
| 275 | + // non-wrapping inner container's children came out `flex-basis: | |
| 276 | + // auto` (content-sized) instead of `0%` (equal share). | |
| 277 | + // | |
| 278 | + // Derived from the pairs rather than stored, so it costs nothing in | |
| 279 | + // the bucket tree, and — because this runs after style_hash() has | |
| 280 | + // read $rules — the hash in already-saved markup is unaffected. | |
| 281 | + $body .= self::wrap_child_css( $pairs, $selector_base . $state ); | |
| 282 | + $body .= self::flex_child_css( $pairs, $selector_base . $state ); | |
| 283 | + | |
| 178 | 284 | $css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body; |
| 179 | 285 | } |
| 180 | 286 | return $css; |
| 181 | 287 | } |
| 182 | 288 | |
| 289 | + /** | |
| 290 | + * The value a declaration is emitted with, which is its compiled value | |
| 291 | + * except for the centring guard's auto margins (see state_declarations()). | |
| 292 | + * | |
| 293 | + * Those centre a width-capped box in normal flow, but in a flex row an | |
| 294 | + * auto margin swallows the free space on the main axis and overrides the | |
| 295 | + * parent's justify-content: two 180px children of a centred, 24px-gap row | |
| 296 | + * came out ~116px apart, the leftover space split into all four margins. | |
| 297 | + * Emitted through a custom property instead, which the parent resolves | |
| 298 | + * for its own children (flex_child_css()) and which falls back to the | |
| 299 | + * same `auto` everywhere else. An `auto` here can only be the guard's: the | |
| 300 | + * Dimensions control always appends a unit, and the guard stands down | |
| 301 | + * when the author set a horizontal margin. | |
| 302 | + * | |
| 303 | + * Rewritten at emission, after style_hash() has read the pairs, so the | |
| 304 | + * class in already-saved markup is unaffected. | |
| 305 | + * | |
| 306 | + * Mirrors emittedValue() in atomic-shared/styles.js. | |
| 307 | + * | |
| 308 | + * @param string $property The CSS property. | |
| 309 | + * @param string $value The compiled value. | |
| 310 | + * @return string The value to emit. | |
| 311 | + */ | |
| 312 | + public static function emitted_value( $property, $value ) { | |
| 313 | + if ( 'auto' === $value && ( 'margin-left' === $property || 'margin-right' === $property ) ) { | |
| 314 | + return 'var(--ablocks-center-margin,auto)'; | |
| 315 | + } | |
| 316 | + return $value; | |
| 317 | + } | |
| 318 | + | |
| 319 | + /** | |
| 320 | + * The child rule a bucket that sets `display` or `flex-direction` needs, | |
| 321 | + * or ''. Resolves --ablocks-center-margin (see emitted_value()) for this | |
| 322 | + * container's own children: 0 across a flex row's main axis, the `auto` | |
| 323 | + * fallback in a flex column (where horizontal is the cross axis and | |
| 324 | + * centring cannot open a gap) and in any other display. | |
| 325 | + * | |
| 326 | + * Display and direction are separate variables because they may come | |
| 327 | + * from different breakpoints — a row at Desktop turned into a column at | |
| 328 | + * Tablet only compiles `flex-direction` there — and the cascade has to | |
| 329 | + * combine them. The inner var() is substituted on the child itself, so | |
| 330 | + * it reads the direction set for that same child. | |
| 331 | + * | |
| 332 | + * Custom properties inherit, so the grandchildren are reset to the | |
| 333 | + * guaranteed-invalid value; `:where()` keeps that at 0-0-0, below any | |
| 334 | + * nested container's own child rule. Grid is left alone on purpose: an | |
| 335 | + * auto margin there centres an item in its own cell and opens no gap. | |
| 336 | + * | |
| 337 | + * Mirrors flexChildCss() in atomic-shared/styles.js. | |
| 338 | + * | |
| 339 | + * @param array $pairs The bucket's declaration pairs. | |
| 340 | + * @param string $selector The already-composed selector for this bucket. | |
| 341 | + * @return string A CSS rule, or ''. | |
| 342 | + */ | |
| 343 | + public static function flex_child_css( $pairs, $selector ) { | |
| 344 | + $declarations = ''; | |
| 345 | + foreach ( $pairs as $pair ) { | |
| 346 | + if ( 'display' === $pair[0] ) { | |
| 347 | + $declarations .= ( 'flex' === $pair[1] || 'inline-flex' === $pair[1] ) | |
| 348 | + ? '--ablocks-center-margin:var(--ablocks-column-margin,0);' | |
| 349 | + : '--ablocks-center-margin:initial;'; | |
| 350 | + } elseif ( 'flex-direction' === $pair[0] ) { | |
| 351 | + $declarations .= ( 'column' === $pair[1] || 'column-reverse' === $pair[1] ) | |
| 352 | + ? '--ablocks-column-margin:auto;' | |
| 353 | + : '--ablocks-column-margin:initial;'; | |
| 354 | + } | |
| 355 | + } | |
| 356 | + if ( '' === $declarations ) { | |
| 357 | + return ''; | |
| 358 | + } | |
| 359 | + return $selector . '>*{' . $declarations . '}' | |
| 360 | + . ':where(' . $selector . '>*>*){--ablocks-center-margin:initial;--ablocks-column-margin:initial;}'; | |
| 361 | + } | |
| 362 | + | |
| 363 | + /** | |
| 364 | + * The child rule a wrapping container needs, or ''. | |
| 365 | + * | |
| 366 | + * Scoped to container children only, matching the base stylesheets — a leaf | |
| 367 | + * block is sized by its own block, not by the row it sits in. `:where()` | |
| 368 | + * keeps the selector at the same specificity as those base rules, and this | |
| 369 | + * <style> is injected after them, so it wins on order alone. | |
| 370 | + * | |
| 371 | + * Mirrors wrapChildCss() in atomic-shared/styles.js. | |
| 372 | + * | |
| 373 | + * @param array $pairs The bucket's declaration pairs. | |
| 374 | + * @param string $selector The already-composed selector for this bucket. | |
| 375 | + * @return string A CSS rule, or ''. | |
| 376 | + */ | |
| 377 | + public static function wrap_child_css( $pairs, $selector ) { | |
| 378 | + $wraps = false; | |
| 379 | + foreach ( $pairs as $pair ) { | |
| 380 | + if ( 'flex-wrap' === $pair[0] | |
| 381 | + && ( 'wrap' === $pair[1] || 'wrap-reverse' === $pair[1] ) ) { | |
| 382 | + $wraps = true; | |
| 383 | + } | |
| 384 | + } | |
| 385 | + if ( ! $wraps ) { | |
| 386 | + return ''; | |
| 387 | + } | |
| 388 | + return $selector . self::WRAP_CHILD_SELECTOR . '{flex-basis:auto;}'; | |
| 389 | + } | |
| 390 | + | |
| 391 | + /** The child combinator both compilers append for a wrapping container. */ | |
| 392 | + const WRAP_CHILD_SELECTOR = '>:where(.ablocks-atomic-div,.ablocks-atomic-flex,.ablocks-atomic-grid)'; | |
| 393 | + | |
| 394 | + /** | |
| 395 | + * A block-specific rule emitted only for the buckets that compile a given | |
| 396 | + * CSS property — media query and state preserved. | |
| 397 | + * | |
| 398 | + * Lets one block react to a declaration the shared compiler produced | |
| 399 | + * without that reaction leaking to every other atomic block, and without a | |
| 400 | + * second copy of the bucket/breakpoint walk. Used by the SVG block, whose | |
| 401 | + * graphic must stop filling its wrapper once the author has asked for the | |
| 402 | + * wrapper to position it. | |
| 403 | + * | |
| 404 | + * @param array $styles The block's styles object. | |
| 405 | + * @param string $selector_base The block's own selector. | |
| 406 | + * @param string $property The compiled CSS property to look for. | |
| 407 | + * @param string $suffix Appended to the selector (e.g. ' svg'). | |
| 408 | + * @param string $declarations The declarations to emit. | |
| 409 | + * @param string|null $unless_property Skip a bucket that ALSO compiles this | |
| 410 | + * property — an explicit value there is | |
| 411 | + * more specific than the reaction being | |
| 412 | + * conditioned on, and must win outright | |
| 413 | + * rather than being overridden by it. | |
| 414 | + * @return string CSS, or ''. | |
| 415 | + */ | |
| 416 | + public static function conditional_rules( $styles, $selector_base, $property, $suffix, $declarations, $unless_property = null ) { | |
| 417 | + $css = ''; | |
| 418 | + foreach ( self::compile_rules( $styles ) as $rule ) { | |
| 419 | + list( $media, $state, $pairs ) = $rule; | |
| 420 | + $found = false; | |
| 421 | + $skip = false; | |
| 422 | + foreach ( $pairs as $pair ) { | |
| 423 | + if ( $pair[0] === $property ) { | |
| 424 | + $found = true; | |
| 425 | + } | |
| 426 | + if ( null !== $unless_property && $pair[0] === $unless_property ) { | |
| 427 | + $skip = true; | |
| 428 | + } | |
| 429 | + } | |
| 430 | + if ( ! $found || $skip ) { | |
| 431 | + continue; | |
| 432 | + } | |
| 433 | + $body = $selector_base . $state . $suffix . '{' . $declarations . '}'; | |
| 434 | + $css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body; | |
| 435 | + } | |
| 436 | + return $css; | |
| 437 | + } | |
| 438 | + | |
| 183 | 439 | /** Whether a bucket is the base one (base device, normal state). */ |
| 184 | 440 | public static function is_base_bucket( $bucket ) { |
| 185 | 441 | return '' === $bucket['state'] |
| 186 | 442 | && '' === StyleBuckets::device_bucket_key( $bucket['device'] ); |
| @@ -186,8 +442,20 @@ | ||
| 186 | 442 | && '' === StyleBuckets::device_bucket_key( $bucket['device'] ); |
| 187 | 443 | } |
| 188 | 444 | |
| 189 | 445 | /** |
| 446 | + * Whether a backgroundColor value is itself a gradient function (the | |
| 447 | + * Background tab's Color control can now produce one — see | |
| 448 | + * ABlocksColorControl's `isGradient` picker). Such a value is not valid | |
| 449 | + * CSS under `background-color`; it belongs under `background-image` | |
| 450 | + * instead, the same property the legacy `backgroundGradient` scalar and | |
| 451 | + * `backgroundOverlay` layers already use. Mirrors JS `isGradientValue()`. | |
| 452 | + */ | |
| 453 | + public static function is_gradient_value( $value ) { | |
| 454 | + return is_string( $value ) && ( 0 === strpos( $value, 'linear-gradient(' ) || 0 === strpos( $value, 'radial-gradient(' ) ); | |
| 455 | + } | |
| 456 | + | |
| 457 | + /** | |
| 190 | 458 | * A bucket that sets a solid background colour and no gradient of its own |
| 191 | 459 | * must clear any gradient inherited from a lower-precedence bucket: |
| 192 | 460 | * `background-color` and `background-image` are separate properties, so the |
| 193 | 461 | * gradient would otherwise stay painted on top of the solid colour. |
| @@ -220,9 +488,9 @@ | ||
| 220 | 488 | * rather than enumerating props itself, so the JS editor compiler and this |
| 221 | 489 | * one cannot drift on which props exist, what CSS property each maps to, or |
| 222 | 490 | * what order they emit in. |
| 223 | 491 | */ |
| 224 | - public static function state_declarations( $props ) { | |
| 492 | + public static function state_declarations( $props, $inherited_border_style = '' ) { | |
| 225 | 493 | $css = []; |
| 226 | 494 | if ( ! is_array( $props ) ) { |
| 227 | 495 | return $css; |
| 228 | 496 | } |
| @@ -243,9 +511,17 @@ | ||
| 243 | 511 | |
| 244 | 512 | case 'color': |
| 245 | 513 | $value = self::read_scalar( $props, $prop ); |
| 246 | 514 | if ( '' !== $value ) { |
| 247 | - $css[ $entry['css'] ] = Color::get_css( $value ); | |
| 515 | + $css_value = Color::get_css( $value ); | |
| 516 | + // backgroundColor is the one colour prop whose value can be | |
| 517 | + // a gradient function; every other colour prop (textColor, | |
| 518 | + // borderColor) keeps writing its own CSS property as before. | |
| 519 | + if ( 'backgroundColor' === $prop && self::is_gradient_value( $css_value ) ) { | |
| 520 | + $css['background-image'] = $css_value; | |
| 521 | + } else { | |
| 522 | + $css[ $entry['css'] ] = $css_value; | |
| 523 | + } | |
| 248 | 524 | } |
| 249 | 525 | break; |
| 250 | 526 | |
| 251 | 527 | case 'scalar': |
| @@ -285,9 +561,9 @@ | ||
| 285 | 561 | } |
| 286 | 562 | break; |
| 287 | 563 | |
| 288 | 564 | case 'border': |
| 289 | - $css = array_merge( $css, self::border_css( $props ) ); | |
| 565 | + $css = array_merge( $css, self::border_css( $props, $inherited_border_style ) ); | |
| 290 | 566 | break; |
| 291 | 567 | |
| 292 | 568 | case 'dimensions': |
| 293 | 569 | $css = array_merge( $css, self::spacing_css( $props, $prop ) ); |
| @@ -305,11 +581,11 @@ | ||
| 305 | 581 | $has_width = '' !== self::read_range( $props, 'width' ); |
| 306 | 582 | $has_max_width = '' !== self::read_range( $props, 'maxWidth' ); |
| 307 | 583 | |
| 308 | 584 | if ( $has_width ) { |
| 309 | - if ( '' === self::read_scalar( $props, 'flexShrink' ) ) { | |
| 310 | - $css['flex-shrink'] = '0'; | |
| 311 | - } | |
| 585 | + // No `flex-shrink: 0` — see the JS note. Pinning shrink to 0 is | |
| 586 | + // what let a child escape its parent, and `flex-basis: auto` below | |
| 587 | + // already holds the width whenever the row has room for it. | |
| 312 | 588 | if ( '' === self::read_scalar( $props, 'flexGrow' ) ) { |
| 313 | 589 | $css['flex-grow'] = '0'; |
| 314 | 590 | } |
| 315 | 591 | if ( '' === self::read_scalar( $props, 'flexBasis' ) ) { |
| @@ -378,9 +654,9 @@ | ||
| 378 | 654 | * OR only a colour still gets one. Colour-only is the common case — a hover |
| 379 | 655 | * bucket that recolours an existing border — and it rendered nothing before |
| 380 | 656 | * this fallback covered it. |
| 381 | 657 | */ |
| 382 | - private static function border_css( $props ) { | |
| 658 | + private static function border_css( $props, $inherited_style = '' ) { | |
| 383 | 659 | $css = []; |
| 384 | 660 | |
| 385 | 661 | $width = self::read_range( $props, 'borderWidth' ); |
| 386 | 662 | $style = self::read_scalar( $props, 'borderStyle' ); |
| @@ -409,9 +685,18 @@ | ||
| 409 | 685 | } |
| 410 | 686 | |
| 411 | 687 | // A width on any single side needs a style too, or it paints nothing. |
| 412 | 688 | if ( '' !== $width || $has_side_width || '' !== $color ) { |
| 413 | - $css['border-style'] = '' !== $style ? $style : 'solid'; | |
| 689 | + // Without a type of its own the bucket carries the one it inherits: | |
| 690 | + // this rule's border-style would otherwise override a wider | |
| 691 | + // device's (or the normal state's) `dashed` with a `solid` nobody | |
| 692 | + // chose. `solid` is only the default when nothing up the cascade | |
| 693 | + // sets a type either. | |
| 694 | + if ( '' !== $style ) { | |
| 695 | + $css['border-style'] = $style; | |
| 696 | + } else { | |
| 697 | + $css['border-style'] = '' !== $inherited_style ? $inherited_style : 'solid'; | |
| 698 | + } | |
| 414 | 699 | } elseif ( '' !== $style ) { |
| 415 | 700 | // A style on its own is meaningful (e.g. `none` to remove a border). |
| 416 | 701 | $css['border-style'] = $style; |
| 417 | 702 | } |