Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

6 changed files with 533 additions and 1868 deletions

155
app.php
View File

@ -1,155 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
const RECLINER_PATTERNS = ['continuous', 'pulse'];
function project_meta(): array
{
$projectName = $_SERVER['PROJECT_NAME'] ?? getenv('PROJECT_NAME') ?: 'Recliner Haptics Studio';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: 'Browser-based recliner simulator with gamepad vibration controls, saved presets, and a live recline angle visualizer.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? getenv('PROJECT_IMAGE_URL') ?: '';
return [
'name' => (string) $projectName,
'description' => (string) $projectDescription,
'image' => (string) $projectImageUrl,
];
}
function ensure_recliner_schema(): void
{
db()->exec(
"CREATE TABLE IF NOT EXISTS recliner_presets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
angle_deg TINYINT UNSIGNED NOT NULL,
intensity_pct TINYINT UNSIGNED NOT NULL,
pattern_mode VARCHAR(20) NOT NULL,
notes VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
function default_preset(): array
{
return [
'name' => 'Demo Lounge',
'angle_deg' => 112,
'intensity_pct' => 44,
'pattern_mode' => 'continuous',
'notes' => 'Balanced demo profile for a calm showroom preview.',
];
}
function validate_preset_input(array $input): array
{
$errors = [];
$name = trim((string) ($input['name'] ?? ''));
if ($name === '' || strlen($name) < 2 || strlen($name) > 120) {
$errors['name'] = 'Preset name must be between 2 and 120 characters.';
}
$angle = filter_var($input['angle_deg'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 160],
]);
if ($angle === false) {
$errors['angle_deg'] = 'Angle must be between 0° and 160°.';
}
$intensity = filter_var($input['intensity_pct'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 100],
]);
if ($intensity === false) {
$errors['intensity_pct'] = 'Intensity must be between 0% and 100%.';
}
$pattern = (string) ($input['pattern_mode'] ?? 'continuous');
if (!in_array($pattern, RECLINER_PATTERNS, true)) {
$errors['pattern_mode'] = 'Choose a supported vibration pattern.';
}
$notes = trim((string) ($input['notes'] ?? ''));
if (strlen($notes) > 255) {
$errors['notes'] = 'Notes must stay under 255 characters.';
}
return [
'errors' => $errors,
'data' => [
'name' => $name,
'angle_deg' => $angle === false ? 0 : (int) $angle,
'intensity_pct' => $intensity === false ? 0 : (int) $intensity,
'pattern_mode' => $pattern,
'notes' => $notes,
],
];
}
function save_preset(array $data): int
{
$stmt = db()->prepare(
'INSERT INTO recliner_presets (name, angle_deg, intensity_pct, pattern_mode, notes)
VALUES (:name, :angle_deg, :intensity_pct, :pattern_mode, :notes)'
);
$stmt->bindValue(':name', $data['name'], PDO::PARAM_STR);
$stmt->bindValue(':angle_deg', $data['angle_deg'], PDO::PARAM_INT);
$stmt->bindValue(':intensity_pct', $data['intensity_pct'], PDO::PARAM_INT);
$stmt->bindValue(':pattern_mode', $data['pattern_mode'], PDO::PARAM_STR);
$stmt->bindValue(':notes', $data['notes'] !== '' ? $data['notes'] : null, $data['notes'] !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->execute();
return (int) db()->lastInsertId();
}
function get_recent_presets(int $limit = 8): array
{
$limit = max(1, min(24, $limit));
$stmt = db()->query(
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, notes, created_at
FROM recliner_presets
ORDER BY created_at DESC, id DESC
LIMIT ' . $limit
);
return $stmt->fetchAll() ?: [];
}
function get_preset(int $id): ?array
{
$stmt = db()->prepare(
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, notes, created_at
FROM recliner_presets
WHERE id = :id
LIMIT 1'
);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$preset = $stmt->fetch();
return $preset ?: null;
}
function preset_tone(int $angle): string
{
if ($angle >= 135) {
return 'Deep recline';
}
if ($angle >= 90) {
return 'Lounge';
}
if ($angle >= 45) {
return 'Reading';
}
return 'Upright';
}
function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}

File diff suppressed because it is too large Load Diff

View File

@ -1,523 +1,39 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const appConfig = window.appConfig || {}; const chatForm = document.getElementById('chat-form');
const toastElement = document.getElementById('app-toast'); const chatInput = document.getElementById('chat-input');
const toastMessage = document.getElementById('toast-message'); const chatMessages = document.getElementById('chat-messages');
const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 3200 }) : null;
const controls = { const appendMessage = (text, sender) => {
angle: document.getElementById('angle_deg'), const msgDiv = document.createElement('div');
backAngle: document.getElementById('back_angle'), msgDiv.classList.add('message', sender);
legAngle: document.getElementById('leg_angle'), msgDiv.textContent = text;
headAngle: document.getElementById('head_angle'), chatMessages.appendChild(msgDiv);
intensity: document.getElementById('intensity_pct'), chatMessages.scrollTop = chatMessages.scrollHeight;
pattern: document.getElementById('pattern_mode'),
name: document.getElementById('name'),
notes: document.getElementById('notes')
}; };
const ui = { chatForm.addEventListener('submit', async (e) => {
figure: document.getElementById('recliner-figure'), e.preventDefault();
motion: document.getElementById('recliner-motion'), const message = chatInput.value.trim();
motionState: document.getElementById('motion-state'), if (!message) return;
angleValue: document.getElementById('angle-value'),
anglePill: document.getElementById('angle-pill'),
backAnglePill: document.getElementById('back-angle-pill'),
legAnglePill: document.getElementById('leg-angle-pill'),
headAnglePill: document.getElementById('head-angle-pill'),
intensityPill: document.getElementById('intensity-pill'),
statAngle: document.getElementById('stat-angle'),
statIntensity: document.getElementById('stat-intensity'),
statPattern: document.getElementById('stat-pattern'),
saveAngle: document.getElementById('save-angle'),
saveIntensity: document.getElementById('save-intensity'),
savePattern: document.getElementById('save-pattern'),
summaryAngle: document.getElementById('summary-angle'),
summaryIntensity: document.getElementById('summary-intensity'),
summaryPattern: document.getElementById('summary-pattern'),
reclineMode: document.getElementById('recline-mode'),
gamepadState: document.getElementById('gamepad-state'),
gamepadSelect: document.getElementById('gamepad-select'),
testButton: document.getElementById('test-vibration'),
resetButton: document.getElementById('reset-defaults')
};
let knownGamepads = []; appendMessage(message, 'visitor');
let scanTimer = null; chatInput.value = '';
let continuousVibrationInterval = null;
let activeActuator = null;
const notify = (message) => {
if (!message) {
return;
}
if (toastMessage) {
toastMessage.textContent = message;
}
if (toast) {
toast.show();
}
};
const presetTone = (angle) => {
if (angle >= 135) return 'Deep recline';
if (angle >= 90) return 'Lounge';
if (angle >= 45) return 'Reading';
return 'Upright';
};
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
const currentState = () => ({
angle: Number(controls.angle?.value || 0),
backAngle: Number(controls.backAngle?.value || 0),
legAngle: Number(controls.legAngle?.value || 0),
headAngle: Number(controls.headAngle?.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('--back-angle', String(state.backAngle));
ui.figure.style.setProperty('--leg-angle', String(state.legAngle));
ui.figure.style.setProperty('--head-angle', String(state.headAngle));
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);
syncFigureVariables(state);
const map = {
angleValue: `${state.angle}`,
anglePill: `${state.angle}°`,
backAnglePill: `${state.backAngle}°`,
legAnglePill: `${state.legAngle}°`,
headAnglePill: `${state.headAngle}°`,
intensityPill: `${state.intensity}%`,
statAngle: `${state.angle}°`,
statIntensity: `${state.intensity}%`,
statPattern: capitalize(state.pattern),
saveAngle: `${state.angle}°`,
saveIntensity: `${state.intensity}%`,
savePattern: capitalize(state.pattern),
summaryAngle: `${state.angle}°`,
summaryIntensity: `${state.intensity}%`,
summaryPattern: capitalize(state.pattern),
reclineMode: tone
};
Object.entries(map).forEach(([key, value]) => {
if (ui[key]) {
ui[key].textContent = value;
}
});
if (!vibrationIsRunning()) {
updateMotionIndicator(false, state.pattern, state.intensity);
} else {
setVisualVibrationState(true, state);
}
};
const applyPresetState = (preset) => {
if (!preset) return;
if (controls.angle && preset.angle_deg !== undefined) controls.angle.value = preset.angle_deg;
if (controls.backAngle && preset.back_angle !== undefined) controls.backAngle.value = preset.back_angle;
if (controls.legAngle && preset.leg_angle !== undefined) controls.legAngle.value = preset.leg_angle;
if (controls.headAngle && preset.head_angle !== undefined) controls.headAngle.value = preset.head_angle;
if (controls.intensity && preset.intensity_pct !== undefined) controls.intensity.value = preset.intensity_pct;
if (controls.pattern && preset.pattern_mode) controls.pattern.value = preset.pattern_mode;
if (controls.name && preset.name !== undefined) controls.name.value = preset.name;
if (controls.notes && preset.notes !== undefined) controls.notes.value = preset.notes;
updateVisualization();
};
const getActuator = (gamepad) => {
if (!gamepad) return null;
if (gamepad.vibrationActuator) return gamepad.vibrationActuator;
if (Array.isArray(gamepad.hapticActuators) && gamepad.hapticActuators.length > 0) {
return gamepad.hapticActuators[0];
}
return null;
};
const selectedGamepad = () => {
if (!ui.gamepadSelect || !knownGamepads.length) return null;
const selectedIndex = Number(ui.gamepadSelect.value || knownGamepads[0].index);
return knownGamepads.find((gamepad) => gamepad.index === selectedIndex) || knownGamepads[0];
};
const stopEffect = async (actuator) => {
if (!actuator) {
return;
}
try { try {
if (typeof actuator.reset === 'function') { const response = await fetch('api/chat.php', {
await actuator.reset(); method: 'POST',
return; headers: { 'Content-Type': 'application/json' },
} body: JSON.stringify({ message })
if (typeof actuator.playEffect === 'function') {
await actuator.playEffect('dual-rumble', {
startDelay: 0,
duration: 0,
weakMagnitude: 0,
strongMagnitude: 0
}); });
return; const data = await response.json();
}
if (typeof actuator.pulse === 'function') { // Artificial delay for realism
await actuator.pulse(0, 0); setTimeout(() => {
} appendMessage(data.reply, 'bot');
}, 500);
} catch (error) { } catch (error) {
console.debug('Unable to reset vibration actuator cleanly.', error); console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
} }
};
const refreshGamepads = () => {
if (!navigator.getGamepads) {
if (ui.gamepadState) {
ui.gamepadState.textContent = 'Gamepad API unavailable';
}
if (ui.gamepadSelect) {
ui.gamepadSelect.innerHTML = '<option>Unsupported browser</option>';
ui.gamepadSelect.disabled = true;
}
if (ui.testButton) {
ui.testButton.disabled = true;
}
setVisualVibrationState(false);
return;
}
knownGamepads = Array.from(navigator.getGamepads()).filter(Boolean);
const previousValue = ui.gamepadSelect ? ui.gamepadSelect.value : '';
if (ui.gamepadSelect) {
ui.gamepadSelect.innerHTML = '';
if (!knownGamepads.length) {
const option = document.createElement('option');
option.textContent = 'No controller detected';
option.value = '';
ui.gamepadSelect.appendChild(option);
ui.gamepadSelect.disabled = true;
} else {
knownGamepads.forEach((gamepad) => {
const option = document.createElement('option');
const supported = getActuator(gamepad) ? 'haptics ready' : 'no haptics';
option.value = String(gamepad.index);
option.textContent = `${gamepad.id} (${supported})`;
ui.gamepadSelect.appendChild(option);
});
ui.gamepadSelect.disabled = false;
const hasPrevious = knownGamepads.some((gamepad) => String(gamepad.index) === previousValue);
ui.gamepadSelect.value = hasPrevious ? previousValue : String(knownGamepads[0].index);
}
}
const active = selectedGamepad();
const actuator = getActuator(active);
if (ui.gamepadState) {
if (!active) {
ui.gamepadState.textContent = 'Connect a controller to enable rumble';
} else if (!actuator) {
ui.gamepadState.textContent = 'Controller found, but haptics are unavailable';
} else {
ui.gamepadState.textContent = 'Controller connected and ready';
}
}
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) => {
if (typeof actuator.playEffect === 'function') {
await actuator.playEffect('dual-rumble', {
startDelay: 0,
duration,
weakMagnitude: Math.min(1, intensity * 0.8 + 0.1),
strongMagnitude: intensity
});
return;
}
if (typeof actuator.pulse === 'function') {
await actuator.pulse(intensity, duration);
}
};
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);
if (!gamepad || !actuator) {
notify('No compatible haptic controller is currently selected.');
refreshGamepads();
return;
}
if (vibrationIsRunning()) {
await stopVibration();
return;
}
try {
await startVibration(actuator);
} catch (error) {
console.error(error);
await stopVibration({ silent: true });
notify('The browser detected the controller, but vibration could not start.');
}
};
const wirePresetButtons = () => {
document.querySelectorAll('.apply-preset').forEach((button) => {
button.addEventListener('click', () => {
applyPresetState({
name: button.dataset.name || '',
angle_deg: button.dataset.angle || 0,
back_angle: button.dataset.back || 0,
leg_angle: button.dataset.leg || 0,
head_angle: button.dataset.head || 0,
intensity_pct: button.dataset.intensity || 0,
pattern_mode: button.dataset.pattern || 'continuous',
notes: button.dataset.notes || ''
});
window.location.hash = 'simulator';
notify(`Loaded preset “${button.dataset.name || 'Preset'}” into the simulator.`);
});
});
};
if (ui.testButton) {
ui.testButton.addEventListener('click', () => {
runVibration().catch((error) => console.error(error));
});
}
if (ui.resetButton) {
ui.resetButton.addEventListener('click', () => {
applyPresetState(appConfig.defaults || {});
notify('Demo defaults restored.');
});
}
[controls.angle, controls.backAngle, controls.legAngle, controls.headAngle, 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);
}
window.addEventListener('gamepadconnected', (event) => {
refreshGamepads();
notify(`Connected: ${event.gamepad.id}`);
});
window.addEventListener('gamepaddisconnected', (event) => {
refreshGamepads();
notify(`Disconnected: ${event.gamepad.id}`);
});
// Keyboard Shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
const step = e.shiftKey ? 10 : 5;
switch (e.key.toLowerCase()) {
case 'v':
runVibration().catch(console.error);
break;
case 'r':
if (ui.resetButton) ui.resetButton.click();
break;
case 'arrowup':
if (controls.angle) {
controls.angle.value = Math.min(180, Number(controls.angle.value) + step);
updateVisualization();
}
break;
case 'arrowdown':
if (controls.angle) {
controls.angle.value = Math.max(0, Number(controls.angle.value) - step);
updateVisualization();
}
break;
case 'arrowright':
if (controls.intensity) {
controls.intensity.value = Math.min(100, Number(controls.intensity.value) + step);
updateVisualization();
}
break;
case 'arrowleft':
if (controls.intensity) {
controls.intensity.value = Math.max(0, Number(controls.intensity.value) - step);
updateVisualization();
}
break;
}
});
applyPresetState(appConfig.initialPreset || {});
wirePresetButtons();
refreshGamepads();
updateVisualization();
if (appConfig.toastMessage) {
notify(appConfig.toastMessage);
}
if (navigator.getGamepads) {
scanTimer = window.setInterval(refreshGamepads, 1500);
}
window.addEventListener('beforeunload', () => {
if (scanTimer) {
window.clearInterval(scanTimer);
}
if (continuousVibrationInterval) {
window.clearInterval(continuousVibrationInterval);
}
stopEffect(activeActuator).catch(() => {});
}); });
}); });

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
header('Content-Type: application/json; charset=utf-8');
try {
ensure_recliner_schema();
$count = (int) db()->query('SELECT COUNT(*) FROM recliner_presets')->fetchColumn();
echo json_encode([
'status' => 'ok',
'database' => 'connected',
'preset_count' => $count,
'timestamp_utc' => gmdate('c'),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Health check failed.',
'timestamp_utc' => gmdate('c'),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

479
index.php
View File

@ -1,363 +1,150 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1');
require_once __DIR__ . '/app.php'; @error_reporting(E_ALL);
@date_default_timezone_set('UTC'); @date_default_timezone_set('UTC');
ensure_recliner_schema(); $phpVersion = PHP_VERSION;
$meta = project_meta(); $now = date('Y-m-d H:i:s');
$pageTitle = $meta['name'] === 'Recliner Haptics Studio' ? $meta['name'] : $meta['name'] . ' | Recliner Haptics Studio';
$pageDescription = $meta['description'];
$projectImageUrl = $meta['image'];
$errors = [];
$formData = default_preset();
$selectedPreset = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_preset') {
$validation = validate_preset_input($_POST);
$errors = $validation['errors'];
$formData = $validation['data'];
if ($errors === []) {
$savedId = save_preset($formData);
header('Location: /index.php?saved=' . $savedId . '&preset=' . $savedId . '#presets');
exit;
}
}
$presetId = filter_input(INPUT_GET, 'preset', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($presetId) {
$selectedPreset = get_preset((int) $presetId);
if ($selectedPreset && $_SERVER['REQUEST_METHOD'] !== 'POST') {
$formData = [
'name' => (string) $selectedPreset['name'],
'angle_deg' => (int) $selectedPreset['angle_deg'],
'intensity_pct' => (int) $selectedPreset['intensity_pct'],
'pattern_mode' => (string) $selectedPreset['pattern_mode'],
'notes' => (string) ($selectedPreset['notes'] ?? ''),
];
}
}
$recentPresets = get_recent_presets(8);
$assetVersion = (string) max(@filemtime(__DIR__ . '/assets/css/custom.css') ?: time(), @filemtime(__DIR__ . '/assets/js/main.js') ?: time());
$toastMessage = null;
if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
$toastMessage = 'Preset saved. You can reopen it anytime from Recent presets.';
}
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= e($pageTitle) ?></title> <title>New Style</title>
<meta name="description" content="<?= e($pageDescription) ?>"> <?php
<meta name="theme-color" content="#0b0d10"> // Read project preview data from environment
<meta property="og:title" content="<?= e($pageTitle) ?>"> $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
<meta property="og:description" content="<?= e($pageDescription) ?>"> $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<meta property="twitter:title" content="<?= e($pageTitle) ?>"> ?>
<meta property="twitter:description" content="<?= e($pageDescription) ?>"> <?php if ($projectDescription): ?>
<?php if ($projectImageUrl): ?> <!-- Meta description -->
<meta property="og:image" content="<?= e($projectImageUrl) ?>"> <meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>"> <!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?> <?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <?php if ($projectImageUrl): ?>
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= e($assetVersion) ?>"> <!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head> </head>
<body> <body>
<div class="toast-container position-fixed top-0 end-0 p-3"> <main>
<div id="app-toast" class="toast text-bg-dark border border-secondary-subtle" role="status" aria-live="polite" aria-atomic="true"> <div class="card">
<div class="toast-header bg-dark text-light border-bottom border-secondary-subtle"> <h1>Analyzing your requirements and generating your website…</h1>
<strong class="me-auto">Recliner Haptics</strong> <div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<small>Now</small> <span class="sr-only">Loading…</span>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
</div> </div>
<div class="toast-body" id="toast-message"><?= e($toastMessage ?? '') ?></div> <p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
</div> <p class="hint">This page will update automatically as the plan is implemented.</p>
</div> <p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<header class="border-bottom border-secondary-subtle sticky-top shell-header">
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container-xxl py-2">
<a class="navbar-brand fw-semibold" href="/index.php">Recliner Haptics</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="#simulator">Simulator</a></li>
<li class="nav-item"><a class="nav-link" href="#presets">Recent presets</a></li>
<li class="nav-item"><a class="nav-link" href="/healthz.php">Health</a></li>
</ul>
</div>
</div>
</nav>
</header>
<main class="py-4 py-lg-5">
<div class="container-xxl">
<section class="hero-panel panel p-4 p-lg-5 mb-4 mb-lg-5">
<div class="row g-4 align-items-center">
<div class="col-lg-8">
<div class="eyebrow mb-3">Browser web app · Gamepad vibration demo</div>
<h1 class="display-title mb-3">Simulate a recliner experience with live angle control and haptic testing.</h1>
<p class="lead text-secondary mb-4">Connect a compatible gamepad in Chrome or Edge, tune the recline angle, choose a vibration pattern, and save reusable presets for demos or prototyping.</p>
<div class="d-flex flex-wrap gap-2 meta-pills">
<span class="chip">0160° recline visualizer</span>
<span class="chip">Continuous + pulse rumble</span>
<span class="chip">Saved preset library</span>
</div>
</div>
<div class="col-lg-4">
<div class="panel inset-panel p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<div class="small-label">Loaded state</div>
<div class="h5 mb-0"><?= e($selectedPreset['name'] ?? $formData['name']) ?></div>
</div>
<span class="status-dot <?= $selectedPreset ? 'status-live' : 'status-idle' ?>"></span>
</div>
<div class="spec-list">
<div><span>Angle</span><strong id="summary-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
<div><span>Intensity</span><strong id="summary-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
<div><span>Pattern</span><strong id="summary-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
</div>
<p class="small text-secondary mb-0 mt-3">Tip: the browser requires a user interaction before triggering vibration. Press <strong>Test vibration</strong> after connecting the controller.</p>
</div>
</div>
</div>
</section>
<form method="post" class="row g-4 align-items-start" id="preset-form">
<input type="hidden" name="action" value="save_preset">
<div class="col-xl-7" id="simulator">
<section class="panel p-3 p-md-4 mb-4">
<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 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="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']) ?>; --back-angle: 0; --leg-angle: 0; --head-angle: 0;">
<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-arm-top"></div>
<div class="recliner-back recliner-block" id="part-back"></div>
<div class="recliner-back-inner" id="part-back-inner"></div>
<div class="recliner-head recliner-block" id="part-head"></div>
<div class="recliner-head-pad" id="part-head-pad"></div>
<div class="recliner-leg recliner-block" id="part-leg"></div>
<div class="recliner-footpad recliner-block" id="part-footpad"></div>
</div>
</div>
<div class="angle-indicator"><span id="angle-value"><?= e((string) $formData['angle_deg']) ?></span><small>degrees</small></div>
</div>
</div>
<div class="row g-3 stat-row">
<div class="col-6 col-md-4">
<div class="mini-stat"><span>Recline</span><strong id="stat-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
</div>
<div class="col-6 col-md-4">
<div class="mini-stat"><span>Rumble</span><strong id="stat-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
</div>
<div class="col-6 col-md-4">
<div class="mini-stat"><span>Pattern</span><strong id="stat-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
</div>
</div>
</section>
<section class="panel p-3 p-md-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="small-label">Controls</div>
<h2 class="h4 mb-1">Part and Haptic Controls</h2>
<p class="text-secondary mb-0">Adjust individual recliner parts and test haptics.</p>
</div>
<div class="gamepad-state" id="gamepad-state">Scanning for controllers…</div>
</div>
<div class="row g-3 align-items-end mb-4">
<div class="col-md-7">
<label for="gamepad-select" class="form-label">Detected gamepad</label>
<select class="form-select" id="gamepad-select" aria-describedby="gamepad-help"></select>
<div id="gamepad-help" class="form-text">Works best in Chrome or Edge with a gamepad that exposes <code>vibrationActuator</code> support.</div>
</div>
<div class="col-md-5">
<label class="form-label d-block">Action</label>
<button type="button" class="btn btn-accent w-100" id="test-vibration">Test vibration (continuous)</button>
</div>
</div>
<div class="control-stack">
<div class="control-group">
<div class="d-flex justify-content-between align-items-center mb-2"><label for="angle_deg" class="form-label mb-0">Overall Recline</label><span class="value-pill" id="angle-pill"><?= e((string) $formData['angle_deg']) ?>°</span></div>
<input type="range" class="form-range" min="0" max="160" step="1" id="angle_deg" name="angle_deg" value="<?= e((string) $formData['angle_deg']) ?>">
</div>
<div class="control-group">
<div class="d-flex justify-content-between align-items-center mb-2"><label for="back_angle" class="form-label mb-0">Back Adjustment</label><span class="value-pill" id="back-angle-pill">0°</span></div>
<input type="range" class="form-range" min="-20" max="20" step="1" id="back_angle" name="back_angle" value="0">
</div>
<div class="control-group">
<div class="d-flex justify-content-between align-items-center mb-2"><label for="leg_angle" class="form-label mb-0">Leg Adjustment</label><span class="value-pill" id="leg-angle-pill">0°</span></div>
<input type="range" class="form-range" min="-20" max="20" step="1" id="leg_angle" name="leg_angle" value="0">
</div>
<div class="control-group">
<div class="d-flex justify-content-between align-items-center mb-2"><label for="head_angle" class="form-label mb-0">Head Adjustment</label><span class="value-pill" id="head-angle-pill">0°</span></div>
<input type="range" class="form-range" min="-20" max="20" step="1" id="head_angle" name="head_angle" value="0">
</div>
<div class="control-group">
<div class="d-flex justify-content-between align-items-center mb-2"><label for="intensity_pct" class="form-label mb-0">Vibration intensity</label><span class="value-pill" id="intensity-pill"><?= e((string) $formData['intensity_pct']) ?>%</span></div>
<input type="range" class="form-range" min="0" max="100" step="1" id="intensity_pct" name="intensity_pct" value="<?= e((string) $formData['intensity_pct']) ?>">
</div>
<div class="control-group">
<label for="pattern_mode" class="form-label">Pattern</label>
<select class="form-select" id="pattern_mode" name="pattern_mode">
<option value="continuous" <?= $formData['pattern_mode'] === 'continuous' ? 'selected' : '' ?>>Continuous</option>
<option value="pulse" <?= $formData['pattern_mode'] === 'pulse' ? 'selected' : '' ?>>Pulse</option>
</select>
</div>
</div>
</section>
</div>
<div class="col-xl-5">
<section class="panel p-3 p-md-4 mb-4">
<div class="small-label">Save preset</div>
<h2 class="h4 mb-1">Name and store the current configuration</h2>
<p class="text-secondary mb-4">Create a reusable preset library so your simulator feels like a real tool, not just a single test screen.</p>
<?php if ($errors): ?>
<div class="alert alert-danger border-0 mb-4" role="alert">
<strong>Couldnt save preset.</strong>
<ul class="mb-0 mt-2 ps-3">
<?php foreach ($errors as $error): ?>
<li><?= e($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="mb-3">
<label for="name" class="form-label">Preset name</label>
<input type="text" class="form-control <?= isset($errors['name']) ? 'is-invalid' : '' ?>" id="name" name="name" maxlength="120" value="<?= e($formData['name']) ?>" placeholder="Evening Relaxation">
<?php if (isset($errors['name'])): ?><div class="invalid-feedback"><?= e($errors['name']) ?></div><?php endif; ?>
</div>
<div class="mb-4">
<label for="notes" class="form-label">Operator notes</label>
<textarea class="form-control <?= isset($errors['notes']) ? 'is-invalid' : '' ?>" id="notes" name="notes" rows="4" maxlength="255" placeholder="Useful for showroom demos, comfort testing, or accessibility rehearsal."><?= e($formData['notes']) ?></textarea>
<?php if (isset($errors['notes'])): ?><div class="invalid-feedback"><?= e($errors['notes']) ?></div><?php endif; ?>
</div>
<div class="panel inset-panel p-3 mb-4">
<div class="small-label mb-2">Current profile</div>
<div class="spec-list compact">
<div><span>Angle</span><strong id="save-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
<div><span>Intensity</span><strong id="save-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
<div><span>Pattern</span><strong id="save-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-light">Save preset</button>
<button type="button" class="btn btn-outline-light" id="reset-defaults">Reset to demo defaults</button>
</div>
</section>
<section class="panel p-3 p-md-4" id="presets">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="small-label">Preset library</div>
<h2 class="h4 mb-1">Recent presets</h2>
<p class="text-secondary mb-0">Load a saved setup back into the simulator or open a dedicated detail view.</p>
</div>
<span class="badge badge-soft"><?= count($recentPresets) ?> stored</span>
</div>
<?php if ($recentPresets): ?>
<div class="preset-list">
<?php foreach ($recentPresets as $preset): ?>
<article class="preset-card <?= $selectedPreset && (int) $selectedPreset['id'] === (int) $preset['id'] ? 'is-active' : '' ?>">
<div class="d-flex justify-content-between gap-3 mb-2">
<div>
<h3 class="h6 mb-1"><?= e($preset['name']) ?></h3>
<p class="text-secondary small mb-0"><?= e(preset_tone((int) $preset['angle_deg'])) ?> · <?= e(date('M j, Y · H:i', strtotime((string) $preset['created_at']))) ?> UTC</p>
</div>
<span class="badge badge-soft"><?= e((string) $preset['angle_deg']) ?>°</span>
</div>
<div class="preset-metrics mb-3">
<span><?= e((string) $preset['intensity_pct']) ?>% intensity</span>
<span><?= e(ucfirst((string) $preset['pattern_mode'])) ?></span>
</div>
<?php if (!empty($preset['notes'])): ?>
<p class="text-secondary small mb-3"><?= e($preset['notes']) ?></p>
<?php endif; ?>
<div class="d-flex flex-wrap gap-2">
<button
type="button"
class="btn btn-sm btn-outline-light apply-preset"
data-id="<?= e((string) $preset['id']) ?>"
data-name="<?= e($preset['name']) ?>"
data-angle="<?= e((string) $preset['angle_deg']) ?>"
data-intensity="<?= e((string) $preset['intensity_pct']) ?>"
data-pattern="<?= e((string) $preset['pattern_mode']) ?>"
data-notes="<?= e((string) ($preset['notes'] ?? '')) ?>"
>Apply in simulator</button>
<a class="btn btn-sm btn-link text-decoration-none px-0" href="/preset.php?id=<?= e((string) $preset['id']) ?>">View detail</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state">
<h3 class="h6 mb-2">No presets yet</h3>
<p class="text-secondary mb-0">Tune the simulator, save your first profile, and it will appear here for quick reuse.</p>
</div>
<?php endif; ?>
</section>
</div>
</form>
</div> </div>
</main> </main>
<footer>
<footer class="py-4 border-top border-secondary-subtle"> Page updated: <?= htmlspecialchars($now) ?> (UTC)
<div class="container-xxl d-flex flex-column flex-lg-row justify-content-between gap-2">
<p class="text-secondary small mb-0">Built for browser-based recliner demos with connected gamepads and saved comfort presets.</p>
<p class="text-secondary small mb-0">Use Chrome or Edge, connect the controller first, then press a button on the device so the browser exposes it.</p>
</div>
</footer> </footer>
<script>
window.appConfig = {
toastMessage: <?= json_encode($toastMessage, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>,
initialPreset: <?= json_encode($formData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>,
selectedPresetId: <?= json_encode($selectedPreset['id'] ?? null) ?>,
defaults: <?= json_encode(default_preset(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>
};
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
<script src="/assets/js/main.js?v=<?= e($assetVersion) ?>" defer></script>
</body> </body>
</html> </html>

View File

@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
ensure_recliner_schema();
$meta = project_meta();
$presetId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$preset = $presetId ? get_preset((int) $presetId) : null;
http_response_code($preset ? 200 : 404);
$assetVersion = (string) max(@filemtime(__DIR__ . '/assets/css/custom.css') ?: time(), @filemtime(__DIR__ . '/assets/js/main.js') ?: time());
$pageTitle = ($preset ? $preset['name'] . ' · ' : '') . $meta['name'];
$pageDescription = $preset
? 'Preset detail for ' . $preset['name'] . ' with a saved recline angle of ' . $preset['angle_deg'] . ' degrees.'
: $meta['description'];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?></title>
<meta name="description" content="<?= e($pageDescription) ?>">
<meta name="theme-color" content="#0b0d10">
<?php if (!empty($meta['image'])): ?>
<meta property="og:image" content="<?= e($meta['image']) ?>">
<meta property="twitter:image" content="<?= e($meta['image']) ?>">
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= e($assetVersion) ?>">
</head>
<body>
<header class="border-bottom border-secondary-subtle shell-header">
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container-xxl py-2">
<a class="navbar-brand fw-semibold" href="/index.php">Recliner Haptics</a>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-sm btn-outline-light" href="/index.php#presets">Back to presets</a>
<?php if ($preset): ?>
<a class="btn btn-sm btn-light" href="/index.php?preset=<?= e((string) $preset['id']) ?>#simulator">Open in simulator</a>
<?php endif; ?>
</div>
</div>
</nav>
</header>
<main class="py-4 py-lg-5">
<div class="container-xl">
<?php if (!$preset): ?>
<section class="panel p-4 p-lg-5 text-center mx-auto" style="max-width: 720px;">
<div class="small-label">Preset detail</div>
<h1 class="h3 mb-3">Preset not found</h1>
<p class="text-secondary mb-4">The requested preset does not exist yet or may have been removed.</p>
<a class="btn btn-light" href="/index.php#presets">Return to the simulator</a>
</section>
<?php else: ?>
<section class="hero-panel panel p-4 p-lg-5 mb-4">
<div class="row g-4 align-items-center">
<div class="col-lg-8">
<div class="eyebrow mb-3">Preset detail · #<?= e((string) $preset['id']) ?></div>
<h1 class="display-title mb-3"><?= e($preset['name']) ?></h1>
<p class="lead text-secondary mb-4">A saved recliner profile for repeatable haptic demos. Use this screen to review the settings, then reopen it in the simulator to test with a connected controller.</p>
<div class="d-flex flex-wrap gap-2 meta-pills">
<span class="chip"><?= e((string) $preset['angle_deg']) ?>° angle</span>
<span class="chip"><?= e((string) $preset['intensity_pct']) ?>% intensity</span>
<span class="chip"><?= e(ucfirst((string) $preset['pattern_mode'])) ?> rumble</span>
</div>
</div>
<div class="col-lg-4">
<div class="panel inset-panel p-3 h-100">
<div class="small-label mb-3">Profile tone</div>
<div class="h4 mb-2"><?= e(preset_tone((int) $preset['angle_deg'])) ?></div>
<p class="text-secondary small mb-0">Saved on <?= e(date('F j, Y H:i', strtotime((string) $preset['created_at']))) ?> UTC.</p>
</div>
</div>
</div>
</section>
<div class="row g-4">
<div class="col-lg-7">
<section class="panel p-4 h-100">
<div class="small-label mb-3">Settings overview</div>
<div class="spec-list preset-detail-specs">
<div><span>Recline angle</span><strong><?= e((string) $preset['angle_deg']) ?>°</strong></div>
<div><span>Vibration intensity</span><strong><?= e((string) $preset['intensity_pct']) ?>%</strong></div>
<div><span>Pattern</span><strong><?= e(ucfirst((string) $preset['pattern_mode'])) ?></strong></div>
<div><span>Saved at</span><strong><?= e((string) $preset['created_at']) ?></strong></div>
</div>
<hr class="border-secondary-subtle my-4">
<div class="small-label mb-2">Operator notes</div>
<p class="text-secondary mb-0"><?= e($preset['notes'] ?: 'No notes added for this preset.') ?></p>
</section>
</div>
<div class="col-lg-5">
<section class="panel p-4 h-100 d-flex flex-column justify-content-between">
<div>
<div class="small-label mb-3">Next action</div>
<h2 class="h4 mb-2">Load this profile into the simulator</h2>
<p class="text-secondary mb-4">Jump back into the main workspace with these saved values prefilled. Then press <strong>Test vibration</strong> to drive the controller.</p>
</div>
<div class="d-grid gap-2">
<a class="btn btn-light" href="/index.php?preset=<?= e((string) $preset['id']) ?>#simulator">Open in simulator</a>
<a class="btn btn-outline-light" href="/index.php#presets">Browse recent presets</a>
</div>
</section>
</div>
</div>
<?php endif; ?>
</div>
</main>
</body>
</html>