openvibers/openvibe.games Commits

The game follows the player's OpenVibe theme: HUD accents and buttons from the theme, text size scales the HUD, calm motion stills the weapon bob and sway and the hit wobble (#148)

OP OpenVibers committed 19 days ago · 2a28be7ada · 6 files, +235 −86

Changes

ddiff --git a/apps/client/src/render/entityView.ts b/apps/client/src/render/entityView.ts
iindex 7c15dff..8b703a3 100644
---- a/apps/client/src/render/entityView.ts
++++ b/apps/client/src/render/entityView.ts
@@@ -19,6 +19,7 @@ import { Avatar } from './avatar/avatar.js'
import { buildPropLook } from './propLooks.js'
import { buildCritter, npcAppearance } from './npcLooks.js'
import { materialFor, meshForShape } from './sceneSetup.js'
++import { motionScale } from '../ui/theme.js'
/**
* Maintains the renderable + collidable view of replicated entities:
@@@ -353,7 +354,7 @@ export class EntityView {
// Chop/mine feedback: brief wobble on the hit node.
if (v.shakeT > 0 && v.mesh) {
v.shakeT = Math.max(0, v.shakeT - dt)
-- const k = v.shakeT / 0.3
++ const k = (v.shakeT / 0.3) * motionScale()
v.mesh.rotation.z = Math.sin(localTime * 55) * 0.045 * k
v.mesh.rotation.x = Math.sin(localTime * 47 + 1.3) * 0.03 * k
}
ddiff --git a/apps/client/src/render/viewmodel.ts b/apps/client/src/render/viewmodel.ts
iindex f61524a..a0b203d 100644
---- a/apps/client/src/render/viewmodel.ts
++++ b/apps/client/src/render/viewmodel.ts
@@@ -15,6 +15,7 @@ import { createHeldItemNode, type HeldItemNode } from './heldItem.js'
import { outfitColor, skinTone } from './avatar/palettes.js'
import { createTaperedBox } from './avatar/taperedBox.js'
import { materialFor } from './sceneSetup.js'
++import { motionScale } from '../ui/theme.js'
/**
* First-person viewmodel: the equipped tool rendered at the camera with
@@@ -291,13 +292,14 @@ export class Viewmodel {
this.equipT = Math.min(1, this.equipT + dt * 3.1)
if (this.swingT > 0) this.swingT = Math.max(0, this.swingT - dt)
-- // Sway: lag behind the view with exponential decay.
-- this.swayYaw += (mouseDx * 0.6 - this.swayYaw) * Math.min(1, dt * 10)
-- this.swayPitch += (mouseDy * 0.6 - this.swayPitch) * Math.min(1, dt * 10)
++ // Sway: lag behind the view with exponential decay. Calm motion (the player's OpenVibe setting) keeps a fifth of it.
++ const keep = motionScale()
++ this.swayYaw += (mouseDx * 0.6 * keep - this.swayYaw) * Math.min(1, dt * 10)
++ this.swayPitch += (mouseDy * 0.6 * keep - this.swayPitch) * Math.min(1, dt * 10)
// Bob while moving on the ground.
if (grounded && speed > 0.3) this.bobPhase += dt * Math.min(speed, 8) * 1.6
-- const bobAmp = grounded ? Math.min(speed / 7.2, 1) * 0.012 : 0
++ const bobAmp = grounded ? Math.min(speed / 7.2, 1) * 0.012 * keep : 0
const bobY = Math.abs(Math.sin(this.bobPhase)) * -bobAmp
const bobX = Math.sin(this.bobPhase * 0.5) * bobAmp * 0.6
ddiff --git a/apps/client/src/ui/styles.css b/apps/client/src/ui/styles.css
iindex ddd613b..f9dac16 100644
---- a/apps/client/src/ui/styles.css
++++ b/apps/client/src/ui/styles.css
@@@ -1,3 +1,24 @@
++/* ── The player's OpenVibe theme ───────────────────────────────────
++ The shared theme loader (theme-loader.js on every page) sets the
++ theme's tokens on <html>; the HUD takes its accents and buttons from
++ them, so the game follows the theme someone picked on any OpenVibe
++ site (and the season's, in October and December). Without a theme,
++ the game's own gold and steel blue. Panels stay dark glass over the
++ world whatever the theme, so they read on any scene. The display
++ preferences apply too: text size scales every font size here
++ (calc(Npx * var(--ov-text-scale, 1))),
++ calm motion stills its animations (html[data-ov-motion=reduced]);
++ src/ui/theme.ts tells the engine the rest. */
++:root {
++ --g-accent: var(--accent, #f0b040);
++ --g-accent-text: var(--accent-light, var(--accent, #f0b040));
++ --g-accent-soft: color-mix(in srgb, var(--g-accent) 45%, transparent);
++ --g-on-accent: var(--on-accent, #1a1206);
++ --g-action: var(--accent-strong, var(--accent, #2a5a8a));
++ --g-action-edge: var(--accent-light, #58a4c4);
++ --g-on-action: var(--on-accent-strong, var(--on-accent, #fff));
++}
++
* {
margin: 0;
padding: 0;
@@@ -120,7 +141,7 @@ body.ovg-frame-hidden #ov-footer {
background: rgba(0, 0, 0, 0.6);
padding: 6px 14px;
border-radius: 4px;
-- font-size: 14px;
++ font-size: calc(14px * var(--ov-text-scale, 1));
display: none;
}
@@@ -143,7 +164,7 @@ body.ovg-frame-hidden #ov-footer {
display: flex;
align-items: center;
justify-content: center;
-- font-size: 11px;
++ font-size: calc(11px * var(--ov-text-scale, 1));
text-align: center;
pointer-events: auto;
cursor: pointer;
@@@ -151,7 +172,7 @@ body.ovg-frame-hidden #ov-footer {
}
.slot.active {
-- border-color: #f0b040;
++ border-color: var(--g-accent-text);
}
.slot.holstered {
@@@ -167,7 +188,7 @@ body.ovg-frame-hidden #ov-footer {
position: absolute;
right: 4px;
bottom: 2px;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
font-weight: 700;
color: #f0d090;
}
@@@ -176,7 +197,7 @@ body.ovg-frame-hidden #ov-footer {
position: absolute;
left: 4px;
top: 2px;
-- font-size: 10px;
++ font-size: calc(10px * var(--ov-text-scale, 1));
color: #8fa0b0;
}
@@@ -191,9 +212,9 @@ body.ovg-frame-hidden #ov-footer {
}
.panel h2 {
-- font-size: 15px;
++ font-size: calc(15px * var(--ov-text-scale, 1));
margin-bottom: 10px;
-- color: #f0b040;
++ color: var(--g-accent-text);
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@@ -235,11 +256,11 @@ body.ovg-frame-hidden #ov-footer {
.recipe .name {
font-weight: 600;
-- font-size: 14px;
++ font-size: calc(14px * var(--ov-text-scale, 1));
}
.recipe .req {
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9fb0c0;
margin: 4px 0;
}
@@@ -249,13 +270,13 @@ body.ovg-frame-hidden #ov-footer {
}
.recipe button {
-- background: #2a5a8a;
-- color: #fff;
++ background: var(--g-action);
++ color: var(--g-on-action);
border: 0;
border-radius: 4px;
padding: 5px 12px;
cursor: pointer;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
}
.recipe button:disabled {
@@@ -277,10 +298,10 @@ body.ovg-frame-hidden #ov-footer {
.toast {
background: rgba(0, 0, 0, 0.7);
-- border-left: 3px solid #f0b040;
++ border-left: 3px solid var(--g-accent);
padding: 6px 14px;
border-radius: 4px;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
animation: fadeout 3.5s forwards;
}
@@@ -304,7 +325,7 @@ body.ovg-frame-hidden #ov-footer {
position: absolute;
top: 10px;
left: 12px;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
color: #8fa0b0;
}
/* In the world the folded Frame's OpenVibe pill sits in that corner: the status line goes under it. */
@@@ -325,9 +346,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.overlay-center h1 {
-- font-size: 42px;
++ font-size: calc(42px * var(--ov-text-scale, 1));
letter-spacing: 0.12em;
-- color: #f0b040;
++ color: var(--g-accent-text);
}
.overlay-center input {
@@@ -336,15 +357,15 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border: 1px solid #3a4450;
border-radius: 6px;
padding: 10px 16px;
-- font-size: 16px;
++ font-size: calc(16px * var(--ov-text-scale, 1));
text-align: center;
}
.overlay-center button {
-- background: #2a5a8a;
++ background: var(--g-action);
border: 0;
-- color: #fff;
-- font-size: 16px;
++ color: var(--g-on-action);
++ font-size: calc(16px * var(--ov-text-scale, 1));
padding: 10px 30px;
border-radius: 6px;
cursor: pointer;
@@@ -352,7 +373,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.overlay-center .hint {
color: #8fa0b0;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
}
#skills-panel {
@@@ -369,12 +390,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.skill-head {
display: flex;
justify-content: space-between;
-- font-size: 14px;
++ font-size: calc(14px * var(--ov-text-scale, 1));
margin-bottom: 4px;
}
.skill-level {
-- color: #f0b040;
++ color: var(--g-accent-text);
font-weight: 700;
}
@@@ -387,11 +408,11 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.skill-fill {
height: 100%;
-- background: linear-gradient(90deg, #2a5a8a, #58a4c4);
++ background: linear-gradient(90deg, var(--g-action), var(--g-action-edge));
}
.skill-xp {
-- font-size: 11px;
++ font-size: calc(11px * var(--ov-text-scale, 1));
color: #8fa0b0;
margin-top: 2px;
}
@@@ -404,7 +425,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.hint-line {
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
color: #8fa0b0;
margin-bottom: 10px;
}
@@@ -415,17 +436,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
align-items: center;
padding: 6px 0;
border-bottom: 1px solid #232b33;
-- font-size: 14px;
++ font-size: calc(14px * var(--ov-text-scale, 1));
}
.trust-btn {
-- background: #2a5a8a;
-- color: #fff;
++ background: var(--g-action);
++ color: var(--g-on-action);
border: 0;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
}
.trust-btn.trusted {
@@@ -453,9 +474,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.customize-panel h1 {
-- font-size: 26px;
++ font-size: calc(26px * var(--ov-text-scale, 1));
letter-spacing: 0.12em;
-- color: #f0b040;
++ color: var(--g-accent-text);
margin-bottom: 12px;
}
@@@ -467,12 +488,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border: 1px solid #3a4450;
border-radius: 6px;
padding: 8px 12px;
-- font-size: 15px;
++ font-size: calc(15px * var(--ov-text-scale, 1));
margin-bottom: 10px;
}
.cust-label {
-- font-size: 11px;
++ font-size: calc(11px * var(--ov-text-scale, 1));
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8fa0b0;
@@@ -495,7 +516,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.swatch.selected {
-- border-color: #f0b040;
++ border-color: var(--g-accent-text);
}
.cust-btn {
@@@ -505,12 +526,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border-radius: 5px;
padding: 5px 12px;
cursor: pointer;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
}
.cust-btn.selected {
-- background: #2a5a8a;
-- border-color: #58a4c4;
++ background: var(--g-action);
++ border-color: var(--g-action-edge);
}
.cust-sliders {
@@@ -521,7 +542,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.cust-sliders label {
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9fb0c0;
display: flex;
justify-content: space-between;
@@@ -547,12 +568,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border-radius: 6px;
padding: 10px 0;
cursor: pointer;
-- font-size: 14px;
++ font-size: calc(14px * var(--ov-text-scale, 1));
}
.cust-actions button.primary {
-- background: #2a5a8a;
-- border-color: #58a4c4;
++ background: var(--g-action);
++ border-color: var(--g-action-edge);
font-weight: 600;
}
@@@ -590,7 +611,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
color: #8fa0b0;
padding: 11px 10px;
white-space: nowrap;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
letter-spacing: 0.05em;
cursor: pointer;
border-bottom: 2px solid transparent;
@@@ -604,8 +625,8 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.menu-tab.active {
-- color: #f0b040;
-- border-bottom-color: #f0b040;
++ color: var(--g-accent-text);
++ border-bottom-color: var(--g-accent-text);
}
.menu-body {
@@@ -675,13 +696,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.craft-btn {
flex: none;
-- background: #2a5a8a;
-- color: #fff;
++ background: var(--g-action);
++ color: var(--g-on-action);
border: 0;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
}
.craft-btn:disabled {
@@@ -720,7 +741,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border: 0;
border-radius: 6px;
color: #8fa0b0;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
letter-spacing: 0.03em;
cursor: pointer;
transition:
@@@ -734,7 +755,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.segmented-option.active {
background: #2a333d;
-- color: #f0b040;
++ color: var(--g-accent-text);
}
/* The first-steps waypoint: an arrow and a distance at the top centre, toward the current step's target. */
@@@ -748,10 +769,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
gap: 8px;
padding: 5px 12px 5px 8px;
border-radius: 999px;
-- border: 1px solid rgba(240, 176, 64, 0.45);
++ border: 1px solid var(--g-accent-soft);
background: rgba(14, 18, 24, 0.78);
color: #f3e6c8;
-- font: 600 13px/1.2 system-ui, sans-serif;
++ font:
++ 600 calc(13px * var(--ov-text-scale, 1))/1.2 system-ui,
++ sans-serif;
pointer-events: none;
white-space: nowrap;
}
@@@ -765,9 +788,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
width: 22px;
height: 22px;
border-radius: 50%;
-- background: #f0b040;
-- color: #1a1206;
-- font-size: 15px;
++ background: var(--g-accent);
++ color: var(--g-on-accent);
++ font-size: calc(15px * var(--ov-text-scale, 1));
font-weight: 800;
transition: transform 0.15s linear;
}
@@@ -778,17 +801,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
width: 100%;
min-height: 40px;
margin: 2px 0 6px;
-- accent-color: #f0b040;
++ accent-color: var(--g-accent-text);
}
.segmented-option:focus-visible {
-- outline: 2px solid #f0b040;
++ outline: 2px solid var(--g-accent);
outline-offset: 1px;
}
.weapon-title {
-- font-size: 15px;
-- color: #f0b040;
++ font-size: calc(15px * var(--ov-text-scale, 1));
++ color: var(--g-accent-text);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 10px;
@@@ -799,7 +822,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.weapon-opt-label {
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9fb0c0;
margin-bottom: 5px;
}
@@@ -833,7 +856,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
min-height: 0;
}
.status-chip {
-- font-size: 10px;
++ font-size: calc(10px * var(--ov-text-scale, 1));
letter-spacing: 0.04em;
color: #e8edf2;
background: rgba(10, 14, 18, 0.6);
@@@ -844,7 +867,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
.vital span {
width: 38px;
-- font-size: 10px;
++ font-size: calc(10px * var(--ov-text-scale, 1));
letter-spacing: 0.08em;
color: #cdd3da;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
@@@ -983,7 +1006,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
font-weight: 600;
}
.shop-cost {
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9ab08e;
}
.shop-cost.missing {
@@@ -999,7 +1022,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
color: #f0d79a;
border-radius: 10px;
padding: 10px 22px;
-- font-size: 16px;
++ font-size: calc(16px * var(--ov-text-scale, 1));
letter-spacing: 0.03em;
opacity: 0;
transition: opacity 0.4s ease;
@@@ -1020,7 +1043,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
background: rgba(20, 26, 34, 0.86);
border: 1px solid rgba(232, 181, 74, 0.55);
color: #e8e2d4;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
line-height: 1.35;
pointer-events: auto;
}
@@@ -1035,14 +1058,14 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
-- font-size: 11px;
++ font-size: calc(11px * var(--ov-text-scale, 1));
color: #e8b54a;
}
.first-steps-head button {
background: none;
border: 0;
color: #b9b2a3;
-- font-size: 18px;
++ font-size: calc(18px * var(--ov-text-scale, 1));
line-height: 1;
cursor: pointer;
padding: 0 2px;
@@@ -1081,13 +1104,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
margin: 2px 0 4px 1.2em;
font-weight: 400;
color: #cfc8b8;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
}
@media (max-width: 640px) {
.first-steps {
top: auto;
bottom: 96px;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
}
}
@@@ -1117,14 +1140,14 @@ body:has(.ov-touch:not([hidden])) .status {
}
body:has(.ov-touch:not([hidden])) .waypoint {
top: 52px;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
}
body:has(.ov-touch:not([hidden])) .first-steps {
top: 92px;
bottom: auto;
right: 8px;
width: min(250px, calc(100vw - 16px));
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
}
/* Only the step in progress: the list would cover a phone's view. */
body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@@ -1149,7 +1172,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
opacity: 1;
}
.death-title {
-- font-size: 64px;
++ font-size: calc(64px * var(--ov-text-scale, 1));
font-weight: 800;
letter-spacing: 0.35em;
color: #c33;
@@@ -1167,11 +1190,11 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
width: 100%;
padding: 14px 16px;
margin-top: 8px;
-- font-size: 15px;
++ font-size: calc(15px * var(--ov-text-scale, 1));
}
.char-slot span {
color: #78828e;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
}
.char-locked {
@@@ -1184,7 +1207,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
background: rgba(40, 60, 86, 0.55);
border: 1px solid #34506e;
border-radius: 10px;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
color: #b8c6d6;
line-height: 1.5;
}
@@@ -1197,7 +1220,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
width: 100%;
margin-top: 10px;
padding: 13px 16px;
-- font-size: 15px;
++ font-size: calc(15px * var(--ov-text-scale, 1));
font-weight: 700;
background: linear-gradient(180deg, #3b6a96, #2a4d70);
border-color: #4b7cab;
@@@ -1212,7 +1235,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
background: rgba(60, 50, 25, 0.5);
border: 1px solid #6e5a2e;
border-radius: 8px;
-- font-size: 12.5px;
++ font-size: calc(12.5px * var(--ov-text-scale, 1));
color: #d8c9a0;
line-height: 1.45;
}
@@@ -1231,7 +1254,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
border: 1px solid #ffce54;
border-radius: 8px;
color: #fff3d6;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
font-weight: 700;
letter-spacing: 0.04em;
pointer-events: none;
@@@ -1257,12 +1280,12 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
background: rgba(30, 70, 45, 0.45);
border: 1px solid #3f7a55;
border-radius: 8px;
-- font-size: 13px;
++ font-size: calc(13px * var(--ov-text-scale, 1));
color: #a8dcb8;
}
.char-logout {
padding: 5px 12px;
-- font-size: 12px;
++ font-size: calc(12px * var(--ov-text-scale, 1));
background: rgba(90, 40, 40, 0.6);
border: 1px solid #8a4a4a;
border-radius: 6px;
ddiff --git a/apps/client/src/ui/theme.test.ts b/apps/client/src/ui/theme.test.ts
nnew file mode 100644
iindex 0000000..dd34aab
---- /dev/null
++++ b/apps/client/src/ui/theme.test.ts
@@@ -0,0 +1,35 @@
++// @vitest-environment jsdom
++import { afterEach, describe, expect, it } from 'vitest'
++import { _resetTheme, calm, motionScale, onCalmChange, themeColor } from './theme.js'
++
++const flush = () => new Promise((r) => setTimeout(r, 0))
++
++describe("the player's OpenVibe display preferences in the engine", () => {
++ afterEach(() => {
++ document.documentElement.removeAttribute('data-ov-motion')
++ document.documentElement.style.removeProperty('--accent')
++ _resetTheme()
++ })
++
++ it('keeps all the motion unless calm motion is on, and follows it as it changes', async () => {
++ expect(calm()).toBe(false)
++ expect(motionScale()).toBe(1)
++ const seen: boolean[] = []
++ onCalmChange((c) => seen.push(c))
++ document.documentElement.setAttribute('data-ov-motion', 'reduced') // the theme loader, after a change on any site
++ await flush()
++ expect(calm()).toBe(true)
++ expect(motionScale()).toBe(0.2)
++ window.dispatchEvent(new Event('ov:display'))
++ document.documentElement.removeAttribute('data-ov-motion')
++ await flush()
++ expect(motionScale()).toBe(1)
++ expect(seen).toEqual([true, false])
++ })
++
++ it("reads the theme's colours for canvas-drawn UI, with a fallback", () => {
++ expect(themeColor('--accent', '#f0b040')).toBe('#f0b040')
++ document.documentElement.style.setProperty('--accent', '#ff7a1a')
++ expect(themeColor('--accent', '#f0b040')).toBe('#ff7a1a')
++ })
++})
ddiff --git a/apps/client/src/ui/theme.ts b/apps/client/src/ui/theme.ts
nnew file mode 100644
iindex 0000000..bea1155
---- /dev/null
++++ b/apps/client/src/ui/theme.ts
@@@ -0,0 +1,85 @@
++/**
++ * The player's OpenVibe display preferences in the engine.
++ *
++ * Every page runs the shared theme loader (theme-loader.js), which paints the theme someone picked on any OpenVibe site
++ * onto <html> (the HUD's CSS reads those tokens: styles.css :root --g-*) and keeps their display preferences: text size
++ * (--ov-text-scale, also read by the CSS) and calm motion (html[data-ov-motion=reduced]). This module is what CSS cannot
++ * reach: how much of the engine's own decorative motion to keep (the weapon's bob and sway, the hit wobble), current as
++ * the person changes it in another tab or on another site, and the theme's colours for anything drawn on a canvas.
++ */
++
++let calmNow = false
++let started = false
++const listeners = new Set<(calm: boolean) => void>()
++
++function read(): boolean {
++ if (typeof document === 'undefined') return false
++ if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') return true
++ try {
++ return (
++ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
++ )
++ } catch {
++ return false
++ }
++}
++
++function refresh(): void {
++ const c = read()
++ if (c === calmNow) return
++ calmNow = c
++ listeners.forEach((fn) => fn(c))
++}
++
++function start(): void {
++ if (started) return
++ started = true
++ calmNow = read()
++ if (typeof window === 'undefined') return
++ window.addEventListener('ov:display', refresh)
++ try {
++ matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', refresh)
++ } catch {
++ /* no media queries */
++ }
++ try {
++ new MutationObserver(refresh).observe(document.documentElement, {
++ attributes: true,
++ attributeFilter: ['data-ov-motion'],
++ })
++ } catch {
++ /* no DOM */
++ }
++}
++
++/** Calm motion is on (the OpenVibe setting or the system's reduced-motion setting). */
++export function calm(): boolean {
++ start()
++ return calmNow
++}
++
++/** How much of the engine's decorative motion to keep: 1, or 0.2 when calm. */
++export function motionScale(): number {
++ return calm() ? 0.2 : 1
++}
++
++/** Called with the new value whenever calm motion turns on or off; returns a function that stops it. */
++export function onCalmChange(fn: (calm: boolean) => void): () => void {
++ start()
++ listeners.add(fn)
++ return () => listeners.delete(fn)
++}
++
++/** One of the theme's colours (a CSS custom property on <html>, like '--accent'), or the fallback. */
++export function themeColor(name: string, fallback: string): string {
++ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return fallback
++ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
++ return v || fallback
++}
++
++/** Tests only: forget what was read. */
++export function _resetTheme(): void {
++ started = false
++ calmNow = false
++ listeners.clear()
++}
ddiff --git a/test/apps__client__src__ui__theme.test.js b/test/apps__client__src__ui__theme.test.js
nnew file mode 100644
iindex 0000000..33b15ec
---- /dev/null
++++ b/test/apps__client__src__ui__theme.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/ui/theme.test.ts'
++// covers: 'apps/client/src/ui'
++// Generated by node test/run.js --index; do not edit.