PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
1.1.10 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 All 34 releases
desktop-mode / includes / mio.php

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

519 lines 17.5 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 'outlineWidth' => 3,
81 // Reach of the light, as a multiple of Mio's own radius:
82 // `10` carries the wash about one and a half radii past the
83 // outline. Deliberately generous — Mio sits on a dark desk
84 // and the glow is the thing that makes her read as lit
85 // rather than drawn. The slider runs to `20`.
86 //
87 // Must match `MIO_DEFAULTS` in `src/mio/config.ts`; this is
88 // the value the shell renders before a user has a look of
89 // their own, and the two disagreeing means Mio changes
90 // appearance the first time anything is saved.
91 'glow' => 10,
92 // No UI switches this off. Each glow pass is a ramp of
93 // concentric shells, and unblurred that ramp shows as the
94 // contour rings it is built from. It is here so a site that
95 // needs the two filter passes back for performance can drop
96 // them.
97 'glowBlur' => true,
98 // Starlight, the palette's white — what the brand's mascot
99 // fills its two eye pills with. Not '#ffffff'.
100 'eyeColor' => '#fffbff',
101 'eyeScale' => 0.3,
102 ),
103 'physics' => array(
104 'points' => 12,
105 // Silhouette: 'circle', 'blob', 'ghost', 'potato' or
106 // 'custom'. Nearly round, with a shallow dimple at the
107 // bottom centre.
108 'shapePreset' => 'blob',
109 // Only read by the 'custom' preset.
110 'shapeLobes' => 3,
111 'shapeAmount' => 1,
112 'shapeAngle' => 0,
113 // Seconds between Mio picking a new silhouette at
114 // random and morphing into it. 0 holds shapePreset.
115 'shapeShuffle' => 60,
116 'radialStiffness' => 460,
117 'edgeStiffness' => 540,
118 'bendStiffness' => 170,
119 'pressure' => 2400,
120 'damping' => 9,
121 'airDamping' => 0.5,
122 'magnetStrength' => 2200,
123 'magnetRange' => 260,
124 'magnetGrip' => 0.24,
125 'magnetDamping' => 7,
126 'floatAmplitude' => 10,
127 'floatSpeed' => 1.1,
128 'idleWobble' => 0.085,
129 'idleWobbleSpeed' => 0.55,
130 'speedStretch' => 0.3,
131 'friction' => 0.86,
132 'restitution' => 0.2,
133 'dragStiffness' => 480,
134 'throwBoost' => 1,
135 'minStretch' => 0.55,
136 'maxStretch' => 1.7,
137 'minAngularGap' => 0.25,
138 'limitIterations' => 3,
139 'dragMaxAccel' => 9000,
140 'subStep' => 1 / 240,
141 'maxSubSteps' => 8,
142 ),
143 );
144 }
145
146 /**
147 * Returns Mio configuration for the current user.
148 *
149 * Shape mirrors `MioConfig` in `src/mio/types.ts`:
150 *
151 * array(
152 * 'appearance' => array( radius, bodyColor, bodyAlpha, hueStart,
153 * hueSpan, hueDrift, hueLoop, hueAngle,
154 * saturation, lightness, iridescence,
155 * outlineWidth, glow, glowBlur,
156 * eyeColor, eyeScale ),
157 * 'physics' => array( points, shapePreset, shapeLobes,
158 * shapeAmount, shapeAngle, shapeShuffle,
159 * radialStiffness, edgeStiffness,
160 * bendStiffness, pressure, damping,
161 * airDamping, magnetStrength, magnetRange,
162 * magnetGrip, magnetDamping, floatAmplitude,
163 * floatSpeed, idleWobble, idleWobbleSpeed,
164 * speedStretch, friction, restitution,
165 * dragStiffness, throwBoost, minStretch,
166 * maxStretch, minAngularGap,
167 * limitIterations, dragMaxAccel, subStep,
168 * maxSubSteps ),
169 * )
170 *
171 * Colours may be given as integers (`0x05050a`) or CSS hex strings
172 * (`'#05050a'`); the client accepts both.
173 *
174 * @return array Mio configuration.
175 */
176 function openstation_mio_config() {
177 $defaults = openstation_mio_default_config();
178
179 /**
180 * Filters Mio's appearance and physics.
181 *
182 * Runs once per shell render. Returning a partial array is fine —
183 * anything missing falls back to the reference design, and every
184 * value is clamped client-side before it reaches the simulation.
185 *
186 * Example — a slower, heavier, teal mio:
187 *
188 * add_filter( 'openstation_mio_config', function ( $config ) {
189 * $config['appearance']['hueStart'] = 170;
190 * $config['appearance']['hueSpan'] = 40;
191 * $config['physics']['magnetStrength'] = 3400;
192 * return $config;
193 * } );
194 *
195 * @param array $defaults Default configuration, as documented above.
196 */
197 $config = apply_filters( 'openstation_mio_config', $defaults );
198
199 return is_array( $config ) ? $config : $defaults;
200 }
201
202 /**
203 * Appearance keys a stored user look may carry.
204 *
205 * Mirrors `APPEARANCE_KEYS` in `src/mio/look.ts`. A whitelist rather
206 * than "whatever the client sent", because this lands in user meta:
207 * an unbounded key set is an unbounded row.
208 *
209 * @return string[]
210 */
211 function openstation_mio_look_appearance_keys() {
212 return array(
213 'radius',
214 'bodyColor',
215 'bodyAlpha',
216 'hueStart',
217 'hueSpan',
218 'hueDrift',
219 'hueLoop',
220 'hueAngle',
221 'hueSpin',
222 'saturation',
223 'lightness',
224 'iridescence',
225 'outlineWidth',
226 'glow',
227 'glowBlur',
228 'eyeColor',
229 'eyeScale',
230 );
231 }
232
233 /**
234 * Physics keys a stored user look may carry.
235 *
236 * Mirrors `LOOK_PHYSICS_KEYS` in `src/mio/look.ts`. Every one of them
237 * modulates a rest length. The spring constants are deliberately
238 * absent: they are the site's, they interact, and a stored preference
239 * that could reach them would be a way for a corrupt row to make Mio
240 * unstable.
241 *
242 * @return string[]
243 */
244 function openstation_mio_look_physics_keys() {
245 return array(
246 'shapePreset',
247 'shapeLobes',
248 'shapeAmount',
249 'shapeAngle',
250 'shapeShuffle',
251 'idleWobble',
252 'idleWobbleSpeed',
253 );
254 }
255
256 /**
257 * Numeric ranges every look value is held inside.
258 *
259 * Mirrors `LIMITS` in `src/mio/config.ts`, and exists for the same
260 * reason the client one does: the shipped values are a design, not a
261 * boundary, and everything downstream of them assumes a sane number.
262 *
263 * Only the keys a stored look may carry are listed. The rest of
264 * `MioPhysics` is spring constants the panel deliberately never
265 * exposes.
266 *
267 * @return array<string, array{0: float, 1: float}>
268 */
269 function openstation_mio_look_limits() {
270 return array(
271 'radius' => array( 16, 220 ),
272 'bodyAlpha' => array( 0, 1 ),
273 'hueStart' => array( -720, 720 ),
274 'hueSpan' => array( -360, 360 ),
275 'hueDrift' => array( -180, 180 ),
276 'hueAngle' => array( -360, 360 ),
277 'hueSpin' => array( -180, 180 ),
278 'saturation' => array( 0, 1 ),
279 'lightness' => array( 0.15, 1 ),
280 'iridescence' => array( 0, 2 ),
281 'outlineWidth' => array( 0.5, 24 ),
282 'glow' => array( 0, 20 ),
283 'eyeScale' => array( 0.05, 0.6 ),
284 'shapeLobes' => array( 0, 8 ),
285 'shapeAmount' => array( 0, 1.4 ),
286 'shapeAngle' => array( -360, 360 ),
287 'shapeShuffle' => array( 0, 3600 ),
288 'idleWobble' => array( 0, 0.4 ),
289 'idleWobbleSpeed' => array( 0, 8 ),
290 );
291 }
292
293 /**
294 * Every silhouette `shapePreset` accepts. Mirrors `SHAPE_PRESETS`.
295 *
296 * @return string[]
297 */
298 function openstation_mio_shape_presets() {
299 return array(
300 'circle',
301 'blob',
302 'ghost',
303 'potato',
304 'star',
305 'flower',
306 'heart',
307 'diamond',
308 'drop',
309 'cloud',
310 'custom',
311 );
312 }
313
314 /**
315 * Coerce a colour to a 24-bit int. Mirrors `color()` in `config.ts`.
316 *
317 * The shipped defaults write colours as CSS hex strings because that is
318 * what reads well in a config array; the renderers want integers.
319 *
320 * @param mixed $candidate Colour as int or `#rrggbb` / `#rgb` string.
321 * @param int $fallback Value to use when the candidate is unusable.
322 * @return int Packed 24-bit colour.
323 */
324 function openstation_mio_color_int( $candidate, $fallback = 0 ) {
325 if ( is_int( $candidate ) || is_float( $candidate ) ) {
326 if ( ! is_finite( (float) $candidate ) ) {
327 return $fallback;
328 }
329 return (int) min( 0xffffff, max( 0, floor( $candidate ) ) );
330 }
331 if ( is_string( $candidate ) ) {
332 $hex = ltrim( trim( $candidate ), '#' );
333 if ( preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) {
334 return (int) hexdec( $hex );
335 }
336 if ( preg_match( '/^[0-9a-fA-F]{3}$/', $hex ) ) {
337 return (int) hexdec( $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2] );
338 }
339 }
340 return $fallback;
341 }
342
343 /**
344 * Hold a look inside its ranges and resolve it against the defaults.
345 *
346 * `openstation_sanitize_mio_look()` is a SHAPE check: right keys,
347 * right kinds. That was enough while every look was handed straight to
348 * the client, where `sanitizeMioConfig()` clamped it before anything
349 * drew with it. It is not enough now. A stored look reaches a PHP
350 * renderer that samples trigonometry and builds a path, so an
351 * `outlineWidth` of -400 or a `shapeAmount` of 1e9 arrives as a number
352 * nobody checked.
353 *
354 * The result is a COMPLETE config with integer colours, ready to draw:
355 * unlike the sanitizer, this is not what you store.
356 *
357 * `shapeShuffle` is dropped rather than clamped. It means "pick a new
358 * silhouette every so often", which is meaningless in a still portrait
359 * and would be a bug if anything ever honoured it there.
360 *
361 * @param mixed $raw Stored look, or anything at all.
362 * @return array Complete `array( 'appearance' => ..., 'physics' => ... )`.
363 */
364 function openstation_mio_clamp_look( $raw ) {
365 $defaults = openstation_mio_default_config();
366 $limits = openstation_mio_look_limits();
367 $look = openstation_sanitize_mio_look( $raw );
368
369 $resolve = static function ( $group, $overrides ) use ( $limits ) {
370 $out = array();
371 foreach ( $group as $key => $default ) {
372 $value = array_key_exists( $key, $overrides ) ? $overrides[ $key ] : $default;
373
374 if ( 'shapePreset' === $key ) {
375 $presets = openstation_mio_shape_presets();
376 $out[ $key ] = in_array( $value, $presets, true ) ? $value : $default;
377 continue;
378 }
379 if ( 'bodyColor' === $key || 'eyeColor' === $key ) {
380 $out[ $key ] = openstation_mio_color_int( $value, openstation_mio_color_int( $default ) );
381 continue;
382 }
383 if ( is_bool( $default ) ) {
384 $out[ $key ] = is_bool( $value ) ? $value : $default;
385 continue;
386 }
387 if ( ! is_numeric( $value ) || ! is_finite( (float) $value ) ) {
388 $value = $default;
389 }
390 if ( isset( $limits[ $key ] ) ) {
391 $value = min( $limits[ $key ][1], max( $limits[ $key ][0], (float) $value ) );
392 }
393 $out[ $key ] = $value;
394 }
395 return $out;
396 };
397
398 $physics = $resolve( $defaults['physics'], $look['physics'] );
399 // A face that changed silhouette on a timer is not a portrait.
400 $physics['shapeShuffle'] = 0;
401
402 return array(
403 'appearance' => $resolve( $defaults['appearance'], $look['appearance'] ),
404 'physics' => $physics,
405 );
406 }
407
408 /**
409 * Sanitizes a user's saved Mio look for storage in user meta.
410 *
411 * **A shape check, not a clamp.** It answers "are these the right keys
412 * carrying the right kinds of value" and nothing more. Deciding what a
413 * legal hue, silhouette or spring constant is stays with
414 * `sanitizeMioConfig()` in `src/mio/config.ts`, which runs on
415 * everything headed for the simulation whatever route it arrived by.
416 * Two validators with overlapping opinions about ranges is how ranges
417 * drift apart.
418 *
419 * Only the keys the user actually changed are kept, so a site that
420 * later ships a different Mio still shows through everywhere its users
421 * have no opinion.
422 *
423 * @param mixed $raw Raw look from the client or user meta.
424 * @return array {
425 * @type array $appearance Partial appearance overrides.
426 * @type array $physics Partial silhouette + idle overrides.
427 * }
428 */
429 function openstation_sanitize_mio_look( $raw ) {
430 $clean = array(
431 'appearance' => array(),
432 'physics' => array(),
433 );
434
435 if ( ! is_array( $raw ) ) {
436 return $clean;
437 }
438
439 $groups = array(
440 'appearance' => openstation_mio_look_appearance_keys(),
441 'physics' => openstation_mio_look_physics_keys(),
442 );
443
444 foreach ( $groups as $group => $keys ) {
445 if ( ! isset( $raw[ $group ] ) || ! is_array( $raw[ $group ] ) ) {
446 continue;
447 }
448 foreach ( $keys as $key ) {
449 if ( ! isset( $raw[ $group ][ $key ] ) ) {
450 continue;
451 }
452 $value = $raw[ $group ][ $key ];
453 if ( is_bool( $value ) ) {
454 $clean[ $group ][ $key ] = $value;
455 } elseif ( is_int( $value ) || is_float( $value ) ) {
456 // Reject non-finite floats outright: they survive JSON
457 // round-trips as `null` and would land in the blob as a
458 // key the client then has to defend against.
459 if ( is_finite( (float) $value ) ) {
460 $clean[ $group ][ $key ] = 0 + $value;
461 }
462 } elseif ( is_string( $value ) ) {
463 // The only string-valued keys are `shapePreset` and the
464 // two colours in `#rrggbb` form.
465 $clean[ $group ][ $key ] = sanitize_text_field( $value );
466 }
467 }
468 }
469
470 return $clean;
471 }
472
473 /**
474 * Narrow a partial look to the keys it carried, with every number in range.
475 *
476 * Two passes that answer different questions.
477 * {@see openstation_sanitize_mio_look()} asks "are these the right keys
478 * carrying the right kinds of value", and keeps only what was actually
479 * set. {@see openstation_mio_clamp_look()} then asks "is every number
480 * inside its range", because a stored look now reaches a PHP renderer
481 * that samples trigonometry and builds a path. The clamp resolves
482 * against the defaults and hands back a *complete* config, which is
483 * what you draw with and not what you store, so the carried keys are
484 * picked back out of it afterwards.
485 *
486 * Keeping only the overridden keys is what lets a future change to the
487 * shipped Mio still show through wherever nobody had an opinion.
488 *
489 * Lives here rather than beside the agent store because two callers on
490 * different sides of the feature flag need it: the store, when an agent
491 * saves a face, and the WP Explorer config, when the flag is off and
492 * the section previews the cast it would seed. One owner of the rule
493 * means the preview cannot draw a face the seeder would not store.
494 *
495 * @param mixed $raw Raw look (array), from the client or from our own data.
496 * @return array {
497 * @type array $appearance Clamped appearance overrides.
498 * @type array $physics Clamped silhouette + idle overrides.
499 * }
500 */
501 function openstation_mio_narrow_look( $raw ) {
502 $look = openstation_sanitize_mio_look( $raw );
503 $clamped = openstation_mio_clamp_look( $look );
504 $out = array(
505 'appearance' => array(),
506 'physics' => array(),
507 );
508
509 foreach ( array( 'appearance', 'physics' ) as $group ) {
510 foreach ( array_keys( $look[ $group ] ) as $key ) {
511 if ( array_key_exists( $key, $clamped[ $group ] ) ) {
512 $out[ $group ][ $key ] = $clamped[ $group ][ $key ];
513 }
514 }
515 }
516
517 return $out;
518 }
519