| @@ -22,39 +22,24 @@ | ||
| 22 | 22 | |
| 23 | 23 | defined( 'ABSPATH' ) || exit; |
| 24 | 24 | |
| 25 | 25 | /** |
| 26 | - * Returns Mio configuration for the current user. | |
| 26 | + * Mio's shipped look and feel, before any filter. | |
| 27 | 27 | * |
| 28 | - * Shape mirrors `MioConfig` in `src/mio/types.ts`: | |
| 28 | + * Split out of {@see openstation_mio_config()} so a consumer that is | |
| 29 | + * NOT the current user's own companion can start from the reference | |
| 30 | + * design. An agent's portrait is exactly that: `openstation_mio_config` | |
| 31 | + * is a filter about the desk companion this person sees, and letting it | |
| 32 | + * silently restyle every agent's face on the site would be a surprise | |
| 33 | + * with no way to opt out. | |
| 29 | 34 | * |
| 30 | - * array( | |
| 31 | - * 'appearance' => array( radius, bodyColor, bodyAlpha, hueStart, | |
| 32 | - * hueSpan, hueDrift, hueLoop, hueAngle, | |
| 33 | - * saturation, lightness, iridescence, | |
| 34 | - * outlineWidth, glow, glowBlur, | |
| 35 | - * eyeColor, eyeScale ), | |
| 36 | - * 'physics' => array( points, shapePreset, shapeLobes, | |
| 37 | - * shapeAmount, shapeAngle, shapeShuffle, | |
| 38 | - * radialStiffness, edgeStiffness, | |
| 39 | - * bendStiffness, pressure, damping, | |
| 40 | - * airDamping, magnetStrength, magnetRange, | |
| 41 | - * magnetGrip, magnetDamping, floatAmplitude, | |
| 42 | - * floatSpeed, idleWobble, idleWobbleSpeed, | |
| 43 | - * speedStretch, friction, restitution, | |
| 44 | - * dragStiffness, throwBoost, minStretch, | |
| 45 | - * maxStretch, minAngularGap, | |
| 46 | - * limitIterations, dragMaxAccel, subStep, | |
| 47 | - * maxSubSteps ), | |
| 48 | - * ) | |
| 35 | + * Shape mirrors `MIO_DEFAULTS` in `src/mio/config.ts`, and | |
| 36 | + * `tests/vitest/mio-defaults-parity.test.ts` holds the two together. | |
| 49 | 37 | * |
| 50 | - * Colours may be given as integers (`0x05050a`) or CSS hex strings | |
| 51 | - * (`'#05050a'`); the client accepts both. | |
| 52 | - * | |
| 53 | 38 | * @return array Mio configuration. |
| 54 | 39 | */ |
| 55 | -function openstation_mio_config() { | |
| 56 | - $defaults = array( | |
| 40 | +function openstation_mio_default_config() { | |
| 41 | + return array( | |
| 57 | 42 | 'appearance' => array( |
| 58 | 43 | 'radius' => 56, |
| 59 | 44 | // Void, the palette's base. The brand's own Mio is |
| 60 | 45 | // `fill="none"` over the Void page; the shell floats over |
| @@ -91,9 +76,22 @@ | ||
| 91 | 76 | // The official artwork has no hologram and no interior |
| 92 | 77 | // sheen — a flat gradient over dead black. One number here |
| 93 | 78 | // turns both back on for a whole site. |
| 94 | 79 | 'iridescence' => 0, |
| 95 | - 'outlineWidth' => 3, | |
| 80 | + // The artwork's ring is 13 units on a body of roughly 240 — | |
| 81 | + // 5.4%, or 6px at this radius. That 6 is the whole drawn | |
| 82 | + // ring, chroma and white line together, split two to one. | |
| 83 | + 'outlineWidth' => 4, | |
| 84 | + // The white line the artwork draws between the body and the | |
| 85 | + // chroma. It reaches INWARD, so thickening it eats into the | |
| 86 | + // body rather than widening the ring — 'outlineWidth' keeps | |
| 87 | + // meaning the coloured band whatever this says. | |
| 88 | + // | |
| 89 | + // Must match `MIO_DEFAULTS` in `src/mio/config.ts`. | |
| 90 | + 'linerWidth' => 2, | |
| 91 | + // Starlight again: the brand has one white, and the line and | |
| 92 | + // the eyes are both drawn in it. | |
| 93 | + 'linerColor' => '#fffbff', | |
| 96 | 94 | // Reach of the light, as a multiple of Mio's own radius: |
| 97 | 95 | // `10` carries the wash about one and a half radii past the |
| 98 | 96 | // outline. Deliberately generous — Mio sits on a dark desk |
| 99 | 97 | // and the glow is the thing that makes her read as lit |
| @@ -155,9 +153,43 @@ | ||
| 155 | 153 | 'subStep' => 1 / 240, |
| 156 | 154 | 'maxSubSteps' => 8, |
| 157 | 155 | ), |
| 158 | 156 | ); |
| 157 | +} | |
| 159 | 158 | |
| 159 | +/** | |
| 160 | + * Returns Mio configuration for the current user. | |
| 161 | + * | |
| 162 | + * Shape mirrors `MioConfig` in `src/mio/types.ts`: | |
| 163 | + * | |
| 164 | + * array( | |
| 165 | + * 'appearance' => array( radius, bodyColor, bodyAlpha, hueStart, | |
| 166 | + * hueSpan, hueDrift, hueLoop, hueAngle, | |
| 167 | + * saturation, lightness, iridescence, | |
| 168 | + * outlineWidth, linerWidth, linerColor, | |
| 169 | + * glow, glowBlur, eyeColor, eyeScale ), | |
| 170 | + * 'physics' => array( points, shapePreset, shapeLobes, | |
| 171 | + * shapeAmount, shapeAngle, shapeShuffle, | |
| 172 | + * radialStiffness, edgeStiffness, | |
| 173 | + * bendStiffness, pressure, damping, | |
| 174 | + * airDamping, magnetStrength, magnetRange, | |
| 175 | + * magnetGrip, magnetDamping, floatAmplitude, | |
| 176 | + * floatSpeed, idleWobble, idleWobbleSpeed, | |
| 177 | + * speedStretch, friction, restitution, | |
| 178 | + * dragStiffness, throwBoost, minStretch, | |
| 179 | + * maxStretch, minAngularGap, | |
| 180 | + * limitIterations, dragMaxAccel, subStep, | |
| 181 | + * maxSubSteps ), | |
| 182 | + * ) | |
| 183 | + * | |
| 184 | + * Colours may be given as integers (`0x05050a`) or CSS hex strings | |
| 185 | + * (`'#05050a'`); the client accepts both. | |
| 186 | + * | |
| 187 | + * @return array Mio configuration. | |
| 188 | + */ | |
| 189 | +function openstation_mio_config() { | |
| 190 | + $defaults = openstation_mio_default_config(); | |
| 191 | + | |
| 160 | 192 | /** |
| 161 | 193 | * Filters Mio's appearance and physics. |
| 162 | 194 | * |
| 163 | 195 | * Runs once per shell render. Returning a partial array is fine — |
| @@ -203,8 +235,10 @@ | ||
| 203 | 235 | 'saturation', |
| 204 | 236 | 'lightness', |
| 205 | 237 | 'iridescence', |
| 206 | 238 | 'outlineWidth', |
| 239 | + 'linerWidth', | |
| 240 | + 'linerColor', | |
| 207 | 241 | 'glow', |
| 208 | 242 | 'glowBlur', |
| 209 | 243 | 'eyeColor', |
| 210 | 244 | 'eyeScale', |
| @@ -234,8 +268,161 @@ | ||
| 234 | 268 | ); |
| 235 | 269 | } |
| 236 | 270 | |
| 237 | 271 | /** |
| 272 | + * Numeric ranges every look value is held inside. | |
| 273 | + * | |
| 274 | + * Mirrors `LIMITS` in `src/mio/config.ts`, and exists for the same | |
| 275 | + * reason the client one does: the shipped values are a design, not a | |
| 276 | + * boundary, and everything downstream of them assumes a sane number. | |
| 277 | + * | |
| 278 | + * Only the keys a stored look may carry are listed. The rest of | |
| 279 | + * `MioPhysics` is spring constants the panel deliberately never | |
| 280 | + * exposes. | |
| 281 | + * | |
| 282 | + * @return array<string, array{0: float, 1: float}> | |
| 283 | + */ | |
| 284 | +function openstation_mio_look_limits() { | |
| 285 | + return array( | |
| 286 | + 'radius' => array( 16, 220 ), | |
| 287 | + 'bodyAlpha' => array( 0, 1 ), | |
| 288 | + 'hueStart' => array( -720, 720 ), | |
| 289 | + 'hueSpan' => array( -360, 360 ), | |
| 290 | + 'hueDrift' => array( -180, 180 ), | |
| 291 | + 'hueAngle' => array( -360, 360 ), | |
| 292 | + 'hueSpin' => array( -180, 180 ), | |
| 293 | + 'saturation' => array( 0, 1 ), | |
| 294 | + 'lightness' => array( 0.15, 1 ), | |
| 295 | + 'iridescence' => array( 0, 2 ), | |
| 296 | + 'outlineWidth' => array( 0.5, 24 ), | |
| 297 | + 'linerWidth' => array( 0, 12 ), | |
| 298 | + 'glow' => array( 0, 20 ), | |
| 299 | + 'eyeScale' => array( 0.05, 0.6 ), | |
| 300 | + 'shapeLobes' => array( 0, 8 ), | |
| 301 | + 'shapeAmount' => array( 0, 1.4 ), | |
| 302 | + 'shapeAngle' => array( -360, 360 ), | |
| 303 | + 'shapeShuffle' => array( 0, 3600 ), | |
| 304 | + 'idleWobble' => array( 0, 0.4 ), | |
| 305 | + 'idleWobbleSpeed' => array( 0, 8 ), | |
| 306 | + ); | |
| 307 | +} | |
| 308 | + | |
| 309 | +/** | |
| 310 | + * Every silhouette `shapePreset` accepts. Mirrors `SHAPE_PRESETS`. | |
| 311 | + * | |
| 312 | + * @return string[] | |
| 313 | + */ | |
| 314 | +function openstation_mio_shape_presets() { | |
| 315 | + return array( | |
| 316 | + 'circle', | |
| 317 | + 'blob', | |
| 318 | + 'ghost', | |
| 319 | + 'potato', | |
| 320 | + 'star', | |
| 321 | + 'flower', | |
| 322 | + 'heart', | |
| 323 | + 'diamond', | |
| 324 | + 'drop', | |
| 325 | + 'cloud', | |
| 326 | + 'custom', | |
| 327 | + ); | |
| 328 | +} | |
| 329 | + | |
| 330 | +/** | |
| 331 | + * Coerce a colour to a 24-bit int. Mirrors `color()` in `config.ts`. | |
| 332 | + * | |
| 333 | + * The shipped defaults write colours as CSS hex strings because that is | |
| 334 | + * what reads well in a config array; the renderers want integers. | |
| 335 | + * | |
| 336 | + * @param mixed $candidate Colour as int or `#rrggbb` / `#rgb` string. | |
| 337 | + * @param int $fallback Value to use when the candidate is unusable. | |
| 338 | + * @return int Packed 24-bit colour. | |
| 339 | + */ | |
| 340 | +function openstation_mio_color_int( $candidate, $fallback = 0 ) { | |
| 341 | + if ( is_int( $candidate ) || is_float( $candidate ) ) { | |
| 342 | + if ( ! is_finite( (float) $candidate ) ) { | |
| 343 | + return $fallback; | |
| 344 | + } | |
| 345 | + return (int) min( 0xffffff, max( 0, floor( $candidate ) ) ); | |
| 346 | + } | |
| 347 | + if ( is_string( $candidate ) ) { | |
| 348 | + $hex = ltrim( trim( $candidate ), '#' ); | |
| 349 | + if ( preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) { | |
| 350 | + return (int) hexdec( $hex ); | |
| 351 | + } | |
| 352 | + if ( preg_match( '/^[0-9a-fA-F]{3}$/', $hex ) ) { | |
| 353 | + return (int) hexdec( $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2] ); | |
| 354 | + } | |
| 355 | + } | |
| 356 | + return $fallback; | |
| 357 | +} | |
| 358 | + | |
| 359 | +/** | |
| 360 | + * Hold a look inside its ranges and resolve it against the defaults. | |
| 361 | + * | |
| 362 | + * `openstation_sanitize_mio_look()` is a SHAPE check: right keys, | |
| 363 | + * right kinds. That was enough while every look was handed straight to | |
| 364 | + * the client, where `sanitizeMioConfig()` clamped it before anything | |
| 365 | + * drew with it. It is not enough now. A stored look reaches a PHP | |
| 366 | + * renderer that samples trigonometry and builds a path, so an | |
| 367 | + * `outlineWidth` of -400 or a `shapeAmount` of 1e9 arrives as a number | |
| 368 | + * nobody checked. | |
| 369 | + * | |
| 370 | + * The result is a COMPLETE config with integer colours, ready to draw: | |
| 371 | + * unlike the sanitizer, this is not what you store. | |
| 372 | + * | |
| 373 | + * `shapeShuffle` is dropped rather than clamped. It means "pick a new | |
| 374 | + * silhouette every so often", which is meaningless in a still portrait | |
| 375 | + * and would be a bug if anything ever honoured it there. | |
| 376 | + * | |
| 377 | + * @param mixed $raw Stored look, or anything at all. | |
| 378 | + * @return array Complete `array( 'appearance' => ..., 'physics' => ... )`. | |
| 379 | + */ | |
| 380 | +function openstation_mio_clamp_look( $raw ) { | |
| 381 | + $defaults = openstation_mio_default_config(); | |
| 382 | + $limits = openstation_mio_look_limits(); | |
| 383 | + $look = openstation_sanitize_mio_look( $raw ); | |
| 384 | + | |
| 385 | + $resolve = static function ( $group, $overrides ) use ( $limits ) { | |
| 386 | + $out = array(); | |
| 387 | + foreach ( $group as $key => $default ) { | |
| 388 | + $value = array_key_exists( $key, $overrides ) ? $overrides[ $key ] : $default; | |
| 389 | + | |
| 390 | + if ( 'shapePreset' === $key ) { | |
| 391 | + $presets = openstation_mio_shape_presets(); | |
| 392 | + $out[ $key ] = in_array( $value, $presets, true ) ? $value : $default; | |
| 393 | + continue; | |
| 394 | + } | |
| 395 | + if ( 'bodyColor' === $key || 'eyeColor' === $key || 'linerColor' === $key ) { | |
| 396 | + $out[ $key ] = openstation_mio_color_int( $value, openstation_mio_color_int( $default ) ); | |
| 397 | + continue; | |
| 398 | + } | |
| 399 | + if ( is_bool( $default ) ) { | |
| 400 | + $out[ $key ] = is_bool( $value ) ? $value : $default; | |
| 401 | + continue; | |
| 402 | + } | |
| 403 | + if ( ! is_numeric( $value ) || ! is_finite( (float) $value ) ) { | |
| 404 | + $value = $default; | |
| 405 | + } | |
| 406 | + if ( isset( $limits[ $key ] ) ) { | |
| 407 | + $value = min( $limits[ $key ][1], max( $limits[ $key ][0], (float) $value ) ); | |
| 408 | + } | |
| 409 | + $out[ $key ] = $value; | |
| 410 | + } | |
| 411 | + return $out; | |
| 412 | + }; | |
| 413 | + | |
| 414 | + $physics = $resolve( $defaults['physics'], $look['physics'] ); | |
| 415 | + // A face that changed silhouette on a timer is not a portrait. | |
| 416 | + $physics['shapeShuffle'] = 0; | |
| 417 | + | |
| 418 | + return array( | |
| 419 | + 'appearance' => $resolve( $defaults['appearance'], $look['appearance'] ), | |
| 420 | + 'physics' => $physics, | |
| 421 | + ); | |
| 422 | +} | |
| 423 | + | |
| 424 | +/** | |
| 238 | 425 | * Sanitizes a user's saved Mio look for storage in user meta. |
| 239 | 426 | * |
| 240 | 427 | * **A shape check, not a clamp.** It answers "are these the right keys |
| 241 | 428 | * carrying the right kinds of value" and nothing more. Deciding what a |
| @@ -289,9 +476,9 @@ | ||
| 289 | 476 | $clean[ $group ][ $key ] = 0 + $value; |
| 290 | 477 | } |
| 291 | 478 | } elseif ( is_string( $value ) ) { |
| 292 | 479 | // The only string-valued keys are `shapePreset` and the |
| 293 | - // two colours in `#rrggbb` form. | |
| 480 | + // three colours in `#rrggbb` form. | |
| 294 | 481 | $clean[ $group ][ $key ] = sanitize_text_field( $value ); |
| 295 | 482 | } |
| 296 | 483 | } |
| 297 | 484 | } |
| @@ -296,5 +483,52 @@ | ||
| 296 | 483 | } |
| 297 | 484 | } |
| 298 | 485 | |
| 299 | 486 | return $clean; |
| 487 | +} | |
| 488 | + | |
| 489 | +/** | |
| 490 | + * Narrow a partial look to the keys it carried, with every number in range. | |
| 491 | + * | |
| 492 | + * Two passes that answer different questions. | |
| 493 | + * {@see openstation_sanitize_mio_look()} asks "are these the right keys | |
| 494 | + * carrying the right kinds of value", and keeps only what was actually | |
| 495 | + * set. {@see openstation_mio_clamp_look()} then asks "is every number | |
| 496 | + * inside its range", because a stored look now reaches a PHP renderer | |
| 497 | + * that samples trigonometry and builds a path. The clamp resolves | |
| 498 | + * against the defaults and hands back a *complete* config, which is | |
| 499 | + * what you draw with and not what you store, so the carried keys are | |
| 500 | + * picked back out of it afterwards. | |
| 501 | + * | |
| 502 | + * Keeping only the overridden keys is what lets a future change to the | |
| 503 | + * shipped Mio still show through wherever nobody had an opinion. | |
| 504 | + * | |
| 505 | + * Lives here rather than beside the agent store because two callers on | |
| 506 | + * different sides of the feature flag need it: the store, when an agent | |
| 507 | + * saves a face, and the WP Explorer config, when the flag is off and | |
| 508 | + * the section previews the cast it would seed. One owner of the rule | |
| 509 | + * means the preview cannot draw a face the seeder would not store. | |
| 510 | + * | |
| 511 | + * @param mixed $raw Raw look (array), from the client or from our own data. | |
| 512 | + * @return array { | |
| 513 | + * @type array $appearance Clamped appearance overrides. | |
| 514 | + * @type array $physics Clamped silhouette + idle overrides. | |
| 515 | + * } | |
| 516 | + */ | |
| 517 | +function openstation_mio_narrow_look( $raw ) { | |
| 518 | + $look = openstation_sanitize_mio_look( $raw ); | |
| 519 | + $clamped = openstation_mio_clamp_look( $look ); | |
| 520 | + $out = array( | |
| 521 | + 'appearance' => array(), | |
| 522 | + 'physics' => array(), | |
| 523 | + ); | |
| 524 | + | |
| 525 | + foreach ( array( 'appearance', 'physics' ) as $group ) { | |
| 526 | + foreach ( array_keys( $look[ $group ] ) as $key ) { | |
| 527 | + if ( array_key_exists( $key, $clamped[ $group ] ) ) { | |
| 528 | + $out[ $group ][ $key ] = $clamped[ $group ][ $key ]; | |
| 529 | + } | |
| 530 | + } | |
| 531 | + } | |
| 532 | + | |
| 533 | + return $out; | |
| 300 | 534 | } |