yf_bs_theme class

Runtime layer of the internal Bootstrap 5 themes (assets/themes/): site themes and overrides of the shipped themes as a small stylesheet of `--bs-*` and component variables, laid over the theme's own build. The colour maths follows Bootstrap 5.3's Sass (tint-color / shade-color / color-contrast / button-variant), so a site colour gets the same hover, active and focus shades a compiled theme would have; no Sass at run time.

Fileplugins/assets/classes/yf_bs_theme.class.php
Use_class('bs_theme')

Public properties

A site sets them in its config with $PROJECT_CONF['bs_theme']['NAME'] = ...;

PropertyDefaultDescription
$BASES
array
['bootstrap' => 4.5, 'slate' => 1.95, 'cyborg' => 2.25] shipped bs5 themes and the Sass min-contrast-ratio of their build (assets/themes/src/<name>/_variables.scss)
$OVERRIDES
array
[] shipped theme name => [<variable> => value, ...] laid over that theme ($PROJECT_CONF['bs_theme']['OVERRIDES'])
$THEMES
array
[] site themes, name => ['base' => 'bootstrap'|'slate'|'cyborg', <variable> => value, ...] ($PROJECT_CONF['bs_theme']['THEMES'])
$VARIABLES
array
['primary' => 'color', 'secondary' => 'color', 'success' => 'color', 'info' => 'color', 'warning' => 'color', 'danger' => 'color', ... (15 items)]
array (
  'primary' => 'color',
  'secondary' => 'color',
  'success' => 'color',
  'info' => 'color',
  'warning' => 'color',
  'danger' => 'color',
  'light' => 'color',
  'dark' => 'color',
  'body-bg' => 'color',
  'body-color' => 'color',
  'link-color' => 'color',
  'border-color' => 'color',
  'border-radius' => 'length',
  'font-size' => 'length',
  'font-family' => 'font',
)
the supported variables and their kind
$_noticed
array
[] one notice per problem and request

Constants

VERSION1generated css format, part of the file hash

Public methods 16

base_of

public function base_of($name): string

The shipped theme a name is built on: itself for a shipped theme, the base of a site theme, '' for anything else.

  • $name string
    public function base_of($name): string
    {
        $name = (string) $name;
        if (isset($this->BASES[$name])) {
            return $name;
        }
        return in_array($name, $this->site_themes(), true) ? (string) ($this->THEMES[$name]['base'] ?? 'bootstrap') : '';
    }

button

public function button(array $bg, $color, $ratio): array

Button variables as Bootstrap 5.3's button-variant() mixin computes them.

  • $bg array
  • $color string theme colour name: light and dark get their fixed shade / tint as in scss/_buttons.scss
  • $ratio float the min-contrast-ratio of the base
    public function button(array $bg, $color, $ratio): array
    {
        $fg = $this->contrast($bg, $ratio);
        $light_fg = $fg === [255, 255, 255];
        $darken = $color === 'light' || ($color !== 'dark' && $light_fg);
        $hover_bg = $darken ? $this->shade($bg, 15) : $this->tint($bg, 15);
        $hover_border = $darken ? $this->shade($bg, 20) : $this->tint($bg, 10);
        $active_bg = $darken ? $this->shade($bg, 20) : $this->tint($bg, 20);
        $active_border = $darken ? $this->shade($bg, 25) : $this->tint($bg, 10);
        return [
            'color' => $this->hex($fg),
            'bg' => $this->hex($bg),
            'border-color' => $this->hex($bg),
            'hover-color' => $this->hex($this->contrast($hover_bg, $ratio)),
            'hover-bg' => $this->hex($hover_bg),
            'hover-border-color' => $this->hex($hover_border),
            'focus-shadow-rgb' => implode(',', $this->mix($fg, $bg, 15)),
            'active-color' => $this->hex($this->contrast($active_bg, $ratio)),
            'active-bg' => $this->hex($active_bg),
            'active-border-color' => $this->hex($active_border),
            'disabled-color' => $this->hex($fg),
            'disabled-bg' => $this->hex($bg),
            'disabled-border-color' => $this->hex($bg),
        ];
    }
public function button_outline(array $color, $ratio): array

Button variables as Bootstrap 5.3's button-outline-variant() mixin computes them.

  • $color array
  • $ratio float
    public function button_outline(array $color, $ratio): array
    {
        $hex = $this->hex($color);
        $fg = $this->hex($this->contrast($color, $ratio));
        return [
            'color' => $hex,
            'border-color' => $hex,
            'hover-color' => $fg,
            'hover-bg' => $hex,
            'hover-border-color' => $hex,
            'focus-shadow-rgb' => implode(',', $color),
            'active-color' => $fg,
            'active-bg' => $hex,
            'active-border-color' => $hex,
            'disabled-color' => $hex,
            'disabled-border-color' => $hex,
        ];
    }

contrast

public function contrast(array $bg, $ratio = 4.5): array

Bootstrap's color-contrast(): the first of white, black that reaches the ratio, else the one with the higher ratio.

  • $bg array
  • $ratio float
    public function contrast(array $bg, $ratio = 4.5): array
    {
        $best = [255, 255, 255];
        $best_ratio = 0.0;
        foreach ([[255, 255, 255], [0, 0, 0]] as $fg) {
            $l1 = $this->luminance($bg);
            $l2 = $this->luminance($fg);
            $r = (max($l1, $l2) + 0.05) / (min($l1, $l2) + 0.05);
            if ($r >= $ratio) {
                return $fg;
            }
            if ($r > $best_ratio) {
                $best_ratio = $r;
                $best = $fg;
            }
        }
        return $best;
    }

css

public function css(array $vars, $base = 'bootstrap', $color_modes = false): string

The stylesheet for a set of validated variables over a shipped base. $color_modes (the page has a Bootstrap colour mode, yf_common::bs_color_mode()): the light-only values (body / link / border colours, the subtle and emphasis shades) go in a block that a dark <html> skips, so Bootstrap's own dark values apply there; without it the output is the same as before.

  • $vars array
  • $base string
  • $color_modes bool
    public function css(array $vars, $base = 'bootstrap', $color_modes = false): string
    {
        $ratio = (float) ($this->BASES[(string) $base] ?? 4.5);
        $root = [];
        $light = [];
        $put = function ($decl, $light_only = false) use (&$root, &$light, $color_modes) {
            if ($color_modes && $light_only) {
                $light[] = $decl;
            } else {
                $root[] = $decl;
            }
        };
        $rules = [];
        if (isset($vars['primary']) && ! isset($vars['link-color']) && $base === 'bootstrap') {
            // stock Bootstrap: $link-color is $primary
            $vars['link-color'] = $vars['primary'];
        }
        foreach (['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'light', 'dark'] as $color) {
            if ( ! isset($vars[$color])) {
                continue;
            }
            $rgb = $this->rgb($vars[$color]);
            $put('--bs-' . $color . ':' . $vars[$color]);
            $put('--bs-' . $color . '-rgb:' . implode(',', $rgb));
            $put('--bs-' . $color . '-text-emphasis:' . $this->hex($this->shade($rgb, 60)), true);
            $put('--bs-' . $color . '-bg-subtle:' . $this->hex($this->tint($rgb, 80)), true);
            $put('--bs-' . $color . '-border-subtle:' . $this->hex($this->tint($rgb, 60)), true);
            $rules[] = '.btn-' . $color . '{' . $this->decls($this->button($rgb, $color, $ratio)) . '}';
            $rules[] = '.btn-outline-' . $color . '{' . $this->decls($this->button_outline($rgb, $ratio)) . '}';
            $rules[] = '.text-bg-' . $color . '{color:' . $this->hex($this->contrast($rgb, $ratio)) . '!important}';
        }
        if (isset($vars['primary'])) {
            $p = $this->rgb($vars['primary']);
            $hex = $vars['primary'];
            $focus = 'rgba(' . implode(',', $p) . ',.25)';
            $put('--bs-focus-ring-color:' . $focus);
            $rules[] = '.nav-pills{--bs-nav-pills-link-active-bg:' . $hex . '}';
            $rules[] = '.pagination{--bs-pagination-active-bg:' . $hex . ';--bs-pagination-active-border-color:' . $hex . ';--bs-pagination-focus-box-shadow:0 0 0 .25rem ' . $focus . '}';
            $rules[] = '.dropdown-menu{--bs-dropdown-link-active-bg:' . $hex . '}';
            $rules[] = '.list-group{--bs-list-group-active-bg:' . $hex . ';--bs-list-group-active-border-color:' . $hex . '}';
            $rules[] = '.progress,.progress-stacked{--bs-progress-bar-bg:' . $hex . '}';
            $rules[] = '.form-control:focus,.form-select:focus,.form-check-input:focus{border-color:' . $this->hex($this->tint($p, 50)) . ';box-shadow:0 0 0 .25rem ' . $focus . '}';
            $rules[] = '.form-check-input:checked,.form-check-input[type=checkbox]:indeterminate{background-color:' . $hex . ';border-color:' . $hex . '}';
            $rules[] = '.form-range::-webkit-slider-thumb{background-color:' . $hex . '}.form-range::-moz-range-thumb{background-color:' . $hex . '}';
        }
        foreach (['body-bg', 'body-color'] as $key) {
            if (isset($vars[$key])) {
                $put('--bs-' . $key . ':' . $vars[$key], true);
                $put('--bs-' . $key . '-rgb:' . implode(',', $this->rgb($vars[$key])), true);
            }
        }
        if (isset($vars['link-color'])) {
            $link = $this->rgb($vars['link-color']);
            $hover = $this->shade($link, 20);
            $put('--bs-link-color:' . $vars['link-color'], true);
            $put('--bs-link-color-rgb:' . implode(',', $link), true);
            $put('--bs-link-hover-color:' . $this->hex($hover), true);
            $put('--bs-link-hover-color-rgb:' . implode(',', $hover), true);
        }
        isset($vars['border-color']) && $put('--bs-border-color:' . $vars['border-color'], true);
        isset($vars['border-radius']) && $put('--bs-border-radius:' . $vars['border-radius']);
        isset($vars['font-size']) && $put('--bs-body-font-size:' . $vars['font-size']);
        if (isset($vars['font-family'])) {
            $put('--bs-font-sans-serif:' . $vars['font-family']);
            $put('--bs-body-font-family:' . $vars['font-family']);
        }
        if ( ! $root && ! $light && ! $rules) {
            return '';
        }
        return '/* yf bs_theme v' . self::VERSION . ' over ' . $base . ' */' . PHP_EOL
            . ($root ? ':root,[data-bs-theme=light]{' . PHP_EOL . implode(';' . PHP_EOL, $root) . PHP_EOL . '}' . PHP_EOL : '')
            . ($light ? ':root:not([data-bs-theme=dark]),[data-bs-theme=light]{' . PHP_EOL . implode(';' . PHP_EOL, $light) . PHP_EOL . '}' . PHP_EOL : '')
            . implode(PHP_EOL, $rules) . PHP_EOL;
    }

css_url

public function css_url($name)

Web url of the generated stylesheet of a theme, null when it has nothing to add (or cannot be written).

  • $name string
    public function css_url($name)
    {
        $base = $this->base_of($name);
        if ($base === '') {
            return null;
        }
        $css = $this->css($this->variables($name), $base, common()->bs_color_mode() !== '');
        return $css === '' ? null : _class('assets')->_theme_generated_url($css);
    }

decls

public function decls(array $vars): string
  • $vars array
    public function decls(array $vars): string
    {
        $out = [];
        foreach ($vars as $k => $v) {
            $out[] = '--bs-btn-' . $k . ':' . $v;
        }
        return implode(';', $out);
    }

hex

public function hex(array $rgb): string
  • $rgb array
    public function hex(array $rgb): string
    {
        return sprintf('#%02x%02x%02x', ...$rgb);
    }

luminance

public function luminance(array $rgb): float
  • $rgb array
    public function luminance(array $rgb): float
    {
        $c = [];
        foreach ($rgb as $v) {
            $v /= 255;
            // Bootstrap's $_luminance-list is this value rounded to 4 decimals
            $c[] = $v < 0.04045 ? $v / 12.92 : round((($v + 0.055) / 1.055) ** 2.4, 4);
        }
        return 0.2126 * $c[0] + 0.7152 * $c[1] + 0.0722 * $c[2];
    }

mix

public function mix(array $a, array $b, $weight): array

Sass mix($a, $b, $weight%) of two opaque colours, channels rounded like Sass hex output.

  • $a array
  • $b array
  • $weight float percent of $a
    public function mix(array $a, array $b, $weight): array
    {
        $w = $weight / 100;
        return array_map(static fn ($x, $y) => (int) round($x * $w + $y * (1 - $w)), $a, $b);
    }

rgb

public function rgb($hex): array
  • $hex string #rgb or #rrggbb
    public function rgb($hex): array
    {
        $hex = ltrim((string) $hex, '#');
        strlen($hex) === 3 && $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
    }

shade

public function shade(array $rgb, $weight): array
  • $rgb array
  • $weight float
    public function shade(array $rgb, $weight): array
    {
        return $this->mix([0, 0, 0], $rgb, $weight);
    }

site_themes

public function site_themes(): array

Names of the valid site themes (a shipped name, a bad name or an unknown base is skipped with a notice).

    public function site_themes(): array
    {
        $names = [];
        foreach ((array) $this->THEMES as $name => $def) {
            $name = (string) $name;
            if ( ! preg_match('~^[a-z][a-z0-9_-]{0,31}$~D', $name) || isset($this->BASES[$name]) || ! is_array($def)) {
                $this->_notice('bs_theme: site theme "' . substr(preg_replace('~[^a-z0-9_-]~i', '?', $name), 0, 32) . '" has an invalid name, is shipped already or is not an array, skipped');
                continue;
            }
            $base = $def['base'] ?? 'bootstrap';
            if ( ! is_string($base) || ! isset($this->BASES[$base])) {
                $this->_notice('bs_theme: site theme "' . $name . '" has an unknown base, skipped');
                continue;
            }
            $names[] = $name;
        }
        return $names;
    }

tint

public function tint(array $rgb, $weight): array
  • $rgb array
  • $weight float
    public function tint(array $rgb, $weight): array
    {
        return $this->mix([255, 255, 255], $rgb, $weight);
    }

variables

public function variables($name): array

The validated variables of a site theme or of the override of a shipped theme; an invalid value is dropped with a notice.

  • $name string
    public function variables($name): array
    {
        $name = (string) $name;
        $def = isset($this->BASES[$name]) ? ($this->OVERRIDES[$name] ?? []) : (in_array($name, $this->site_themes(), true) ? $this->THEMES[$name] : []);
        $vars = [];
        foreach (is_array($def) ? $def : [] as $key => $value) {
            if ($key === 'base') {
                continue;
            }
            $kind = $this->VARIABLES[$key] ?? '';
            $value = is_string($value) || is_int($value) ? trim((string) $value) : '';
            // a font list: bare names or names fully inside one pair of quotes, so no value can leave a string open
            $ok = ($kind === 'color' && preg_match('~^#(?:[0-9a-f]{3}|[0-9a-f]{6})$~iD', $value))
                || ($kind === 'length' && preg_match('~^(?:0|\d{1,3}(?:\.\d{1,4})?(?:rem|em|px))$~D', $value))
                || ($kind === 'font' && strlen($value) <= 200 && preg_match('~^(?:[A-Za-z0-9 -]+|\'[A-Za-z0-9 -]+\'|"[A-Za-z0-9 -]+")(?:\s*,\s*(?:[A-Za-z0-9 -]+|\'[A-Za-z0-9 -]+\'|"[A-Za-z0-9 -]+"))*$~D', $value));
            if ( ! $ok) {
                $this->_notice('bs_theme: "' . $name . '" variable "' . substr(preg_replace('~[^a-z0-9_-]~i', '?', (string) $key), 0, 32) . '" is unknown or has an invalid value, skipped');
                continue;
            }
            $vars[$key] = $kind === 'color' ? $this->hex($this->rgb($value)) : $value;
        }
        return $vars;
    }

Methods starting with _ hooks and helpers; not routable as page actions

_notice

public function _notice($message)
  • $message string
    public function _notice($message)
    {
        if ( ! isset($this->_noticed[$message])) {
            $this->_noticed[$message] = true;
            trigger_error($message, E_USER_NOTICE);
        }
    }