PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Services / Theme / ColorMath.php

ColorMath.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.6, at app/Services/Theme/ColorMath.php

318 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Theme;
4
5 /**
6 * Colour maths for the storefront palette.
7 *
8 * A block theme hands us a handful of palette colours. The storefront needs a
9 * coherent set of surfaces, borders, muted text and accent tints. Rather than
10 * invent those, we derive them from the colours the theme actually declares —
11 * a border is the body text mixed most of the way toward the surface, a muted
12 * text tone is the same mix stopped earlier, and button text is whichever of
13 * light/dark reads better on the button it sits on.
14 *
15 * All maths is sRGB. Luminance follows the WCAG 2.x relative-luminance
16 * definition so the contrast picks match what an accessibility checker sees.
17 */
18 class ColorMath
19 {
20 /**
21 * Parse a colour into an RGB triplet.
22 *
23 * Accepts #rgb, #rrggbb, #rrggbbaa and rgb()/rgba(). Returns null for
24 * anything unreadable so callers can fall back instead of guessing.
25 *
26 * @param string $color
27 * @return array|null [r, g, b] or null
28 */
29 public static function parse($color): ?array
30 {
31 $color = trim((string)$color);
32
33 if ($color === '') {
34 return null;
35 }
36
37 if (preg_match('/^#([0-9a-f]{3})$/i', $color, $matches)) {
38 $shorthand = $matches[1];
39
40 return [
41 hexdec(str_repeat($shorthand[0], 2)),
42 hexdec(str_repeat($shorthand[1], 2)),
43 hexdec(str_repeat($shorthand[2], 2)),
44 ];
45 }
46
47 if (preg_match('/^#([0-9a-f]{6})(?:[0-9a-f]{2})?$/i', $color, $matches)) {
48 $sixDigit = $matches[1];
49
50 return [
51 hexdec(substr($sixDigit, 0, 2)),
52 hexdec(substr($sixDigit, 2, 2)),
53 hexdec(substr($sixDigit, 4, 2)),
54 ];
55 }
56
57 if (preg_match('/^rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)/i', $color, $matches)) {
58 return [
59 (int)round((float)$matches[1]),
60 (int)round((float)$matches[2]),
61 (int)round((float)$matches[3]),
62 ];
63 }
64
65 return null;
66 }
67
68 /**
69 * Convert an RGB triplet to #rrggbb.
70 *
71 * @param array $rgb
72 * @return string
73 */
74 public static function toHex(array $rgb): string
75 {
76 $hex = '#';
77
78 foreach (array_slice($rgb, 0, 3) as $channel) {
79 $channel = max(0, min(255, (int)round($channel)));
80 $hex .= str_pad(dechex($channel), 2, '0', STR_PAD_LEFT);
81 }
82
83 return $hex;
84 }
85
86 /**
87 * Normalise any readable colour to #rrggbb.
88 *
89 * @param string $color
90 * @param string $fallback Returned when the colour cannot be parsed.
91 * @return string
92 */
93 public static function hex($color, string $fallback = ''): string
94 {
95 $rgb = self::parse($color);
96
97 return $rgb ? self::toHex($rgb) : $fallback;
98 }
99
100 /**
101 * Mix two colours.
102 *
103 * @param string $first
104 * @param string $second
105 * @param int $percent How much of $first to keep, 0-100.
106 * @return string Hex, or '' when neither colour is readable.
107 */
108 public static function mix($first, $second, int $percent = 50): string
109 {
110 $firstRgb = self::parse($first);
111 $secondRgb = self::parse($second);
112
113 if (!$firstRgb || !$secondRgb) {
114 return self::hex($firstRgb ? $first : $second);
115 }
116
117 $ratio = max(0, min(100, $percent)) / 100;
118 $mixed = [];
119
120 for ($channel = 0; $channel < 3; $channel++) {
121 $mixed[$channel] = ($firstRgb[$channel] * $ratio) + ($secondRgb[$channel] * (1 - $ratio));
122 }
123
124 return self::toHex($mixed);
125 }
126
127 /**
128 * WCAG relative luminance.
129 *
130 * @param string $color
131 * @return float 0-1, or -1 when the colour cannot be read.
132 */
133 public static function luminance($color): float
134 {
135 $rgb = self::parse($color);
136
137 if (!$rgb) {
138 return -1.0;
139 }
140
141 $linear = [];
142
143 foreach (array_slice($rgb, 0, 3) as $channel) {
144 $channel = $channel / 255;
145 $linear[] = $channel <= 0.03928
146 ? $channel / 12.92
147 : pow(($channel + 0.055) / 1.055, 2.4);
148 }
149
150 return (0.2126 * $linear[0]) + (0.7152 * $linear[1]) + (0.0722 * $linear[2]);
151 }
152
153 /**
154 * Whether a colour reads as dark.
155 *
156 * The 0.45 threshold sits above the midpoint on purpose: mid-tone brand
157 * colours read as "dark" to the eye well before their luminance halves.
158 *
159 * @param string $color
160 * @return bool
161 */
162 public static function isDark($color): bool
163 {
164 $luminance = self::luminance($color);
165
166 return $luminance >= 0 && $luminance < 0.45;
167 }
168
169 /**
170 * WCAG contrast ratio between two colours.
171 *
172 * @param string $first
173 * @param string $second
174 * @return float 1-21, or 0 when either colour cannot be read.
175 */
176 public static function contrast($first, $second): float
177 {
178 $firstLuminance = self::luminance($first);
179 $secondLuminance = self::luminance($second);
180
181 if ($firstLuminance < 0 || $secondLuminance < 0) {
182 return 0.0;
183 }
184
185 $lighter = max($firstLuminance, $secondLuminance);
186 $darker = min($firstLuminance, $secondLuminance);
187
188 return ($lighter + 0.05) / ($darker + 0.05);
189 }
190
191 /**
192 * Pick whichever of two text colours reads better on a background.
193 *
194 * @param string $background
195 * @param string $light
196 * @param string $dark
197 * @return string
198 */
199 public static function readableOn($background, string $light = '#ffffff', string $dark = '#1f2937'): string
200 {
201 return self::contrast($background, $light) >= self::contrast($background, $dark) ? $light : $dark;
202 }
203
204 /**
205 * A text colour that reads on a background (WCAG 4.5:1) wherever one can.
206 *
207 * readableOn()'s usual pair is kept whenever it reads, so every partner it
208 * already made readable is unchanged. On a mid-tone background neither
209 * half of that pair reaches 4.5:1 (#809400 peaks at 4.30:1 with #1f2937);
210 * pure black or white — whichever reads better — goes further, and is
211 * taken only when it does.
212 *
213 * @param string $background
214 * @return string
215 */
216 public static function readableText($background): string
217 {
218 $picked = self::readableOn($background);
219 $pickedContrast = self::contrast($background, $picked);
220
221 if ($pickedContrast >= 4.5) {
222 return $picked;
223 }
224
225 $extreme = self::contrast($background, '#ffffff') >= self::contrast($background, '#000000')
226 ? '#ffffff'
227 : '#000000';
228
229 return self::contrast($background, $extreme) > $pickedContrast ? $extreme : $picked;
230 }
231
232 /**
233 * Mix text into a surface, keeping as little of the text as still reads.
234 *
235 * Starts at $percent of the text and moves toward it in small steps until
236 * the mix reaches $minimum contrast on the surface — a mix that already
237 * reads is returned unchanged. Text that does not read on the surface
238 * itself comes back as the text: nothing between the two reads better.
239 *
240 * @param string $text
241 * @param string $surface
242 * @param int $percent Starting share of the text, 0-100.
243 * @param float $minimum
244 * @return string Hex, or '' when either colour is unreadable.
245 */
246 public static function readableMix($text, $surface, int $percent, float $minimum = 4.5): string
247 {
248 if (!self::parse($text) || !self::parse($surface)) {
249 return '';
250 }
251
252 for ($share = max(0, $percent); $share < 100; $share += 2) {
253 $mixed = self::mix($text, $surface, $share);
254
255 if (self::contrast($surface, $mixed) >= $minimum) {
256 return $mixed;
257 }
258 }
259
260 return self::hex($text);
261 }
262
263 /**
264 * Move a colour away from itself — lighter when it is dark, darker when it
265 * is light. How a hover is derived from a button that states none.
266 *
267 * @param string $color
268 * @param int $percent
269 * @return string
270 */
271 public static function shiftFromItself($color, int $percent = 12): string
272 {
273 return self::isDark($color)
274 ? self::lighten($color, $percent)
275 : self::darken($color, $percent);
276 }
277
278 /**
279 * Nudge a colour toward white.
280 *
281 * @param string $color
282 * @param int $percent
283 * @return string
284 */
285 public static function lighten($color, int $percent): string
286 {
287 return self::mix('#ffffff', $color, $percent);
288 }
289
290 /**
291 * Nudge a colour toward black.
292 *
293 * @param string $color
294 * @param int $percent
295 * @return string
296 */
297 public static function darken($color, int $percent): string
298 {
299 return self::mix('#000000', $color, $percent);
300 }
301
302 /**
303 * Move a colour away from the surface it sits on, so it stays visible on
304 * a light or a dark background without the caller knowing which it has.
305 *
306 * @param string $color
307 * @param string $background
308 * @param int $percent
309 * @return string
310 */
311 public static function awayFrom($color, $background, int $percent): string
312 {
313 return self::isDark($background)
314 ? self::lighten($color, $percent)
315 : self::darken($color, $percent);
316 }
317 }
318