diff --git a/assets/css/magicauth-admin.css b/assets/css/magicauth-admin.css
index 62df64f..f82dd81 100644
--- a/assets/css/magicauth-admin.css
+++ b/assets/css/magicauth-admin.css
@@ -375,6 +375,44 @@ body.admin-bar .magicauth-admin .magicauth-topbar__bar { top: 32px; }
line-height: 1.55;
}
+/* / disclosure widget. is keyboard-focusable
+ natively; no JS. Native marker hidden so we can render our own chevron. */
+.magicauth-admin details.magicauth-block > summary {
+ list-style: none;
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+ padding-right: 32px;
+}
+
+.magicauth-admin details.magicauth-block > summary::-webkit-details-marker { display: none; }
+
+.magicauth-admin details.magicauth-block > summary::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 8px;
+ margin: auto 0;
+ width: 9px;
+ height: 9px;
+ border-right: 2px solid var(--tx-muted);
+ border-bottom: 2px solid var(--tx-muted);
+ transform: rotate(45deg);
+ transform-origin: center;
+ transition: transform 0.15s ease;
+}
+
+.magicauth-admin details.magicauth-block[open] > summary::after {
+ transform: rotate(-135deg);
+}
+
+.magicauth-admin details.magicauth-block > summary:focus-visible {
+ outline: 2px solid var(--ettic-accent, #2271b1);
+ outline-offset: 4px;
+ border-radius: 4px;
+}
+
/* Cards. Namespaced to avoid colliding with WP core `.card` (caps at 520px). */
.magicauth-admin .magicauth-card {
background: var(--ettic-surface);
@@ -833,7 +871,7 @@ body.admin-bar .magicauth-admin .magicauth-topbar__bar { top: 32px; }
align-items: center;
justify-content: space-between;
gap: 16px;
- padding: 14px 8px;
+ padding: 14px;
position: relative;
}
diff --git a/assets/css/magicauth.css b/assets/css/magicauth.css
index d3f208c..a93abd0 100644
--- a/assets/css/magicauth.css
+++ b/assets/css/magicauth.css
@@ -13,6 +13,7 @@
--magicauth-color-divider: #e9ecef;
--magicauth-color-error: #b32d2e;
--magicauth-color-focus-ring: var(--magicauth-color-primary);
+ --magicauth-color-link: var(--magicauth-color-primary);
--magicauth-card-max-width: 480px;
--magicauth-card-padding: 56px 48px;
--magicauth-card-padding-mobile: 32px 24px;
@@ -325,7 +326,7 @@
}
.magicauth-link {
- color: var(--magicauth-color-primary);
+ color: var(--magicauth-color-link, var(--magicauth-color-primary));
text-decoration: underline;
text-underline-offset: 2px;
}
@@ -446,3 +447,45 @@
outline: 2px solid CanvasText;
}
}
+
+/* Dark mode — explicit. Body-class specificity (0,1,1) beats :root (0,0,1),
+ so the shell-vars block can still set per-instance brand_color / link_color /
+ font_stack without being overridden. Curated values, not exposed to admin
+ pickers.
+ !!! SYNC-LOCK !!! --magicauth-color-surface (#181c22) MUST equal the PHP
+ constant MagicAuth\Admin\Settings::DARK_SURFACE_HEX
+ (includes/Admin/Settings.php:~42). The PHP constant feeds the brand-color
+ contrast check; the CSS variable paints the card. If either changes without
+ the other, dark-mode contrast warnings will lie. Value also duplicated at
+ line ~482 below (auto-mode @media block) — update all three together. */
+body.magicauth-mode-dark {
+ --magicauth-color-page: #0e1116;
+ --magicauth-color-surface: #181c22; /* SYNC with Settings::DARK_SURFACE_HEX */
+ --magicauth-color-code-bg: #202832;
+ --magicauth-color-text: #eef0f3;
+ --magicauth-color-text-muted: #9aa3ad;
+ --magicauth-color-border: #2a313a;
+ --magicauth-color-border-strong: #3a424c;
+ --magicauth-color-divider: #262d36;
+ --magicauth-color-error: #ff6b6b;
+}
+
+/* Dark mode — auto. Only kicks in when admin chose color_mode = auto AND
+ the visitor's browser reports a dark preference.
+ !!! SYNC-LOCK !!! --magicauth-color-surface (#181c22) MUST equal the PHP
+ constant MagicAuth\Admin\Settings::DARK_SURFACE_HEX
+ (includes/Admin/Settings.php:~42). See full note above the explicit-dark
+ block (~line 451). Three locations to update in lockstep. */
+@media (prefers-color-scheme: dark) {
+ body.magicauth-mode-auto {
+ --magicauth-color-page: #0e1116;
+ --magicauth-color-surface: #181c22; /* SYNC with Settings::DARK_SURFACE_HEX */
+ --magicauth-color-code-bg: #202832;
+ --magicauth-color-text: #eef0f3;
+ --magicauth-color-text-muted: #9aa3ad;
+ --magicauth-color-border: #2a313a;
+ --magicauth-color-border-strong: #3a424c;
+ --magicauth-color-divider: #262d36;
+ --magicauth-color-error: #ff6b6b;
+ }
+}
diff --git a/assets/js/magicauth-admin.js b/assets/js/magicauth-admin.js
index 6424c47..497b670 100644
--- a/assets/js/magicauth-admin.js
+++ b/assets/js/magicauth-admin.js
@@ -12,6 +12,7 @@
function init() {
initMediaPickers();
initColorPickers();
+ initColorFollowers();
initRowDirtyMarks();
initDirtyTracking();
initCharCounters();
@@ -107,6 +108,54 @@
} );
}
+ // Follower color pickers: when the text input is blank, the swatch mirrors
+ // a source picker (e.g. link_color follows brand_color). Typing a hex or
+ // picking a swatch color writes to the text input, which disconnects.
+ function initColorFollowers() {
+ var HEX_RE = /^#?[0-9a-fA-F]{6}$/;
+ var normalize = function ( v ) { return v.charAt( 0 ) === '#' ? v : '#' + v; };
+
+ document.querySelectorAll( '.magicauth-admin .magicauth-color[data-magicauth-color-follows]' ).forEach( function ( follower ) {
+ var sourceKey = follower.getAttribute( 'data-magicauth-color-follows' );
+ if ( ! sourceKey ) {
+ return;
+ }
+ var sourceText = document.querySelector( 'input[name="magicauth_settings[' + sourceKey + ']"]' );
+ var swatch = follower.querySelector( 'input[type="color"]' );
+ var text = follower.querySelector( 'input[type="text"]' );
+ if ( ! sourceText || ! swatch || ! text ) {
+ return;
+ }
+
+ function isConnected() { return '' === text.value.trim(); }
+ function mirrorFromSource() {
+ var v = sourceText.value.trim();
+ if ( HEX_RE.test( v ) ) {
+ swatch.value = normalize( v );
+ }
+ }
+
+ // Cleared text → snap swatch back to source.
+ text.addEventListener( 'input', function () {
+ if ( isConnected() ) {
+ mirrorFromSource();
+ }
+ } );
+
+ // Source moved → mirror only while connected.
+ sourceText.addEventListener( 'input', function () {
+ if ( isConnected() ) {
+ mirrorFromSource();
+ }
+ } );
+
+ // Initial render: if follower is blank, mirror immediately.
+ if ( isConnected() ) {
+ mirrorFromSource();
+ }
+ } );
+ }
+
// Per-row dirty mark — auto-injected, opacity toggled via .is-dirty
function initRowDirtyMarks() {
document.querySelectorAll( '.magicauth-admin .magicauth-row' ).forEach( function ( row ) {
diff --git a/includes/Admin/Settings.php b/includes/Admin/Settings.php
index 8a8d95a..b81960a 100644
--- a/includes/Admin/Settings.php
+++ b/includes/Admin/Settings.php
@@ -28,6 +28,19 @@ final class Settings {
private const MAX_LINK_USES_PRESETS = [ 1, 2, 3, 5, 10 ];
+ private const FONT_STACK_KEYS = [ 'system', 'sans-modern', 'serif', 'mono', 'rounded' ];
+
+ private const COLOR_MODE_KEYS = [ 'light', 'dark', 'auto' ];
+
+ // Curated dark-mode surface — used by the brand-color contrast check below.
+ // !!! SYNC-LOCK !!! This value MUST equal the --magicauth-color-surface
+ // declarations in assets/css/magicauth.css at:
+ // - line ~463 (body.magicauth-mode-dark block)
+ // - line ~482 (@media (prefers-color-scheme: dark) > body.magicauth-mode-auto)
+ // Three locations, one truth. If you change one, change all three or the
+ // contrast warning shown to admins will disagree with the rendered card.
+ private const DARK_SURFACE_HEX = '#181c22';
+
// Keyed by extension regex (wp_check_filetype_and_ext format); membership checks hit values.
private const LOGO_MIMES = [
'png' => 'image/png',
@@ -36,6 +49,18 @@ final class Settings {
'svg' => 'image/svg+xml',
];
+ // Background images: raster only. SVG explicitly excluded — larger attack
+ // surface than logos and no rendering benefit at full-page scale.
+ private const BACKGROUND_MIMES = [
+ 'png' => 'image/png',
+ 'jpg|jpeg' => 'image/jpeg',
+ 'webp' => 'image/webp',
+ ];
+
+ // Background image soft-limit. Files larger than this trigger a non-blocking
+ // "consider compressing" admin notice but still save successfully.
+ private const BACKGROUND_SIZE_SOFT_LIMIT_BYTES = 1024 * 1024; // 1 MB.
+
public static function setup(): void {
add_action( 'admin_init', [ self::class, 'register' ] );
add_action( 'admin_menu', [ self::class, 'menu' ] );
@@ -161,6 +186,7 @@ public static function render_page(): void {
+
@@ -173,66 +199,86 @@ public static function render_page(): void {
private static function render_section_general(): void {
?>
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-
+
@@ -281,7 +327,7 @@ private static function render_section_diagnostics(): void {
-
+
self::BACKGROUND_SIZE_SOFT_LIMIT_BYTES ) {
+ add_settings_error(
+ self::OPTION_NAME,
+ 'magicauth_bg_large',
+ sprintf(
+ /* translators: %s: file size in MB */
+ __( 'Background image saved. File is %s MB — consider compressing for faster page load.', 'magicauth' ),
+ number_format( $size / self::BACKGROUND_SIZE_SOFT_LIMIT_BYTES, 1 )
+ ),
+ 'warning'
+ );
+ }
+
+ return $attachment_id;
+ }
+
public static function field_ttl_minutes(): void {
$value = (int) magicauth_get_setting( 'ttl_minutes', 10 );
$name = sprintf( '%s[ttl_minutes]', self::OPTION_NAME );
@@ -495,32 +719,17 @@ public static function field_max_link_uses(): void {
}
public static function field_redirect_to_default(): void {
- $value = (string) magicauth_get_setting( 'redirect_to_default', 'auto' );
- $name = sprintf( '%s[redirect_to_default]', self::OPTION_NAME );
- $options = [
- 'auto' => __( 'Auto (admin or home, by capability)', 'magicauth' ),
- 'home' => __( 'Site home', 'magicauth' ),
- 'admin' => __( 'Admin dashboard', 'magicauth' ),
- ];
- ?>
-
-
-
-
-
-
-
-
- __( 'Auto (admin or home, by capability)', 'magicauth' ),
+ 'home' => __( 'Site home', 'magicauth' ),
+ 'admin' => __( 'Admin dashboard', 'magicauth' ),
+ ]
+ );
}
public static function field_replace_default( bool $weak_salts = false ): void {
@@ -596,6 +805,119 @@ public static function field_brand_color(): void {
+
+
+
+
+
+ __( 'System default', 'magicauth' ),
+ 'sans-modern' => __( 'Modern sans (Inter)', 'magicauth' ),
+ 'serif' => __( 'Serif (Georgia)', 'magicauth' ),
+ 'mono' => __( 'Monospace', 'magicauth' ),
+ 'rounded' => __( 'Rounded', 'magicauth' ),
+ ]
+ );
+ }
+
+ public static function field_link_color(): void {
+ $value = (string) magicauth_get_setting( 'link_color', '' );
+ $brand_raw = (string) magicauth_get_setting( 'brand_color', '#2271b1' );
+ $brand_default = (string) ( sanitize_hex_color( $brand_raw ) ?: '#2271b1' );
+ $name = sprintf( '%s[link_color]', self::OPTION_NAME );
+ ?>
+
+ __( 'Light', 'magicauth' ),
+ 'dark' => __( 'Dark', 'magicauth' ),
+ 'auto' => __( "Auto (follow visitor's browser preference)", 'magicauth' ),
+ ]
+ );
+ }
+
+ public static function field_background_image(): void {
+ self::render_media_picker_field(
+ 'background_attachment_id',
+ (int) magicauth_get_setting( 'background_attachment_id', 0 ),
+ __( 'Page background image', 'magicauth' ),
+ __( 'Optional. PNG, JPG, or WebP. Rendered as a cover-fitted, centered background behind the sign-in card. Page background color shows through transparent areas or if the image fails to load.', 'magicauth' )
+ );
+ }
+
public static function field_agency_credit_name(): void {
$value = (string) magicauth_get_setting( 'agency_credit_name', '' );
$name = sprintf( '%s[agency_credit_name]', self::OPTION_NAME );
@@ -707,6 +1029,35 @@ public static function field_throttle(): void {
field markup. Same row scaffold as the v1.0 select fields;
+ * keeps three field_* methods at ~4 LOC each instead of 25.
+ *
+ * @param array $options Map of stored value => translated label.
+ */
+ private static function render_select_field( string $option_key, string $value, string $label, string $help, array $options ): void {
+ $name = sprintf( '%s[%s]', self::OPTION_NAME, $option_key );
+ ?>
+
+
+
+
+
+
+
+
+ '',
'logo_attachment_id' => 0,
'brand_color' => '#2271b1',
+ 'link_color' => '',
+ 'page_color' => '#eeeeee',
+ 'background_attachment_id' => 0,
+ 'card_radius' => 6,
+ 'card_width' => 480,
+ 'font_stack' => 'system',
+ 'color_mode' => 'light',
'agency_credit_name' => '',
'agency_credit_url' => '',
'agency_credit_icon_id' => 0,
diff --git a/includes/helpers.php b/includes/helpers.php
index e328bbc..cf9d57f 100644
--- a/includes/helpers.php
+++ b/includes/helpers.php
@@ -10,8 +10,17 @@
defined( 'ABSPATH' ) || exit;
if ( ! function_exists( 'magicauth_get_settings' ) ) {
- /** Settings merged onto defaults. */
+ /**
+ * Settings merged onto defaults. Memoized per-request via $GLOBALS so the
+ * 7 calls on the login render path don't each rebuild the defaults array
+ * and run array_replace_recursive. Invalidated by the option-update hooks
+ * registered at the bottom of this file (and by the test stubs).
+ */
function magicauth_get_settings(): array {
+ if ( isset( $GLOBALS['magicauth_settings_cache'] ) && is_array( $GLOBALS['magicauth_settings_cache'] ) ) {
+ return $GLOBALS['magicauth_settings_cache'];
+ }
+
$defaults = [
'ttl_minutes' => 10,
'max_link_uses' => 2,
@@ -30,6 +39,13 @@ function magicauth_get_settings(): array {
'company_name' => '',
'logo_attachment_id' => 0,
'brand_color' => '#2271b1',
+ 'link_color' => '',
+ 'page_color' => '#eeeeee',
+ 'background_attachment_id' => 0,
+ 'card_radius' => 6,
+ 'card_width' => 480,
+ 'font_stack' => 'system',
+ 'color_mode' => 'light',
'agency_credit_name' => '',
'agency_credit_url' => '',
'agency_credit_icon_id' => 0,
@@ -44,8 +60,23 @@ function magicauth_get_settings(): array {
$saved = [];
}
- return array_replace_recursive( $defaults, $saved );
+ $GLOBALS['magicauth_settings_cache'] = array_replace_recursive( $defaults, $saved );
+ return $GLOBALS['magicauth_settings_cache'];
+ }
+}
+
+if ( ! function_exists( 'magicauth_invalidate_settings_cache' ) ) {
+ /** Drop the per-request settings memo. Tests reset state; admin saves fire this. */
+ function magicauth_invalidate_settings_cache(): void {
+ unset( $GLOBALS['magicauth_settings_cache'] );
+ }
+}
+
+if ( function_exists( 'add_action' ) ) {
+ foreach ( [ 'update_option_magicauth_settings', 'add_option_magicauth_settings', 'delete_option_magicauth_settings' ] as $magicauth_cache_hook ) {
+ add_action( $magicauth_cache_hook, 'magicauth_invalidate_settings_cache' );
}
+ unset( $magicauth_cache_hook );
}
if ( ! function_exists( 'magicauth_get_setting' ) ) {
@@ -146,24 +177,101 @@ function magicauth_hash_email( string $email ): string {
}
}
-if ( ! function_exists( 'magicauth_yiq_text_color' ) ) {
- /** Black or white text for a hex bg via YIQ luminance. */
- function magicauth_yiq_text_color( string $hex ): string {
+if ( ! function_exists( 'magicauth_hex_to_rgb' ) ) {
+ /**
+ * Parse a 3- or 6-digit hex color (leading `#` optional) into [r, g, b]
+ * ints (0–255). Returns null on malformed input.
+ *
+ * @return array{0:int,1:int,2:int}|null
+ */
+ function magicauth_hex_to_rgb( string $hex ): ?array {
$hex = ltrim( $hex, '#' );
if ( 3 === strlen( $hex ) ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
+ return null;
+ }
+ return [
+ (int) hexdec( substr( $hex, 0, 2 ) ),
+ (int) hexdec( substr( $hex, 2, 2 ) ),
+ (int) hexdec( substr( $hex, 4, 2 ) ),
+ ];
+ }
+}
+
+if ( ! function_exists( 'magicauth_yiq_text_color' ) ) {
+ /** Black or white text for a hex bg via YIQ luminance. */
+ function magicauth_yiq_text_color( string $hex ): string {
+ $rgb = magicauth_hex_to_rgb( $hex );
+ if ( null === $rgb ) {
return '#ffffff';
}
- $r = hexdec( substr( $hex, 0, 2 ) );
- $g = hexdec( substr( $hex, 2, 2 ) );
- $b = hexdec( substr( $hex, 4, 2 ) );
- $yiq = ( ( $r * 299 ) + ( $g * 587 ) + ( $b * 114 ) ) / 1000;
+ $yiq = ( ( $rgb[0] * 299 ) + ( $rgb[1] * 587 ) + ( $rgb[2] * 114 ) ) / 1000;
return $yiq >= 128 ? '#000000' : '#ffffff';
}
}
+if ( ! function_exists( 'magicauth_contrast_ratio' ) ) {
+ /**
+ * WCAG 2.1 relative-luminance contrast ratio between two hex colors.
+ *
+ * Range: 1.0 (identical) to 21.0 (pure black on pure white). Uses ITU-R
+ * BT.709 coefficients and the sRGB gamma curve per the WCAG 2.1 spec.
+ * Accepts 3- or 6-digit hex with optional leading `#`. Returns 1.0 (the
+ * worst valid ratio) on malformed input — callers should pre-validate.
+ */
+ function magicauth_contrast_ratio( string $hex_a, string $hex_b ): float {
+ $lum = static function ( string $hex ): ?float {
+ $rgb = magicauth_hex_to_rgb( $hex );
+ if ( null === $rgb ) {
+ return null;
+ }
+ $channels = [ $rgb[0] / 255.0, $rgb[1] / 255.0, $rgb[2] / 255.0 ];
+ foreach ( $channels as &$c ) {
+ $c = ( $c <= 0.03928 )
+ ? $c / 12.92
+ : pow( ( $c + 0.055 ) / 1.055, 2.4 );
+ }
+ unset( $c );
+ return ( 0.2126 * $channels[0] ) + ( 0.7152 * $channels[1] ) + ( 0.0722 * $channels[2] );
+ };
+
+ $l1 = $lum( $hex_a );
+ $l2 = $lum( $hex_b );
+ if ( null === $l1 || null === $l2 ) {
+ return 1.0;
+ }
+ if ( $l2 > $l1 ) {
+ [ $l1, $l2 ] = [ $l2, $l1 ];
+ }
+ return ( $l1 + 0.05 ) / ( $l2 + 0.05 );
+ }
+}
+
+if ( ! function_exists( 'magicauth_contrast_evaluate' ) ) {
+ /**
+ * Three-tier WCAG verdict on a contrast ratio.
+ *
+ * - 'fail' below 2.5:1 (unreadable territory; reject at sanitize).
+ * - 'warn' from 2.5:1 to context floor (AA 4.5:1 normal, 3:1 large/UI).
+ * - 'pass' at or above context floor.
+ *
+ * @param float $ratio Output of magicauth_contrast_ratio().
+ * @param string $context 'normal'|'large'|'ui' — defaults to 'normal' (strictest).
+ */
+ function magicauth_contrast_evaluate( float $ratio, string $context = 'normal' ): string {
+ $floor = 'normal' === $context ? 4.5 : 3.0;
+ if ( $ratio < 2.5 ) {
+ return 'fail';
+ }
+ if ( $ratio < $floor ) {
+ return 'warn';
+ }
+ return 'pass';
+ }
+}
+
if ( ! function_exists( 'magicauth_current_user_can_control_user' ) ) {
/** Cap gate: edit_user + same-or-higher role. Filterable for custom hierarchies. */
function magicauth_current_user_can_control_user( int $target_user_id ): bool {
diff --git a/magicauth.php b/magicauth.php
index 21db5cb..401a33f 100644
--- a/magicauth.php
+++ b/magicauth.php
@@ -1,7 +1,7 @@
MagicAuth.
+* Branding: new color knobs are validated against WCAG 2.1 AA contrast at save time — accidents below the readability floor are rejected, low-contrast values save with a notice, AA+ values save silently.
+* UX: Branding section now splits into "Identity" and "Appearance" sub-cards; all settings sections are collapsible via native ``/``.
+
+= 1.1.0 =
+* Branding: page background color, card corner radius, card max width, and font family are now admin-controllable from Settings > MagicAuth.
+* Security: `wp-login.php` now sends `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` to refuse framing entirely.
+
= 1.0.0 =
* Initial public release.
diff --git a/templates/login-shell.php b/templates/login-shell.php
index a1f118d..673eb21 100644
--- a/templates/login-shell.php
+++ b/templates/login-shell.php
@@ -19,17 +19,70 @@
defined( 'ABSPATH' ) || exit;
?>
' or a future maintainer can break the
assertEqualsWithDelta( 21.0, magicauth_contrast_ratio( '#000', '#ffffff' ), 0.01 );
+ }
+
+ public function test_identical_colors_yield_1(): void {
+ $this->assertEqualsWithDelta( 1.0, magicauth_contrast_ratio( '#abcdef', '#abcdef' ), 0.001 );
+ }
+
+ public function test_wp_blue_on_white_is_aa_normal(): void {
+ $r = magicauth_contrast_ratio( '#2271b1', '#ffffff' );
+ $this->assertGreaterThanOrEqual( 4.5, $r );
+ $this->assertLessThan( 7.0, $r );
+ }
+
+ public function test_three_digit_hex_normalizes_like_six(): void {
+ $this->assertEqualsWithDelta(
+ magicauth_contrast_ratio( '#abc', '#ffffff' ),
+ magicauth_contrast_ratio( '#aabbcc', '#ffffff' ),
+ 0.01
+ );
+ }
+
+ public function test_symmetric(): void {
+ $a = magicauth_contrast_ratio( '#123456', '#abcdef' );
+ $b = magicauth_contrast_ratio( '#abcdef', '#123456' );
+ $this->assertEqualsWithDelta( $a, $b, 0.001 );
+ }
+
+ public function test_garbage_input_returns_1(): void {
+ $this->assertSame( 1.0, magicauth_contrast_ratio( 'pirate', '#ffffff' ) );
+ }
+
+ public function test_alpha_hex_rejected(): void {
+ // 8-char form is not a valid 3/6-digit hex → 1.0 sentinel.
+ $this->assertSame( 1.0, magicauth_contrast_ratio( '#ff000080', '#ffffff' ) );
+ }
+
+ public function test_evaluate_thresholds(): void {
+ $this->assertSame( 'fail', magicauth_contrast_evaluate( 2.0, 'normal' ) );
+ $this->assertSame( 'warn', magicauth_contrast_evaluate( 4.0, 'normal' ) );
+ $this->assertSame( 'pass', magicauth_contrast_evaluate( 5.0, 'normal' ) );
+ $this->assertSame( 'pass', magicauth_contrast_evaluate( 3.5, 'ui' ) );
+ $this->assertSame( 'pass', magicauth_contrast_evaluate( 3.5, 'large' ) );
+ }
+}
diff --git a/tests/phpunit/Model/SettingsSanitizeTest.php b/tests/phpunit/Model/SettingsSanitizeTest.php
new file mode 100644
index 0000000..1a399bd
--- /dev/null
+++ b/tests/phpunit/Model/SettingsSanitizeTest.php
@@ -0,0 +1,269 @@
+sanitize( [ 'page_color' => '#abcdef' ] );
+ $this->assertSame( '#abcdef', $out['page_color'] );
+ }
+
+ public function test_page_color_falls_back_on_garbage(): void {
+ $out = $this->sanitize( [ 'page_color' => 'not a color' ] );
+ $this->assertSame( '#eeeeee', $out['page_color'] );
+ }
+
+ public function test_page_color_array_input_is_ignored(): void {
+ $out = $this->sanitize( [ 'page_color' => [ '#ff0000' ] ] );
+ // is_scalar guard short-circuits; current default survives.
+ $this->assertSame( '#eeeeee', $out['page_color'] );
+ }
+
+ public function test_card_radius_clamps_above_max(): void {
+ $out = $this->sanitize( [ 'card_radius' => 9999 ] );
+ $this->assertSame( 32, $out['card_radius'] );
+ }
+
+ public function test_card_radius_accepts_bounds(): void {
+ $lo = $this->sanitize( [ 'card_radius' => 0 ] );
+ $hi = $this->sanitize( [ 'card_radius' => 32 ] );
+ $this->assertSame( 0, $lo['card_radius'] );
+ $this->assertSame( 32, $hi['card_radius'] );
+ }
+
+ public function test_card_radius_garbage_yields_zero(): void {
+ $out = $this->sanitize( [ 'card_radius' => 'foo' ] );
+ $this->assertSame( 0, $out['card_radius'] );
+ }
+
+ public function test_card_width_clamps_below_min(): void {
+ $out = $this->sanitize( [ 'card_width' => 100 ] );
+ $this->assertSame( 360, $out['card_width'] );
+ }
+
+ public function test_card_width_clamps_above_max(): void {
+ $out = $this->sanitize( [ 'card_width' => 9999 ] );
+ $this->assertSame( 640, $out['card_width'] );
+ }
+
+ public function test_card_width_accepts_bounds(): void {
+ $lo = $this->sanitize( [ 'card_width' => 360 ] );
+ $hi = $this->sanitize( [ 'card_width' => 640 ] );
+ $this->assertSame( 360, $lo['card_width'] );
+ $this->assertSame( 640, $hi['card_width'] );
+ }
+
+ public function test_font_stack_round_trips_allowlist(): void {
+ foreach ( [ 'system', 'sans-modern', 'serif', 'mono', 'rounded' ] as $key ) {
+ $out = $this->sanitize( [ 'font_stack' => $key ] );
+ $this->assertSame( $key, $out['font_stack'], "key '{$key}' should round-trip" );
+ }
+ }
+
+ public function test_font_stack_rejects_unknown_key(): void {
+ $out = $this->sanitize( [ 'font_stack' => 'pirate' ] );
+ $this->assertSame( 'system', $out['font_stack'] );
+ }
+
+ public function test_font_stack_rejects_array_input(): void {
+ // is_scalar guard rejects array entirely; default survives.
+ $out = $this->sanitize( [ 'font_stack' => [ 'rounded' ] ] );
+ $this->assertSame( 'system', $out['font_stack'] );
+ }
+
+ public function test_defaults_returned_for_missing_keys(): void {
+ // Simulates v1.0 -> v1.1 upgrade: stored option has no new keys.
+ update_option( 'magicauth_settings', [ 'ttl_minutes' => 10 ] );
+ $settings = magicauth_get_settings();
+ $this->assertSame( '#eeeeee', $settings['page_color'] );
+ $this->assertSame( 6, $settings['card_radius'] );
+ $this->assertSame( 480, $settings['card_width'] );
+ $this->assertSame( 'system', $settings['font_stack'] );
+ }
+
+ public function test_link_color_accepts_empty(): void {
+ $out = $this->sanitize( [ 'link_color' => '' ] );
+ $this->assertSame( '', $out['link_color'] );
+ $this->assertEmpty( $this->settings_error_codes(), 'empty link_color should produce no notice' );
+ }
+
+ public function test_link_color_accepts_high_contrast_hex(): void {
+ $out = $this->sanitize( [ 'link_color' => '#0044aa' ] );
+ $this->assertSame( '#0044aa', $out['link_color'] );
+ $this->assertEmpty( $this->settings_error_codes(), 'AA+ link_color should be silent' );
+ }
+
+ public function test_link_color_rejects_invalid_hex(): void {
+ $out = $this->sanitize( [ 'link_color' => 'pirate' ] );
+ // Default link_color is empty; invalid input restores previous value.
+ $this->assertSame( '', $out['link_color'] );
+ $this->assertContains( 'magicauth_link_color_invalid', $this->settings_error_codes() );
+ }
+
+ public function test_link_color_rejects_unreadable_below_floor(): void {
+ // #bbbbbb on #ffffff ≈ 1.9:1 — well under the 2.5 hard floor.
+ $out = $this->sanitize( [ 'link_color' => '#bbbbbb' ] );
+ $this->assertSame( '', $out['link_color'], 'fail verdict must restore previous (empty default)' );
+ $this->assertContains( 'magicauth_link_color_unreadable', $this->settings_error_codes() );
+ }
+
+ public function test_link_color_saves_with_warning_in_warn_band(): void {
+ // #888888 on #ffffff ≈ 3.5:1 — between the 2.5 floor and AA 4.5.
+ $out = $this->sanitize( [ 'link_color' => '#888888' ] );
+ $this->assertSame( '#888888', $out['link_color'], 'warn verdict must still save' );
+ $this->assertContains( 'magicauth_link_color_low_contrast', $this->settings_error_codes() );
+ }
+
+ public function test_link_color_array_input_is_ignored(): void {
+ $out = $this->sanitize( [ 'link_color' => [ '#ff0000' ] ] );
+ $this->assertSame( '', $out['link_color'] );
+ }
+
+ public function test_color_mode_round_trips_allowlist(): void {
+ foreach ( [ 'light', 'dark', 'auto' ] as $mode ) {
+ $out = $this->sanitize( [ 'color_mode' => $mode ] );
+ $this->assertSame( $mode, $out['color_mode'], "mode '{$mode}' should round-trip" );
+ }
+ }
+
+ public function test_color_mode_rejects_unknown(): void {
+ $out = $this->sanitize( [ 'color_mode' => 'midnight' ] );
+ $this->assertSame( 'light', $out['color_mode'] );
+ }
+
+ public function test_color_mode_rejects_array_input(): void {
+ $out = $this->sanitize( [ 'color_mode' => [ 'dark' ] ] );
+ // Default survives via is_scalar guard.
+ $this->assertSame( 'light', $out['color_mode'] );
+ }
+
+ public function test_brand_dark_contrast_warning_fires_for_low_contrast(): void {
+ $out = $this->sanitize( [
+ 'brand_color' => '#444444',
+ 'color_mode' => 'dark',
+ ] );
+ $this->assertSame( '#444444', $out['brand_color'] );
+ $this->assertSame( 'dark', $out['color_mode'] );
+ $this->assertContains( 'magicauth_brand_dark_contrast', $this->settings_error_codes() );
+ }
+
+ public function test_brand_dark_contrast_silent_in_light_mode(): void {
+ // Same low-contrast brand, but light mode — no dark-surface check applies.
+ $out = $this->sanitize( [
+ 'brand_color' => '#444444',
+ 'color_mode' => 'light',
+ ] );
+ $this->assertNotContains( 'magicauth_brand_dark_contrast', $this->settings_error_codes() );
+ }
+
+ public function test_background_id_zero_clears(): void {
+ $out = $this->sanitize( [ 'background_attachment_id' => 0 ] );
+ $this->assertSame( 0, $out['background_attachment_id'] );
+ $this->assertEmpty( $this->settings_error_codes(), 'zero should be silent' );
+ }
+
+ public function test_background_id_rejects_non_image_attachment(): void {
+ $path = $this->make_temp_file( '.png', $this->one_pixel_png_bytes() );
+ try {
+ magicauth_test_register_attachment_file( 42, $path, false /* is_image = false */ );
+ $out = $this->sanitize( [ 'background_attachment_id' => 42 ] );
+ $this->assertSame( 0, $out['background_attachment_id'] );
+ $this->assertContains( 'magicauth_bg_not_image', $this->settings_error_codes() );
+ } finally {
+ @unlink( $path );
+ }
+ }
+
+ public function test_background_id_accepts_valid_png(): void {
+ $path = $this->make_temp_file( '.png', $this->one_pixel_png_bytes() );
+ try {
+ magicauth_test_register_attachment_file( 7, $path, true );
+ $out = $this->sanitize( [ 'background_attachment_id' => 7 ] );
+ $this->assertSame( 7, $out['background_attachment_id'] );
+ $this->assertEmpty( $this->settings_error_codes(), 'valid PNG should be silent' );
+ } finally {
+ @unlink( $path );
+ }
+ }
+
+ public function test_background_id_rejects_svg(): void {
+ $path = $this->make_temp_file( '.svg', '' );
+ try {
+ magicauth_test_register_attachment_file( 11, $path, true );
+ $out = $this->sanitize( [ 'background_attachment_id' => 11 ] );
+ $this->assertSame( 0, $out['background_attachment_id'] );
+ $this->assertContains( 'magicauth_bg_bad_ext', $this->settings_error_codes() );
+ } finally {
+ @unlink( $path );
+ }
+ }
+
+ public function test_background_id_returns_zero_when_path_missing(): void {
+ // Image flag set but no path registered → get_attached_file() returns false.
+ global $magicauth_test_state;
+ $magicauth_test_state['attachment_is_image'][ 99 ] = true;
+ $out = $this->sanitize( [ 'background_attachment_id' => 99 ] );
+ $this->assertSame( 0, $out['background_attachment_id'] );
+ }
+
+ private function make_temp_file( string $extension, string $bytes ): string {
+ $path = sys_get_temp_dir() . '/magicauth-bg-test-' . uniqid( '', true ) . $extension;
+ file_put_contents( $path, $bytes );
+ return $path;
+ }
+
+ private function one_pixel_png_bytes(): string {
+ // Minimal 1×1 transparent PNG — finfo identifies as image/png.
+ return base64_decode(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
+ true
+ ) ?: '';
+ }
+
+ /** @return list */
+ private function settings_error_codes(): array {
+ $errors = $GLOBALS['magicauth_test_state']['settings_errors'] ?? [];
+ return array_map( static fn ( $e ) => (string) ( $e['code'] ?? '' ), $errors );
+ }
+
+ public function test_font_stacks_in_shell_template_contain_no_html_meta_chars(): void {
+ // HTML parsers treat '' as a block-terminator regardless of CSS
+ // quoting. Pin a static assertion so a future maintainer adding e.g.
+ // 'fancy' => 'Helvetica' cannot silently break the