true, // output .bg--* / .text--* + context vars
'dark_mode' => 'off', // 'off' | 'auto', generate dark values (light-dark())
'default_scheme' => 'auto', // with dark mode: 'light' | 'dark' | 'auto' (follow the visitor's OS)
'switcher' => false, // visitor light/dark toggle (.scheme-toggle / shortcode)
// Background relationships: what each .bg--* sets inside it.
'ctx_ultra_light_text' => 'var(--text-dark)',
'ctx_light_text' => 'var(--text-dark)',
'ctx_dark_text' => 'var(--text-light)',
'ctx_ultra_dark_text' => 'var(--text-light)',
'ctx_ultra_light_heading' => 'var(--text-dark)',
'ctx_light_heading' => 'var(--text-dark)',
'ctx_dark_heading' => 'var(--text-light)',
'ctx_ultra_dark_heading' => 'var(--text-light)',
'ctx_ultra_light_button' => '', // a button family (e.g. primary) for btn--* inside
'ctx_light_button' => '',
'ctx_dark_button' => '',
'ctx_ultra_dark_button' => '',
'ctx_ultra_light_icon' => '', // icon colour scheme: light | dark
'ctx_light_icon' => '',
'ctx_dark_icon' => '',
'ctx_ultra_dark_icon' => '',
'ctx_ultra_light_link' => '',
'ctx_light_link' => '',
'ctx_dark_link' => '',
'ctx_ultra_dark_link' => '',
'ctx_ultra_light_link_hover' => '',
'ctx_light_link_hover' => '',
'ctx_dark_link_hover' => '',
'ctx_ultra_dark_link_hover' => '',
'ctx_ultra_light_focus' => '',
'ctx_light_focus' => '',
'ctx_dark_focus' => '',
'ctx_ultra_dark_focus' => '',
// Context colours (the light-scheme values; dark mode swaps the pairs).
'bg_ultra_light' => 'var(--base-ultra-light, #f7f8fa)',
'bg_light' => 'var(--base-light, #e9edf2)',
'bg_dark' => 'var(--base-dark, #1a2433)',
'bg_ultra_dark' => 'var(--base-ultra-dark, #0a0f18)',
'text_dark' => 'var(--base, #10151c)',
'text_dark_muted' => 'var(--base-semi-dark, #47505c)',
'text_light' => 'var(--base-ultra-light, #f7f8fa)',
'text_light_muted' => 'var(--base-light, #c9d2dd)',
// Always render these selectors in one scheme (one per line / comma separated).
'force_dark' => '',
'force_light' => '',
);
}
/**
* Keys that hold CSS values (sanitised as text).
*/
const VALUE_KEYS = array( 'bg_ultra_light', 'bg_light', 'bg_dark', 'bg_ultra_dark', 'text_dark', 'text_dark_muted', 'text_light', 'text_light_muted', 'force_dark', 'force_light' );
/** Background contexts (.bg--*) → the default readable text. */
const CONTEXTS = array( 'ultra-light', 'light', 'dark', 'ultra-dark' );
/** Per-context relationship settings (ctx_{context}_{what}). */
const CTX_PROPS = array( 'text', 'heading', 'button', 'icon', 'link', 'link_hover', 'focus' );
/**
* Current settings.
*
* @return array
*/
public static function settings() {
$saved = get_option( self::OPTION, array() );
return Modules::apply( 'scheme', wp_parse_args( is_array( $saved ) ? $saved : array(), self::defaults() ) );
}
/**
* Save settings.
*
* @param array $in Raw input.
* @return void
*/
public static function save_settings( $in ) {
update_option(
self::OPTION,
array(
'assignments' => ! empty( $in['assignments'] ),
'dark_mode' => ( isset( $in['dark_mode'] ) && 'auto' === $in['dark_mode'] ) ? 'auto' : 'off',
'default_scheme' => ( isset( $in['default_scheme'] ) && in_array( $in['default_scheme'], array( 'light', 'dark' ), true ) ) ? $in['default_scheme'] : 'auto',
'switcher' => ! empty( $in['switcher'] ),
) + self::clean_values( $in ) + self::clean_ctx( $in )
);
}
/**
* Sanitised background-relationship settings.
*
* @param array $in Raw input.
* @return array
*/
private static function clean_ctx( $in ) {
$d = self::defaults();
$out = array();
foreach ( self::CONTEXTS as $c ) {
foreach ( self::CTX_PROPS as $p ) {
$k = 'ctx_' . str_replace( '-', '_', $c ) . '_' . $p;
$v = isset( $in[ $k ] ) ? trim( (string) $in[ $k ] ) : $d[ $k ];
if ( 'button' === $p ) {
$v = preg_replace( '/[^a-z0-9\-]/', '', strtolower( $v ) ); // a family name
} elseif ( 'icon' === $p ) {
$v = in_array( $v, array( 'light', 'dark' ), true ) ? $v : '';
} else {
$v = trim( str_replace( array( ';', '{', '}', '<', '>' ), '', sanitize_text_field( $v ) ) );
}
$out[ $k ] = $v;
}
}
return $out;
}
/**
* Sanitised colour/selector values from input (defaults for missing ones).
*
* @param array $in Raw input.
* @return array
*/
private static function clean_values( $in ) {
$d = self::defaults();
$out = array();
foreach ( self::VALUE_KEYS as $k ) {
$v = isset( $in[ $k ] ) ? (string) $in[ $k ] : $d[ $k ];
$out[ $k ] = trim( str_replace( array( ';', '{', '}', '<', '>' ), '', sanitize_textarea_field( $v ) ) );
}
return $out;
}
/**
* Build the scheme + assignment CSS and class names.
*
* @return array [ 'css' => string, 'classes' => string[] ]
*/
public static function build() {
$s = self::settings();
// The context variables below are always written, the Colors module off
// included (that only switches off dark mode and the assignments): buttons,
// cards, forms and the shop use them.
$dark = 'auto' === $s['dark_mode'];
$css = array();
$classes = array();
// Helper: a value that flips between light + dark when dark mode is on -
// unless the palette already flips itself (light-dark() colour tokens, e.g.
// imported from ACSS): wrapping again would flip twice and cancel out.
$flip = $dark && ! self::palette_flips();
$ld = function ( $light, $darkval ) use ( $flip ) {
return $flip ? "light-dark($light, $darkval)" : $light;
};
// Forced schemes for specific selectors.
foreach ( array( 'dark' => $s['force_dark'], 'light' => $s['force_light'] ) as $scheme => $sel ) {
// One per line or comma separated (commas inside :is()/:not() kept); unquote.
$list = array_filter( array_map( 'trim', preg_split( '/[\r\n]+|,(?![^(]*\))/', str_replace( '"', '', (string) $sel ) ) ) );
if ( $list ) {
$css[] = implode( ',', $list ) . '{color-scheme:' . $scheme . '}';
}
}
if ( $dark ) {
// The site's default scheme: fixed light/dark, or following the visitor's OS.
$css[] = ':root{color-scheme:' . ( 'auto' === $s['default_scheme'] ? 'light dark' : $s['default_scheme'] ) . '}';
$css[] = '.scheme--light{color-scheme:light}';
$css[] = '.scheme--dark{color-scheme:dark}';
$classes[] = 'scheme--light';
$classes[] = 'scheme--dark';
if ( ! empty( $s['switcher'] ) ) {
// A visitor choice (stored by the toggle script) overrides the OS preference.
$css[] = ':root[data-scheme="light"]{color-scheme:light}:root[data-scheme="dark"]{color-scheme:dark}';
$css[] = '.scheme-toggle{display:inline-grid;place-items:center;inline-size:2.5em;block-size:2.5em;padding:0;border:1px solid color-mix(in srgb, currentColor 25%, transparent);border-radius:999px;background:transparent;color:inherit;cursor:pointer}';
$css[] = '.scheme-toggle svg{inline-size:1.25em;block-size:1.25em;grid-area:1/1}';
// Show the moon while light is in effect, the sun while dark is.
$css[] = '.scheme-toggle .tf-sun{display:none}';
$css[] = ':root[data-scheme="dark"] .scheme-toggle .tf-sun{display:block}:root[data-scheme="dark"] .scheme-toggle .tf-moon{display:none}';
$css[] = '@media (prefers-color-scheme:dark){:root:not([data-scheme="light"]) .scheme-toggle .tf-sun{display:block}:root:not([data-scheme="light"]) .scheme-toggle .tf-moon{display:none}}';
$classes[] = 'scheme-toggle';
}
}
// Context variables, always: buttons, cards, forms and the shop use them,
// so switching the assignments off must not leave them without a value.
// In dark mode each flips to its opposite surface/text.
$css[] = ':root{'
. '--bg-ultra-light:' . $ld( $s['bg_ultra_light'], $s['bg_ultra_dark'] ) . ';'
. '--bg-light:' . $ld( $s['bg_light'], $s['bg_dark'] ) . ';'
. '--bg-dark:' . $ld( $s['bg_dark'], $s['bg_light'] ) . ';'
. '--bg-ultra-dark:' . $ld( $s['bg_ultra_dark'], $s['bg_ultra_light'] ) . ';'
. '--text-dark:' . $ld( $s['text_dark'], $s['text_light'] ) . ';'
. '--text-dark-muted:' . $ld( $s['text_dark_muted'], $s['text_light_muted'] ) . ';'
. '--text-light:' . $ld( $s['text_light'], $s['text_dark'] ) . ';'
. '--text-light-muted:' . $ld( $s['text_light_muted'], $s['text_dark_muted'] ) . ';'
. '--bg:var(--bg-ultra-light);--text:var(--text-dark)'
. '}';
if ( ! empty( $s['assignments'] ) ) {
// Background assignments, set the surface + readable text, headings,
// icons, links and focus colour inside it (all overridable variables).
$rel = array();
foreach ( self::CONTEXTS as $surface ) {
$key = 'ctx_' . str_replace( '-', '_', $surface ) . '_';
$light = in_array( $surface, array( 'ultra-light', 'light' ), true );
$text = '' !== $s[ $key . 'text' ] ? $s[ $key . 'text' ] : ( $light ? 'var(--text-dark)' : 'var(--text-light)' );
$head = '' !== $s[ $key . 'heading' ] ? $s[ $key . 'heading' ] : $text;
$muted = $light ? 'var(--text-dark-muted)' : 'var(--text-light-muted)';
$rel[] = "--bg-$surface-text:$text;--bg-$surface-heading:$head" . ( '' !== $s[ $key . 'button' ] ? ";--bg-$surface-button:" . $s[ $key . 'button' ] : '' ) . ( '' !== $s[ $key . 'icon' ] ? ";--bg-$surface-icon:" . $s[ $key . 'icon' ] : '' );
$extra = '';
if ( '' !== $s[ $key . 'icon' ] ) {
$extra .= ';--icon-scheme:var(--bg-' . $surface . '-icon)';
}
foreach ( array( 'link' => '--link-color', 'link_hover' => '--link-color-hover', 'focus' => '--focus-color' ) as $p => $var ) {
if ( '' !== $s[ $key . $p ] ) {
$extra .= ";$var:" . $s[ $key . $p ];
}
}
$css[] = ".bg--$surface{background-color:var(--bg-$surface);--bg:var(--bg-$surface);--text:var(--bg-$surface-text);--text-muted:$muted;--relative-heading-color:var(--bg-$surface-heading)$extra}";
$css[] = ":where(.bg--$surface){--relative-text-color:var(--bg-$surface-text);color:var(--relative-text-color, inherit)}";
$css[] = ".bg--$surface :where(h1,h2,h3,h4,h5,h6){color:var(--relative-heading-color, inherit)}";
$classes[] = "bg--$surface";
}
$css[] = ':root{' . implode( ';', $rel ) . '}';
// Text assignments.
foreach ( array( 'light', 'dark', 'light-muted', 'dark-muted' ) as $t ) {
$css[] = ".text--$t{color:var(--text-$t)}";
$classes[] = "text--$t";
}
}
return array(
'css' => "\n/* --- TokenForge color scheme + assignments --- */\n" . implode( "\n", $css ) . "\n",
'classes' => $classes,
);
}
/**
* Do the colour tokens switch with the scheme on their own (light-dark())?
*
* @return bool
*/
public static function palette_flips() {
foreach ( Settings::get_instance()->all_variables() as $t ) {
if ( ! empty( $t['enabled'] ) && 'color' === $t['group'] && false !== strpos( $t['value'], 'light-dark(' ) ) {
return true;
}
}
return false;
}
/**
* Is the visitor light/dark switcher live? Needs dark mode on.
*
* @return bool
*/
public static function switcher_enabled() {
$s = self::settings();
return 'auto' === $s['dark_mode'] && ! empty( $s['switcher'] );
}
/**
* Markup for a toggle button (used by the shortcode).
*
* @param string $class Extra classes.
* @return string
*/
public static function toggle_markup( $class = '' ) {
$cls = trim( 'scheme-toggle ' . $class );
return '';
}
/**
* The moon + sun icons (CSS shows the one for the scheme you'd switch to).
*
* @return string
*/
public static function toggle_icons() {
$moon = '';
$sun = '';
return $moon . $sun;
}
}