This commit is contained in:
Flatlogic Bot 2026-03-27 23:45:58 +00:00
parent e713c1471c
commit edc6d68e9e
3 changed files with 529 additions and 156 deletions

View File

@ -170,131 +170,319 @@ textarea::placeholder {
.recliner-stage {
position: relative;
background: #101419;
background:
radial-gradient(circle at 22% 18%, rgba(255, 255, 255, 0.08), transparent 28%),
radial-gradient(circle at 78% 20%, rgba(212, 216, 222, 0.12), transparent 20%),
linear-gradient(180deg, #12171d 0%, #0d1116 100%);
border: 1px solid var(--border);
border-radius: var(--radius-md);
min-height: 360px;
min-height: 420px;
overflow: hidden;
isolation: isolate;
perspective: 1600px;
}
.recliner-stage::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 38%;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.34));
pointer-events: none;
}
.grid-fade,
.recliner-aurora {
position: absolute;
inset: 0;
pointer-events: none;
}
.grid-fade {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: linear-gradient(180deg, rgba(0,0,0,0.65), transparent 95%);
background-size: 40px 40px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.72), transparent 95%);
opacity: 0.8;
}
.axis-label {
.recliner-aurora {
background:
radial-gradient(circle at 50% 72%, rgba(212, 216, 222, 0.08), transparent 32%),
radial-gradient(circle at 58% 40%, rgba(255, 255, 255, 0.06), transparent 16%);
}
.axis-label,
.motion-indicator {
position: absolute;
top: 18px;
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
z-index: 2;
}
.axis-label-left { left: 18px; }
.axis-label-right { right: 18px; }
.motion-indicator {
left: 50%;
transform: translateX(-50%);
padding: 0.42rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
background: rgba(10, 12, 15, 0.52);
color: var(--text);
}
.recliner-figure {
position: absolute;
left: 50%;
bottom: 48px;
width: 320px;
height: 230px;
transform: translateX(calc(-50% - (var(--angle) * 0.18px))) translateY(calc(var(--angle) * 0.06px));
bottom: 26px;
width: 430px;
height: 320px;
--figure-scale: 1;
--drift-x: 0px;
--drift-y: 0px;
--shake-x: 4px;
--shake-y: 2px;
--shake-rotate: 0.7deg;
--vibe-speed: 140ms;
--glow-strength: 0.26;
--glow-opacity: 0.54;
transform: translateX(calc(-50% + var(--drift-x))) translateY(var(--drift-y)) scale(var(--figure-scale));
transform-origin: center bottom;
transition: transform 240ms ease;
z-index: 1;
}
.recliner-shadow,
.recliner-seat,
.recliner-arm,
.recliner-back,
.recliner-head,
.recliner-leg,
.recliner-base {
.recliner-rig,
.recliner-motion {
position: absolute;
background: var(--surface-3);
border: 1px solid rgba(255, 255, 255, 0.08);
inset: 0;
transform-style: preserve-3d;
}
.recliner-shadow {
left: 78px;
right: 26px;
bottom: 6px;
.recliner-rig {
transform: rotateX(64deg) rotateZ(-29deg);
transform-origin: center bottom;
}
.recliner-motion.is-vibrating.vibration-continuous {
animation: rumble-continuous var(--vibe-speed) steps(2, end) infinite;
}
.recliner-motion.is-vibrating.vibration-pulse {
animation: rumble-pulse 820ms cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.recliner-glow,
.recliner-floor,
.recliner-seat-pad,
.recliner-back-inner {
position: absolute;
}
.recliner-glow {
left: 76px;
bottom: 32px;
width: 270px;
height: 130px;
border-radius: 50%;
background: radial-gradient(circle, rgba(212, 216, 222, 0.42) 0%, rgba(212, 216, 222, 0) 72%);
filter: blur(18px);
opacity: var(--glow-opacity);
transform: translateZ(-42px);
transition: opacity 180ms ease, filter 180ms ease;
}
.recliner-floor {
left: 64px;
bottom: 12px;
width: 296px;
height: 162px;
border-radius: 52% 48% 44% 56% / 56% 54% 46% 44%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015));
border: 1px solid rgba(255, 255, 255, 0.04);
box-shadow: inset 0 -22px 44px rgba(0, 0, 0, 0.22);
transform: translateZ(-30px);
}
.recliner-block {
position: absolute;
background: linear-gradient(180deg, #2a323d 0%, #171c22 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 34px rgba(0, 0, 0, 0.22);
transform-style: preserve-3d;
}
.recliner-block::before,
.recliner-block::after {
content: "";
position: absolute;
pointer-events: none;
}
.recliner-block::before {
left: 10px;
right: 0;
bottom: 100%;
height: 18px;
border: 0;
background: rgba(0, 0, 0, 0.32);
filter: blur(14px);
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0.05));
clip-path: polygon(0 100%, 100% 100%, calc(100% - 10px) 0, 10px 0);
opacity: 0.9;
}
.recliner-block::after {
top: 8px;
left: 100%;
bottom: 0;
width: 18px;
background: linear-gradient(180deg, rgba(10, 12, 15, 0.44), rgba(255, 255, 255, 0.04));
clip-path: polygon(0 0, 100% 10px, 100% 100%, 0 calc(100% - 8px));
}
.recliner-plinth {
left: 138px;
bottom: 52px;
width: 126px;
height: 28px;
border-radius: 18px;
transform: translateZ(10px);
}
.recliner-column {
left: 182px;
bottom: 80px;
width: 38px;
height: 72px;
border-radius: 18px;
transform: translateZ(22px);
}
.recliner-base {
left: 154px;
bottom: 106px;
width: 110px;
height: 52px;
border-radius: 28px 28px 20px 20px;
transform: translateZ(40px);
}
.recliner-seat {
left: 92px;
bottom: 78px;
width: 130px;
height: 52px;
border-radius: 14px 14px 10px 10px;
left: 144px;
bottom: 152px;
width: 142px;
height: 82px;
border-radius: 28px 26px 18px 18px;
transform: translateZ(62px);
}
.recliner-seat-pad {
left: 158px;
bottom: 166px;
width: 112px;
height: 46px;
border-radius: 20px 18px 16px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.03));
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transform: translateZ(78px);
}
.recliner-arm {
left: 120px;
bottom: 118px;
width: 82px;
height: 20px;
border-radius: 10px;
left: 124px;
bottom: 160px;
width: 28px;
height: 102px;
border-radius: 18px;
transform: translateZ(64px);
}
.recliner-arm-secondary {
left: 278px;
bottom: 154px;
height: 98px;
transform: translateZ(34px);
}
.recliner-back {
left: 106px;
bottom: 108px;
width: 34px;
height: 116px;
border-radius: 14px;
transform-origin: 18px calc(100% - 10px);
transform: rotate(calc(-92deg + (var(--angle) * 0.5deg)));
left: 114px;
bottom: 194px;
width: 92px;
height: 142px;
border-radius: 30px;
transform-origin: right bottom;
transform: translateZ(66px) rotate(calc(-6deg + (var(--angle) * 0.38deg)));
}
.recliner-back-inner {
left: 130px;
bottom: 210px;
width: 60px;
height: 104px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.03));
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transform-origin: right bottom;
transform: translateZ(82px) rotate(calc(-6deg + (var(--angle) * 0.38deg)));
}
.recliner-head {
left: 90px;
bottom: 188px;
width: 48px;
height: 30px;
border-radius: 14px;
transform: rotate(calc(-14deg + (var(--angle) * 0.1deg)));
left: 124px;
bottom: 308px;
width: 74px;
height: 38px;
border-radius: 22px;
transform: translateZ(86px) rotate(calc(-10deg + (var(--angle) * 0.14deg)));
}
.recliner-leg {
left: 202px;
bottom: 88px;
width: 24px;
height: 96px;
border-radius: 14px;
transform-origin: 12px 10px;
transform: rotate(calc(10deg + (var(--angle) * 0.42deg)));
left: 284px;
bottom: 166px;
width: 58px;
height: 118px;
border-radius: 20px;
transform-origin: left center;
transform: translateZ(46px) rotate(calc(10deg + (var(--angle) * 0.34deg)));
}
.recliner-base {
left: 112px;
bottom: 44px;
width: 112px;
height: 14px;
border-radius: 999px;
.recliner-footpad {
left: 326px;
bottom: 208px;
width: 42px;
height: 58px;
border-radius: 18px;
transform-origin: left center;
transform: translateZ(30px) rotate(calc(10deg + (var(--angle) * 0.2deg)));
}
.recliner-figure.is-vibrating .recliner-glow {
opacity: 0.9;
filter: blur(24px);
animation: glow-pulse 760ms ease-in-out infinite;
}
.recliner-figure[data-pattern="continuous"].is-vibrating .recliner-glow {
animation-duration: 220ms;
}
.angle-indicator {
position: absolute;
right: 12px;
top: 16px;
top: 18px;
display: inline-flex;
flex-direction: column;
align-items: flex-end;
padding: 12px 14px;
border: 1px solid var(--border-strong);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
background: rgba(11, 13, 16, 0.74);
background: rgba(11, 13, 16, 0.72);
backdrop-filter: blur(10px);
z-index: 2;
}
.angle-indicator span {
@ -310,6 +498,51 @@ textarea::placeholder {
margin-top: 4px;
}
@keyframes rumble-continuous {
0% {
transform: translate3d(calc(var(--shake-x) * -1), calc(var(--shake-y) * -1), 0) rotate(calc(var(--shake-rotate) * -1));
}
25% {
transform: translate3d(var(--shake-x), 0, 0) rotate(calc(var(--shake-rotate) * 0.6));
}
50% {
transform: translate3d(calc(var(--shake-x) * -0.65), var(--shake-y), 0) rotate(calc(var(--shake-rotate) * -0.5));
}
75% {
transform: translate3d(calc(var(--shake-x) * 0.75), calc(var(--shake-y) * -1), 0) rotate(calc(var(--shake-rotate) * 0.3));
}
100% {
transform: translate3d(calc(var(--shake-x) * -1), calc(var(--shake-y) * -1), 0) rotate(calc(var(--shake-rotate) * -1));
}
}
@keyframes rumble-pulse {
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
}
12% {
transform: translate3d(var(--shake-x), calc(var(--shake-y) * -1), 0) rotate(var(--shake-rotate));
}
20% {
transform: translate3d(calc(var(--shake-x) * -1), var(--shake-y), 0) rotate(calc(var(--shake-rotate) * -1));
}
32% {
transform: translate3d(calc(var(--shake-x) * 0.6), calc(var(--shake-y) * -0.4), 0);
}
44% {
transform: translate3d(0, 0, 0) scale(1.015);
}
}
@keyframes glow-pulse {
0%, 100% {
transform: translateZ(-42px) scale(0.96);
}
50% {
transform: translateZ(-42px) scale(1.04);
}
}
.stat-row {
margin-top: -4px;
}
@ -477,66 +710,39 @@ footer {
}
.recliner-stage {
min-height: 320px;
min-height: 360px;
}
.recliner-figure {
width: 280px;
height: 220px;
}
.recliner-seat {
left: 76px;
width: 122px;
}
.recliner-base {
left: 92px;
--figure-scale: 0.84;
bottom: 8px;
}
}
@media (max-width: 575.98px) {
.recliner-stage {
min-height: 280px;
min-height: 300px;
}
.axis-label,
.motion-indicator {
top: 14px;
}
.motion-indicator {
max-width: calc(100% - 128px);
text-align: center;
line-height: 1.25;
}
.recliner-figure {
width: 240px;
height: 200px;
bottom: 36px;
}
.recliner-seat {
left: 58px;
bottom: 72px;
width: 112px;
height: 46px;
}
.recliner-back {
left: 68px;
bottom: 98px;
height: 104px;
}
.recliner-head {
left: 56px;
bottom: 170px;
}
.recliner-leg {
left: 168px;
height: 86px;
}
.recliner-base {
left: 76px;
width: 100px;
--figure-scale: 0.66;
bottom: -18px;
}
.angle-indicator {
right: 8px;
top: 8px;
top: 52px;
padding: 10px 12px;
}
}

View File

@ -14,6 +14,8 @@ document.addEventListener('DOMContentLoaded', () => {
const ui = {
figure: document.getElementById('recliner-figure'),
motion: document.getElementById('recliner-motion'),
motionState: document.getElementById('motion-state'),
angleValue: document.getElementById('angle-value'),
anglePill: document.getElementById('angle-pill'),
intensityPill: document.getElementById('intensity-pill'),
@ -36,6 +38,7 @@ document.addEventListener('DOMContentLoaded', () => {
let knownGamepads = [];
let scanTimer = null;
let continuousVibrationInterval = null;
let activeActuator = null;
const notify = (message) => {
if (!message) {
@ -56,19 +59,71 @@ document.addEventListener('DOMContentLoaded', () => {
return 'Upright';
};
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
const currentState = () => ({
angle: Number(controls.angle?.value || 0),
intensity: Number(controls.intensity?.value || 0),
pattern: controls.pattern?.value || 'continuous'
});
const vibrationIsRunning = () => continuousVibrationInterval !== null;
const updateMotionIndicator = (isActive, pattern, intensity) => {
if (!ui.motionState) {
return;
}
if (!isActive) {
ui.motionState.textContent = 'Preview idle';
return;
}
ui.motionState.textContent = `${capitalize(pattern)} haptics · ${intensity}%`;
};
const syncFigureVariables = (state) => {
if (!ui.figure) {
return;
}
const driftX = -Math.round(state.angle * 0.22);
const driftY = Math.round(state.angle * 0.08);
const shakeX = (1.6 + state.intensity * 0.055).toFixed(2);
const shakeY = (0.9 + state.intensity * 0.028).toFixed(2);
const shakeRotate = (0.28 + state.intensity * 0.008).toFixed(2);
const vibeSpeed = Math.max(90, 220 - state.intensity).toFixed(0);
const glowStrength = Math.min(0.46, 0.14 + state.intensity / 320).toFixed(2);
const glowOpacity = Math.min(0.92, 0.36 + state.intensity / 180).toFixed(2);
ui.figure.style.setProperty('--angle', String(state.angle));
ui.figure.style.setProperty('--intensity', String(state.intensity));
ui.figure.style.setProperty('--drift-x', `${driftX}px`);
ui.figure.style.setProperty('--drift-y', `${driftY}px`);
ui.figure.style.setProperty('--shake-x', `${shakeX}px`);
ui.figure.style.setProperty('--shake-y', `${shakeY}px`);
ui.figure.style.setProperty('--shake-rotate', `${shakeRotate}deg`);
ui.figure.style.setProperty('--vibe-speed', `${vibeSpeed}ms`);
ui.figure.style.setProperty('--glow-strength', glowStrength);
ui.figure.style.setProperty('--glow-opacity', glowOpacity);
ui.figure.dataset.pattern = state.pattern;
};
const setVisualVibrationState = (isActive, state = currentState()) => {
syncFigureVariables(state);
if (ui.figure) {
ui.figure.classList.toggle('is-vibrating', isActive);
}
if (ui.motion) {
ui.motion.classList.toggle('is-vibrating', isActive);
ui.motion.classList.toggle('vibration-continuous', isActive && state.pattern === 'continuous');
ui.motion.classList.toggle('vibration-pulse', isActive && state.pattern === 'pulse');
}
updateMotionIndicator(isActive, state.pattern, state.intensity);
};
const updateVisualization = () => {
const state = currentState();
const tone = presetTone(state.angle);
if (ui.figure) {
ui.figure.style.setProperty('--angle', String(state.angle));
ui.figure.style.setProperty('--intensity', String(state.intensity));
}
syncFigureVariables(state);
const map = {
angleValue: `${state.angle}`,
@ -91,9 +146,13 @@ document.addEventListener('DOMContentLoaded', () => {
ui[key].textContent = value;
}
});
};
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
if (!vibrationIsRunning()) {
updateMotionIndicator(false, state.pattern, state.intensity);
} else {
setVisualVibrationState(true, state);
}
};
const applyPresetState = (preset) => {
if (!preset) return;
@ -120,6 +179,33 @@ document.addEventListener('DOMContentLoaded', () => {
return knownGamepads.find((gamepad) => gamepad.index === selectedIndex) || knownGamepads[0];
};
const stopEffect = async (actuator) => {
if (!actuator) {
return;
}
try {
if (typeof actuator.reset === 'function') {
await actuator.reset();
return;
}
if (typeof actuator.playEffect === 'function') {
await actuator.playEffect('dual-rumble', {
startDelay: 0,
duration: 0,
weakMagnitude: 0,
strongMagnitude: 0
});
return;
}
if (typeof actuator.pulse === 'function') {
await actuator.pulse(0, 0);
}
} catch (error) {
console.debug('Unable to reset vibration actuator cleanly.', error);
}
};
const refreshGamepads = () => {
if (!navigator.getGamepads) {
if (ui.gamepadState) {
@ -132,6 +218,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (ui.testButton) {
ui.testButton.disabled = true;
}
setVisualVibrationState(false);
return;
}
@ -175,6 +262,16 @@ document.addEventListener('DOMContentLoaded', () => {
if (ui.testButton) {
ui.testButton.disabled = !active || !actuator;
}
if (vibrationIsRunning() && !actuator) {
window.clearInterval(continuousVibrationInterval);
continuousVibrationInterval = null;
activeActuator = null;
if (ui.testButton) {
ui.testButton.textContent = 'Test vibration (continuous)';
}
setVisualVibrationState(false);
}
};
const playEffect = async (actuator, intensity, duration) => {
@ -192,6 +289,70 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
const stopVibration = async ({ silent = false } = {}) => {
if (continuousVibrationInterval) {
window.clearInterval(continuousVibrationInterval);
continuousVibrationInterval = null;
}
await stopEffect(activeActuator);
activeActuator = null;
if (ui.testButton) {
ui.testButton.textContent = 'Test vibration (continuous)';
}
setVisualVibrationState(false);
if (!silent) {
notify('Vibration stopped.');
}
};
const startVibration = async (actuator, { silent = false } = {}) => {
const state = currentState();
const normalizedIntensity = Math.max(0, Math.min(1, state.intensity / 100));
activeActuator = actuator;
if (ui.testButton) {
ui.testButton.textContent = 'Stop vibration';
}
setVisualVibrationState(true, state);
if (state.pattern === 'pulse') {
const pulse = async () => {
const live = currentState();
setVisualVibrationState(true, live);
await playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 500);
};
await pulse();
continuousVibrationInterval = window.setInterval(() => {
pulse().catch((error) => console.error(error));
}, 800);
} else {
await playEffect(actuator, normalizedIntensity, 150);
continuousVibrationInterval = window.setInterval(() => {
const live = currentState();
setVisualVibrationState(true, live);
playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 150).catch((error) => console.error(error));
}, 100);
}
if (!silent) {
notify(`Running ${state.pattern} vibration at ${state.intensity}%.`);
}
};
const restartVibration = async () => {
if (!vibrationIsRunning()) {
return;
}
const gamepad = selectedGamepad();
const actuator = getActuator(gamepad);
if (!gamepad || !actuator) {
await stopVibration({ silent: true });
return;
}
await stopVibration({ silent: true });
await startVibration(actuator, { silent: true });
};
const runVibration = async () => {
const gamepad = selectedGamepad();
const actuator = getActuator(gamepad);
@ -201,38 +362,17 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
const { intensity, pattern } = currentState();
const normalizedIntensity = Math.max(0, Math.min(1, intensity / 100));
if (continuousVibrationInterval) {
window.clearInterval(continuousVibrationInterval);
continuousVibrationInterval = null;
ui.testButton.textContent = 'Test vibration (continuous)';
notify('Vibration stopped.');
if (vibrationIsRunning()) {
await stopVibration();
return;
}
ui.testButton.textContent = 'Stop vibration';
try {
if (pattern === 'pulse') {
const pulse = async () => {
await playEffect(actuator, normalizedIntensity, 500);
};
pulse();
continuousVibrationInterval = window.setInterval(pulse, 800);
} else {
// For continuous, we re-trigger every 100ms
await playEffect(actuator, normalizedIntensity, 150);
continuousVibrationInterval = window.setInterval(async () => {
await playEffect(actuator, normalizedIntensity, 150);
}, 100);
}
notify(`Running ${pattern} vibration at ${intensity}%.`);
await startVibration(actuator);
} catch (error) {
console.error(error);
await stopVibration({ silent: true });
notify('The browser detected the controller, but vibration could not start.');
ui.testButton.textContent = 'Test vibration (continuous)';
}
};
@ -253,7 +393,9 @@ document.addEventListener('DOMContentLoaded', () => {
};
if (ui.testButton) {
ui.testButton.addEventListener('click', runVibration);
ui.testButton.addEventListener('click', () => {
runVibration().catch((error) => console.error(error));
});
}
if (ui.resetButton) {
@ -263,13 +405,24 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
[controls.angle, controls.intensity, controls.pattern].forEach((element) => {
[controls.angle, controls.intensity].forEach((element) => {
if (element) {
element.addEventListener('input', updateVisualization);
element.addEventListener('change', updateVisualization);
}
});
if (controls.pattern) {
controls.pattern.addEventListener('input', () => {
updateVisualization();
restartVibration().catch((error) => console.error(error));
});
controls.pattern.addEventListener('change', () => {
updateVisualization();
restartVibration().catch((error) => console.error(error));
});
}
if (ui.gamepadSelect) {
ui.gamepadSelect.addEventListener('change', refreshGamepads);
}
@ -304,5 +457,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (continuousVibrationInterval) {
window.clearInterval(continuousVibrationInterval);
}
stopEffect(activeActuator).catch(() => {});
});
});
});

View File

@ -139,24 +139,37 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="small-label">Visualizer</div>
<h2 class="h4 mb-1">Live recline preview</h2>
<p class="text-secondary mb-0">A restrained 2D profile that updates instantly as you move the angle and intensity controls.</p>
<h2 class="h4 mb-1">Live 3D recline preview</h2>
<p class="text-secondary mb-0">A dimensional lounge model that tilts with the angle control and physically shakes in sync while the vibration test is running.</p>
</div>
<div class="badge badge-soft" id="recline-mode"><?= e(preset_tone((int) $formData['angle_deg'])) ?></div>
</div>
<div class="recliner-stage mb-4">
<div class="grid-fade"></div>
<div class="recliner-aurora" aria-hidden="true"></div>
<div class="axis-label axis-label-left">upright</div>
<div class="axis-label axis-label-right">full recline</div>
<div class="recliner-figure" id="recliner-figure" style="--angle: <?= e((string) $formData['angle_deg']) ?>; --intensity: <?= e((string) $formData['intensity_pct']) ?>;">
<div class="recliner-shadow"></div>
<div class="recliner-seat"></div>
<div class="recliner-arm"></div>
<div class="recliner-back" id="recliner-back"></div>
<div class="recliner-head"></div>
<div class="recliner-leg" id="recliner-leg"></div>
<div class="recliner-base"></div>
<div class="motion-indicator" id="motion-state">Preview idle</div>
<div class="recliner-figure" id="recliner-figure" role="img" aria-label="3D recliner preview responding to angle and vibration settings" data-pattern="<?= e($formData['pattern_mode']) ?>" style="--angle: <?= e((string) $formData['angle_deg']) ?>; --intensity: <?= e((string) $formData['intensity_pct']) ?>;">
<div class="recliner-rig" aria-hidden="true">
<div class="recliner-motion" id="recliner-motion">
<div class="recliner-glow"></div>
<div class="recliner-floor"></div>
<div class="recliner-plinth recliner-block"></div>
<div class="recliner-column recliner-block"></div>
<div class="recliner-base recliner-block"></div>
<div class="recliner-seat recliner-block"></div>
<div class="recliner-seat-pad"></div>
<div class="recliner-arm recliner-block"></div>
<div class="recliner-arm recliner-arm-secondary recliner-block"></div>
<div class="recliner-back recliner-block"></div>
<div class="recliner-back-inner"></div>
<div class="recliner-head recliner-block"></div>
<div class="recliner-leg recliner-block"></div>
<div class="recliner-footpad recliner-block"></div>
</div>
</div>
<div class="angle-indicator"><span id="angle-value"><?= e((string) $formData['angle_deg']) ?></span><small>degrees</small></div>
</div>
</div>