PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / mio.php

mio.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.8, at includes/mio.php

535 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Mio.
4 *
5 * Server side of the desk companion: the appearance / physics
6 * defaults shipped to the shell, and the filter plugins use to
7 * restyle or re-tune it.
8 *
9 * Mio itself is a lazy JS bundle (`assets/js/mio[.min].js`)
10 * that the shell injects the first time a user switches it on from
11 * the wallpaper context menu. Nothing here enqueues anything — the
12 * bundle URL travels in the shell config as `mioBundleUrl`, and
13 * the on/off preference lives in OS Settings as `mioEnabled`.
14 *
15 * Every value is re-clamped client-side in
16 * `src/mio/config.ts::sanitizeMioConfig()`, so a filter that
17 * returns nonsense produces a plain-looking Mio rather than a
18 * broken shell.
19 *
20 * @package OpenStation
21 */
22
23 defined( 'ABSPATH' ) || exit;
24
25 /**
26 * Mio's shipped look and feel, before any filter.
27 *
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.
34 *
35 * Shape mirrors `MIO_DEFAULTS` in `src/mio/config.ts`, and
36 * `tests/vitest/mio-defaults-parity.test.ts` holds the two together.
37 *
38 * @return array Mio configuration.
39 */
40 function openstation_mio_default_config() {
41 return array(
42 'appearance' => array(
43 'radius' => 56,
44 // Void, the palette's base. The brand's own Mio is
45 // `fill="none"` over the Void page; the shell floats over
46 // whatever wallpaper the user picked, so it fills the body
47 // with the colour that background is. Not '#000000', which
48 // is not in the palette.
49 'bodyColor' => '#0c0b0f',
50 'bodyAlpha' => 1,
51 // Read off Miomesh, Mio's own gradient in the OpenStation
52 // brand guidelines: four stops from #F252FC (Pulse, hue
53 // 296.5) through #AA67FF and #A580FF to #4B3EFF (hue 244).
54 // `hueAngle` pins Pulse where `mioGrad` starts, on the
55 // upper-left shoulder — 225 degrees clockwise from 3
56 // o'clock.
57 'hueStart' => 296.5,
58 'hueSpan' => -52.5,
59 'hueAngle' => 225,
60 // The official Mio holds still; hueLoop is what lets it,
61 // by walking the span out and back so the ring meets
62 // itself instead of ending a span away with a visible seam.
63 // Two kinds of still. hueDrift rewrites the hues, so Mio
64 // cycles through colours that are not its own — the one
65 // thing the official palette must never do. hueSpin turns
66 // the same sweep around the ring, keeping the palette, and
67 // is the most a default Mio should ever animate.
68 'hueDrift' => 0,
69 'hueSpin' => 0,
70 'hueLoop' => true,
71 'saturation' => 1,
72 // The ring's brightest point, not its average — the
73 // renderer rides a cosine hump from 0.72x to 1x over this.
74 // Miomesh's brightest stop, #A580FF, is 0.751.
75 'lightness' => 0.75,
76 // The official artwork has no hologram and no interior
77 // sheen — a flat gradient over dead black. One number here
78 // turns both back on for a whole site.
79 'iridescence' => 0,
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',
94 // Reach of the light, as a multiple of Mio's own radius:
95 // `10` carries the wash about one and a half radii past the
96 // outline. Deliberately generous — Mio sits on a dark desk
97 // and the glow is the thing that makes her read as lit
98 // rather than drawn. The slider runs to `20`.
99 //
100 // Must match `MIO_DEFAULTS` in `src/mio/config.ts`; this is
101 // the value the shell renders before a user has a look of
102 // their own, and the two disagreeing means Mio changes
103 // appearance the first time anything is saved.
104 'glow' => 10,
105 // No UI switches this off. Each glow pass is a ramp of
106 // concentric shells, and unblurred that ramp shows as the
107 // contour rings it is built from. It is here so a site that
108 // needs the two filter passes back for performance can drop
109 // them.
110 'glowBlur' => true,
111 // Starlight, the palette's white — what the brand's mascot
112 // fills its two eye pills with. Not '#ffffff'.
113 'eyeColor' => '#fffbff',
114 'eyeScale' => 0.3,
115 ),
116 'physics' => array(
117 'points' => 12,
118 // Silhouette: 'circle', 'blob', 'ghost', 'potato' or
119 // 'custom'. Nearly round, with a shallow dimple at the
120 // bottom centre.
121 'shapePreset' => 'blob',
122 // Only read by the 'custom' preset.
123 'shapeLobes' => 3,
124 'shapeAmount' => 1,
125 'shapeAngle' => 0,
126 // Seconds between Mio picking a new silhouette at
127 // random and morphing into it. 0 holds shapePreset.
128 'shapeShuffle' => 60,
129 'radialStiffness' => 460,
130 'edgeStiffness' => 540,
131 'bendStiffness' => 170,
132 'pressure' => 2400,
133 'damping' => 9,
134 'airDamping' => 0.5,
135 'magnetStrength' => 2200,
136 'magnetRange' => 260,
137 'magnetGrip' => 0.24,
138 'magnetDamping' => 7,
139 'floatAmplitude' => 10,
140 'floatSpeed' => 1.1,
141 'idleWobble' => 0.085,
142 'idleWobbleSpeed' => 0.55,
143 'speedStretch' => 0.3,
144 'friction' => 0.86,
145 'restitution' => 0.2,
146 'dragStiffness' => 480,
147 'throwBoost' => 1,
148 'minStretch' => 0.55,
149 'maxStretch' => 1.7,
150 'minAngularGap' => 0.25,
151 'limitIterations' => 3,
152 'dragMaxAccel' => 9000,
153 'subStep' => 1 / 240,
154 'maxSubSteps' => 8,
155 ),
156 );
157 }
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
192 /**
193 * Filters Mio's appearance and physics.
194 *
195 * Runs once per shell render. Returning a partial array is fine —
196 * anything missing falls back to the reference design, and every
197 * value is clamped client-side before it reaches the simulation.
198 *
199 * Example — a slower, heavier, teal mio:
200 *
201 * add_filter( 'openstation_mio_config', function ( $config ) {
202 * $config['appearance']['hueStart'] = 170;
203 * $config['appearance']['hueSpan'] = 40;
204 * $config['physics']['magnetStrength'] = 3400;
205 * return $config;
206 * } );
207 *
208 * @param array $defaults Default configuration, as documented above.
209 */
210 $config = apply_filters( 'openstation_mio_config', $defaults );
211
212 return is_array( $config ) ? $config : $defaults;
213 }
214
215 /**
216 * Appearance keys a stored user look may carry.
217 *
218 * Mirrors `APPEARANCE_KEYS` in `src/mio/look.ts`. A whitelist rather
219 * than "whatever the client sent", because this lands in user meta:
220 * an unbounded key set is an unbounded row.
221 *
222 * @return string[]
223 */
224 function openstation_mio_look_appearance_keys() {
225 return array(
226 'radius',
227 'bodyColor',
228 'bodyAlpha',
229 'hueStart',
230 'hueSpan',
231 'hueDrift',
232 'hueLoop',
233 'hueAngle',
234 'hueSpin',
235 'saturation',
236 'lightness',
237 'iridescence',
238 'outlineWidth',
239 'linerWidth',
240 'linerColor',
241 'glow',
242 'glowBlur',
243 'eyeColor',
244 'eyeScale',
245 );
246 }
247
248 /**
249 * Physics keys a stored user look may carry.
250 *
251 * Mirrors `LOOK_PHYSICS_KEYS` in `src/mio/look.ts`. Every one of them
252 * modulates a rest length. The spring constants are deliberately
253 * absent: they are the site's, they interact, and a stored preference
254 * that could reach them would be a way for a corrupt row to make Mio
255 * unstable.
256 *
257 * @return string[]
258 */
259 function openstation_mio_look_physics_keys() {
260 return array(
261 'shapePreset',
262 'shapeLobes',
263 'shapeAmount',
264 'shapeAngle',
265 'shapeShuffle',
266 'idleWobble',
267 'idleWobbleSpeed',
268 );
269 }
270
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 /**
425 * Sanitizes a user's saved Mio look for storage in user meta.
426 *
427 * **A shape check, not a clamp.** It answers "are these the right keys
428 * carrying the right kinds of value" and nothing more. Deciding what a
429 * legal hue, silhouette or spring constant is stays with
430 * `sanitizeMioConfig()` in `src/mio/config.ts`, which runs on
431 * everything headed for the simulation whatever route it arrived by.
432 * Two validators with overlapping opinions about ranges is how ranges
433 * drift apart.
434 *
435 * Only the keys the user actually changed are kept, so a site that
436 * later ships a different Mio still shows through everywhere its users
437 * have no opinion.
438 *
439 * @param mixed $raw Raw look from the client or user meta.
440 * @return array {
441 * @type array $appearance Partial appearance overrides.
442 * @type array $physics Partial silhouette + idle overrides.
443 * }
444 */
445 function openstation_sanitize_mio_look( $raw ) {
446 $clean = array(
447 'appearance' => array(),
448 'physics' => array(),
449 );
450
451 if ( ! is_array( $raw ) ) {
452 return $clean;
453 }
454
455 $groups = array(
456 'appearance' => openstation_mio_look_appearance_keys(),
457 'physics' => openstation_mio_look_physics_keys(),
458 );
459
460 foreach ( $groups as $group => $keys ) {
461 if ( ! isset( $raw[ $group ] ) || ! is_array( $raw[ $group ] ) ) {
462 continue;
463 }
464 foreach ( $keys as $key ) {
465 if ( ! isset( $raw[ $group ][ $key ] ) ) {
466 continue;
467 }
468 $value = $raw[ $group ][ $key ];
469 if ( is_bool( $value ) ) {
470 $clean[ $group ][ $key ] = $value;
471 } elseif ( is_int( $value ) || is_float( $value ) ) {
472 // Reject non-finite floats outright: they survive JSON
473 // round-trips as `null` and would land in the blob as a
474 // key the client then has to defend against.
475 if ( is_finite( (float) $value ) ) {
476 $clean[ $group ][ $key ] = 0 + $value;
477 }
478 } elseif ( is_string( $value ) ) {
479 // The only string-valued keys are `shapePreset` and the
480 // three colours in `#rrggbb` form.
481 $clean[ $group ][ $key ] = sanitize_text_field( $value );
482 }
483 }
484 }
485
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;
534 }
535