yf_bs_theme classRuntime 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.
| File | plugins/assets/classes/yf_bs_theme.class.php |
|---|---|
| Use | _class('bs_theme') |
A site sets them in its config with $PROJECT_CONF['bs_theme']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$BASESarray |
['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) |
$OVERRIDESarray |
[] |
shipped theme name => [<variable> => value, ...] laid over that theme ($PROJECT_CONF['bs_theme']['OVERRIDES']) |
$THEMESarray |
[] |
site themes, name => ['base' => 'bootstrap'|'slate'|'cyborg', <variable> => value, ...] ($PROJECT_CONF['bs_theme']['THEMES']) |
$VARIABLESarray |
|
the supported variables and their kind |
$_noticedarray |
[] |
one notice per problem and request |
VERSION | 1 | generated css format, part of the file hash |
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') : '';
}
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 basepublic function button_outline(array $color, $ratio): array
Button variables as Bootstrap 5.3's button-outline-variant() mixin computes them.
$color array$ratio floatpublic 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;
}
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;
}
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);
}
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);
}
public function hex(array $rgb): string
$rgb array public function hex(array $rgb): string
{
return sprintf('#%02x%02x%02x', ...$rgb);
}
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];
}
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);
}
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))];
}
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);
}
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;
}
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);
}
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;
}
_ hooks and helpers; not routable as page actionspublic function _notice($message)
$message string public function _notice($message)
{
if ( ! isset($this->_noticed[$message])) {
$this->_noticed[$message] = true;
trigger_error($message, E_USER_NOTICE);
}
}