diff --git a/app.php b/app.php
new file mode 100644
index 0000000..df6c949
--- /dev/null
+++ b/app.php
@@ -0,0 +1,166 @@
+ (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,
+ duration_ms SMALLINT UNSIGNED 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',
+ 'duration_ms' => 1400,
+ '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%.';
+ }
+
+ $duration = filter_var($input['duration_ms'] ?? null, FILTER_VALIDATE_INT, [
+ 'options' => ['min_range' => 100, 'max_range' => 5000],
+ ]);
+ if ($duration === false) {
+ $errors['duration_ms'] = 'Duration must be between 100 ms and 5000 ms.';
+ }
+
+ $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,
+ 'duration_ms' => $duration === false ? 1000 : (int) $duration,
+ 'notes' => $notes,
+ ],
+ ];
+}
+
+function save_preset(array $data): int
+{
+ $stmt = db()->prepare(
+ 'INSERT INTO recliner_presets (name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes)
+ VALUES (:name, :angle_deg, :intensity_pct, :pattern_mode, :duration_ms, :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(':duration_ms', $data['duration_ms'], PDO::PARAM_INT);
+ $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, duration_ms, 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, duration_ms, 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');
+}
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..46d81b7 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,542 @@
+:root {
+ --bg: #0b0d10;
+ --surface: #12161b;
+ --surface-2: #171c22;
+ --surface-3: #1d232b;
+ --border: #2a3038;
+ --border-strong: #39414b;
+ --text: #edf1f5;
+ --muted: #9aa4af;
+ --accent: #d4d8de;
+ --accent-ink: #0f1318;
+ --success: #66bb8a;
+ --danger: #ef8c8c;
+ --shadow: 0 20px 48px rgba(0, 0, 0, 0.28);
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+ --space-6: 32px;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
body {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
- margin: 0;
- min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ letter-spacing: -0.01em;
}
-.main-wrapper {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
+.shell-header {
+ background: rgba(11, 13, 16, 0.88);
+ backdrop-filter: blur(12px);
+}
+
+.navbar-brand {
+ letter-spacing: -0.03em;
+}
+
+.panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow);
+}
+
+.inset-panel {
+ background: var(--surface-2);
+ box-shadow: none;
+}
+
+.hero-panel {
position: relative;
- z-index: 1;
-}
-
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
-}
-
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
- display: flex;
- flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
overflow: hidden;
}
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
-}
-
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
-}
-
-.message {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-.message.visitor {
- align-self: flex-end;
- background: linear-gradient(135deg, #212529 0%, #343a40 100%);
- color: #fff;
- border-bottom-right-radius: 4px;
-}
-
-.message.bot {
- align-self: flex-start;
- background: #ffffff;
- color: #212529;
- border-bottom-left-radius: 4px;
-}
-
-.chat-input-area {
- padding: 1.25rem;
- background: rgba(255, 255, 255, 0.5);
- border-top: 1px solid rgba(0, 0, 0, 0.05);
-}
-
-.chat-input-area form {
- display: flex;
- gap: 0.75rem;
-}
-
-.chat-input-area input {
- flex: 1;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- padding: 0.75rem 1rem;
- outline: none;
- background: rgba(255, 255, 255, 0.9);
- transition: all 0.3s ease;
-}
-
-.chat-input-area input:focus {
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
-}
-
-.chat-input-area button {
- background: #212529;
- color: #fff;
- border: none;
- padding: 0.75rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
-}
-
-.chat-input-area button:hover {
- background: #000;
- transform: translateY(-2px);
- box-shadow: 0 5px 15px rgba(0,0,0,0.2);
-}
-
-/* Background Animations */
-.bg-animations {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
+.hero-panel::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border: 1px solid rgba(255, 255, 255, 0.03);
+ border-radius: inherit;
pointer-events: none;
}
-.blob {
- position: absolute;
- width: 500px;
- height: 500px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
- filter: blur(80px);
- animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
+.display-title {
+ font-size: clamp(2rem, 3vw, 3.5rem);
+ line-height: 1.02;
+ font-weight: 650;
+ max-width: 12ch;
+ letter-spacing: -0.05em;
}
-.blob-1 {
- top: -10%;
- left: -10%;
- background: rgba(238, 119, 82, 0.4);
-}
-
-.blob-2 {
- bottom: -10%;
- right: -10%;
- background: rgba(35, 166, 213, 0.4);
- animation-delay: -7s;
- width: 600px;
- height: 600px;
-}
-
-.blob-3 {
- top: 40%;
- left: 30%;
- background: rgba(231, 60, 126, 0.3);
- animation-delay: -14s;
- width: 450px;
- height: 450px;
-}
-
-@keyframes move {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
- 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
- 100% { transform: translate(0, 0) rotate(360deg) scale(1); }
-}
-
-.header-link {
- font-size: 14px;
- color: #fff;
- text-decoration: none;
- background: rgba(0, 0, 0, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 8px;
- transition: all 0.3s ease;
-}
-
-.header-link:hover {
- background: rgba(0, 0, 0, 0.4);
- text-decoration: none;
-}
-
-/* Admin Styles */
-.admin-container {
- max-width: 900px;
- margin: 3rem auto;
- padding: 2.5rem;
- background: rgba(255, 255, 255, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 24px;
- box-shadow: 0 20px 50px rgba(0,0,0,0.15);
- border: 1px solid rgba(255, 255, 255, 0.4);
- position: relative;
- z-index: 1;
-}
-
-.admin-container h1 {
- margin-top: 0;
- color: #212529;
- font-weight: 800;
-}
-
-.table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0 8px;
- margin-top: 1.5rem;
-}
-
-.table th {
- background: transparent;
- border: none;
- padding: 1rem;
- color: #6c757d;
- font-weight: 600;
+.eyebrow,
+.small-label {
+ color: var(--muted);
text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
-}
-
-.table td {
- background: #fff;
- padding: 1rem;
- border: none;
-}
-
-.table tr td:first-child { border-radius: 12px 0 0 12px; }
-.table tr td:last-child { border-radius: 0 12px 12px 0; }
-
-.form-group {
- margin-bottom: 1.25rem;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
+ letter-spacing: 0.12em;
+ font-size: 0.72rem;
font-weight: 600;
- font-size: 0.9rem;
}
-.form-control {
- width: 100%;
- padding: 0.75rem 1rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- background: #fff;
- transition: all 0.3s ease;
- box-sizing: border-box;
+.lead,
+.text-secondary,
+.form-text,
+.form-label,
+.form-control::placeholder,
+textarea::placeholder {
+ color: var(--muted) !important;
}
-.form-control:focus {
- outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
+.meta-pills,
+.control-stack {
+ display: grid;
+ gap: var(--space-3);
}
-.header-container {
- display: flex;
- justify-content: space-between;
+.chip,
+.badge-soft,
+.value-pill,
+.gamepad-state {
+ display: inline-flex;
align-items: center;
+ gap: var(--space-2);
+ border: 1px solid var(--border-strong);
+ background: rgba(255, 255, 255, 0.02);
+ color: var(--text);
+ border-radius: 999px;
+ padding: 0.5rem 0.8rem;
+ font-size: 0.85rem;
}
-.header-links {
+.badge-soft {
+ padding: 0.45rem 0.7rem;
+}
+
+.spec-list {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.spec-list > div {
display: flex;
- gap: 1rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding-bottom: var(--space-2);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
-.admin-card {
- background: rgba(255, 255, 255, 0.6);
- padding: 2rem;
- border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-bottom: 2.5rem;
- box-shadow: 0 10px 30px rgba(0,0,0,0.05);
+.spec-list > div:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
}
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
- font-weight: 700;
+.spec-list span,
+.mini-stat span,
+.preset-metrics span {
+ color: var(--muted);
+ font-size: 0.82rem;
}
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
+.spec-list.compact {
+ gap: var(--space-2);
}
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
+.status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ display: inline-block;
+ margin-top: 4px;
}
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
+.status-live {
+ background: var(--success);
+ box-shadow: 0 0 0 8px rgba(102, 187, 138, 0.12);
+}
+
+.status-idle {
+ background: #66707a;
+ box-shadow: 0 0 0 8px rgba(102, 112, 122, 0.12);
+}
+
+.recliner-stage {
+ position: relative;
+ background: #101419;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ min-height: 360px;
+ overflow: hidden;
+}
+
+.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%);
+}
+
+.axis-label {
+ position: absolute;
+ top: 18px;
+ color: var(--muted);
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.axis-label-left { left: 18px; }
+.axis-label-right { right: 18px; }
+
+.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));
+}
+
+.recliner-shadow,
+.recliner-seat,
+.recliner-arm,
+.recliner-back,
+.recliner-head,
+.recliner-leg,
+.recliner-base {
+ position: absolute;
+ background: var(--surface-3);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.recliner-shadow {
+ left: 78px;
+ right: 26px;
+ bottom: 6px;
+ height: 18px;
+ border: 0;
+ background: rgba(0, 0, 0, 0.32);
+ filter: blur(14px);
+ border-radius: 999px;
+}
+
+.recliner-seat {
+ left: 92px;
+ bottom: 78px;
+ width: 130px;
+ height: 52px;
+ border-radius: 14px 14px 10px 10px;
+}
+
+.recliner-arm {
+ left: 120px;
+ bottom: 118px;
+ width: 82px;
+ height: 20px;
+ border-radius: 10px;
+}
+
+.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)));
+}
+
+.recliner-head {
+ left: 90px;
+ bottom: 188px;
+ width: 48px;
+ height: 30px;
+ border-radius: 14px;
+ transform: rotate(calc(-14deg + (var(--angle) * 0.1deg)));
+}
+
+.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)));
+}
+
+.recliner-base {
+ left: 112px;
+ bottom: 44px;
+ width: 112px;
+ height: 14px;
+ border-radius: 999px;
+}
+
+.angle-indicator {
+ position: absolute;
+ right: 12px;
+ top: 16px;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-end;
+ padding: 12px 14px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ background: rgba(11, 13, 16, 0.74);
+}
+
+.angle-indicator span {
+ font-size: 1.85rem;
+ font-weight: 650;
+ line-height: 1;
+}
+
+.angle-indicator small {
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.09em;
+ margin-top: 4px;
+}
+
+.stat-row {
+ margin-top: -4px;
+}
+
+.mini-stat {
+ height: 100%;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 14px;
+}
+
+.mini-stat strong {
+ display: block;
+ margin-top: 6px;
+ font-size: 1rem;
+}
+
+.control-group {
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.control-group:last-child {
+ padding-bottom: 0;
+ border-bottom: 0;
+}
+
+.form-range::-webkit-slider-runnable-track {
+ background: #242a32;
+ height: 4px;
+ border-radius: 999px;
+}
+
+.form-range::-webkit-slider-thumb {
+ margin-top: -6px;
+ width: 16px;
+ height: 16px;
+ background: var(--accent);
+ border: 0;
+}
+
+.form-range::-moz-range-track {
+ background: #242a32;
+ height: 4px;
+ border-radius: 999px;
+}
+
+.form-range::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border: 0;
+ background: var(--accent);
+}
+
+.form-control,
+.form-select,
+textarea {
+ background: #0f1318 !important;
+ border: 1px solid var(--border-strong) !important;
+ color: var(--text) !important;
+ border-radius: 10px !important;
+ padding: 0.75rem 0.9rem !important;
+}
+
+.form-control:focus,
+.form-select:focus,
+textarea:focus,
+.btn:focus,
+.form-range:focus {
+ box-shadow: 0 0 0 0.2rem rgba(212, 216, 222, 0.12) !important;
+ border-color: #58616d !important;
+}
+
+.btn {
+ border-radius: 10px;
+ padding: 0.78rem 1rem;
font-weight: 600;
- width: 100%;
- transition: all 0.3s ease;
+ letter-spacing: -0.01em;
}
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
+.btn-light {
+ background: var(--accent);
+ color: var(--accent-ink);
+ border-color: var(--accent);
}
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
- padding: 1rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
+.btn-light:hover,
+.btn-light:focus {
+ background: #e3e7eb;
+ border-color: #e3e7eb;
+ color: var(--accent-ink);
}
-.history-table {
- width: 100%;
+.btn-accent {
+ background: #e6eaef;
+ color: #11151a;
+ border: 1px solid #e6eaef;
}
-.history-table-time {
- width: 15%;
- white-space: nowrap;
- font-size: 0.85em;
- color: #555;
+.btn-accent:hover,
+.btn-accent:focus {
+ background: #f2f4f6;
+ border-color: #f2f4f6;
+ color: #11151a;
}
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
+.btn-outline-light {
+ border-color: var(--border-strong);
}
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
+.btn-link {
+ color: #cdd3db;
}
-.no-messages {
- text-align: center;
- color: #777;
-}
\ No newline at end of file
+.btn-link:hover,
+.btn-link:focus {
+ color: #f0f3f6;
+}
+
+.preset-list {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.preset-card,
+.empty-state {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.preset-card.is-active {
+ border-color: #5c6570;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.preset-metrics {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.alert-danger {
+ background: rgba(239, 140, 140, 0.12);
+ color: #ffd9d9;
+}
+
+code {
+ color: #f4f7fa;
+}
+
+.preset-detail-specs > div {
+ padding: 12px 0;
+}
+
+footer {
+ background: rgba(255, 255, 255, 0.01);
+}
+
+@media (max-width: 991.98px) {
+ .display-title {
+ max-width: none;
+ }
+
+ .recliner-stage {
+ min-height: 320px;
+ }
+
+ .recliner-figure {
+ width: 280px;
+ height: 220px;
+ }
+
+ .recliner-seat {
+ left: 76px;
+ width: 122px;
+ }
+
+ .recliner-base {
+ left: 92px;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .recliner-stage {
+ min-height: 280px;
+ }
+
+ .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;
+ }
+
+ .angle-indicator {
+ right: 8px;
+ top: 8px;
+ padding: 10px 12px;
+ }
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..f6781e6 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,310 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
+ const appConfig = window.appConfig || {};
+ const toastElement = document.getElementById('app-toast');
+ const toastMessage = document.getElementById('toast-message');
+ const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 3200 }) : null;
- const appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
+ const controls = {
+ angle: document.getElementById('angle_deg'),
+ intensity: document.getElementById('intensity_pct'),
+ pattern: document.getElementById('pattern_mode'),
+ duration: document.getElementById('duration_ms'),
+ name: document.getElementById('name'),
+ notes: document.getElementById('notes')
};
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
+ const ui = {
+ figure: document.getElementById('recliner-figure'),
+ angleValue: document.getElementById('angle-value'),
+ anglePill: document.getElementById('angle-pill'),
+ intensityPill: document.getElementById('intensity-pill'),
+ durationPill: document.getElementById('duration-pill'),
+ statAngle: document.getElementById('stat-angle'),
+ statIntensity: document.getElementById('stat-intensity'),
+ statPattern: document.getElementById('stat-pattern'),
+ statDuration: document.getElementById('stat-duration'),
+ saveAngle: document.getElementById('save-angle'),
+ saveIntensity: document.getElementById('save-intensity'),
+ savePattern: document.getElementById('save-pattern'),
+ saveDuration: document.getElementById('save-duration'),
+ summaryAngle: document.getElementById('summary-angle'),
+ summaryIntensity: document.getElementById('summary-intensity'),
+ summaryPattern: document.getElementById('summary-pattern'),
+ summaryTone: document.getElementById('summary-tone'),
+ 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')
+ };
- appendMessage(message, 'visitor');
- chatInput.value = '';
+ let knownGamepads = [];
+ let scanTimer = 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 currentState = () => ({
+ angle: Number(controls.angle?.value || 0),
+ intensity: Number(controls.intensity?.value || 0),
+ pattern: controls.pattern?.value || 'continuous',
+ duration: Number(controls.duration?.value || 1000)
+ });
+
+ 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));
+ }
+
+ const map = {
+ angleValue: `${state.angle}`,
+ anglePill: `${state.angle}°`,
+ intensityPill: `${state.intensity}%`,
+ durationPill: `${state.duration} ms`,
+ statAngle: `${state.angle}°`,
+ statIntensity: `${state.intensity}%`,
+ statPattern: capitalize(state.pattern),
+ statDuration: `${state.duration} ms`,
+ saveAngle: `${state.angle}°`,
+ saveIntensity: `${state.intensity}%`,
+ savePattern: capitalize(state.pattern),
+ saveDuration: `${state.duration} ms`,
+ summaryAngle: `${state.angle}°`,
+ summaryIntensity: `${state.intensity}%`,
+ summaryPattern: capitalize(state.pattern),
+ summaryTone: tone,
+ reclineMode: tone
+ };
+
+ Object.entries(map).forEach(([key, value]) => {
+ if (ui[key]) {
+ ui[key].textContent = value;
+ }
+ });
+ };
+
+ const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
+
+ const applyPresetState = (preset) => {
+ if (!preset) return;
+ if (controls.angle && preset.angle_deg !== undefined) controls.angle.value = preset.angle_deg;
+ 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.duration && preset.duration_ms !== undefined) controls.duration.value = preset.duration_ms;
+ 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 refreshGamepads = () => {
+ if (!navigator.getGamepads) {
+ if (ui.gamepadState) {
+ ui.gamepadState.textContent = 'Gamepad API unavailable';
+ }
+ if (ui.gamepadSelect) {
+ ui.gamepadSelect.innerHTML = 'Unsupported browser ';
+ ui.gamepadSelect.disabled = true;
+ }
+ if (ui.testButton) {
+ ui.testButton.disabled = true;
+ }
+ 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;
+ }
+ };
+
+ 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 runVibration = async () => {
+ const gamepad = selectedGamepad();
+ const actuator = getActuator(gamepad);
+ if (!gamepad || !actuator) {
+ notify('No compatible haptic controller is currently selected.');
+ refreshGamepads();
+ return;
+ }
+
+ const { intensity, duration, pattern } = currentState();
+ const normalizedIntensity = Math.max(0, Math.min(1, intensity / 100));
+ ui.testButton.disabled = true;
+ ui.testButton.textContent = 'Testing…';
try {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
- });
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
+ if (pattern === 'pulse') {
+ let remaining = duration;
+ while (remaining > 0) {
+ const burst = Math.min(160, remaining);
+ await playEffect(actuator, normalizedIntensity, burst);
+ remaining -= burst;
+ if (remaining > 0) {
+ await new Promise((resolve) => window.setTimeout(resolve, 120));
+ }
+ }
+ } else {
+ await playEffect(actuator, normalizedIntensity, duration);
+ }
+ notify(`Running ${pattern} vibration at ${intensity}% for ${duration} ms.`);
} catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
+ console.error(error);
+ notify('The browser detected the controller, but vibration could not start.');
+ } finally {
+ ui.testButton.textContent = 'Test vibration';
+ refreshGamepads();
+ }
+ };
+
+ const wirePresetButtons = () => {
+ document.querySelectorAll('.apply-preset').forEach((button) => {
+ button.addEventListener('click', () => {
+ applyPresetState({
+ name: button.dataset.name || '',
+ angle_deg: button.dataset.angle || 0,
+ intensity_pct: button.dataset.intensity || 0,
+ pattern_mode: button.dataset.pattern || 'continuous',
+ duration_ms: button.dataset.duration || 1000,
+ 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);
+ }
+
+ if (ui.resetButton) {
+ ui.resetButton.addEventListener('click', () => {
+ applyPresetState(appConfig.defaults || {});
+ notify('Demo defaults restored.');
+ });
+ }
+
+ [controls.angle, controls.intensity, controls.pattern, controls.duration].forEach((element) => {
+ if (element) {
+ element.addEventListener('input', updateVisualization);
+ element.addEventListener('change', updateVisualization);
+ }
+ });
+
+ 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}`);
+ });
+
+ 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);
}
});
});
diff --git a/healthz.php b/healthz.php
new file mode 100644
index 0000000..790fa84
--- /dev/null
+++ b/healthz.php
@@ -0,0 +1,25 @@
+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);
+}
diff --git a/index.php b/index.php
index 7205f3d..862418a 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,350 @@
['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'],
+ 'duration_ms' => (int) $selectedPreset['duration_ms'],
+ '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.';
+}
?>
-
-
- New Style
-
-
-
-
-
-
-
-
-
+
+
+ = e($pageTitle) ?>
+
+
+
+
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
Analyzing your requirements and generating your website…
-
-
Loading…
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
+
= e($toastMessage ?? '') ?>
+
+
+
+
+
+
+
+
+
+
+
Browser web app · Gamepad vibration demo
+
Simulate a recliner experience with live angle control and haptic testing.
+
Connect a compatible gamepad in Chrome or Edge, tune the recline angle, choose a vibration pattern, and save reusable presets for demos or prototyping.
+
+ 0–160° recline visualizer
+ Continuous + pulse rumble
+ Saved preset library
+
+
+
+
+
+
+
Loaded state
+
= e($selectedPreset['name'] ?? $formData['name']) ?>
+
+
+
+
+
Angle = e((string) $formData['angle_deg']) ?>°
+
Intensity = e((string) $formData['intensity_pct']) ?>%
+
Pattern = e(ucfirst($formData['pattern_mode'])) ?>
+
Tone = e(preset_tone((int) $formData['angle_deg'])) ?>
+
+
Tip: the browser requires a user interaction before triggering vibration. Press Test vibration after connecting the controller.
+
+
+
+
+
+
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
+
+
+
+
+
+
diff --git a/preset.php b/preset.php
new file mode 100644
index 0000000..f472ed2
--- /dev/null
+++ b/preset.php
@@ -0,0 +1,114 @@
+ ['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'];
+?>
+
+
+
+
+
+ = e($pageTitle) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preset detail
+ Preset not found
+ The requested preset does not exist yet or may have been removed.
+ Return to the simulator
+
+
+
+
+
+
Preset detail · #= e((string) $preset['id']) ?>
+
= e($preset['name']) ?>
+
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.
+
+ = e((string) $preset['angle_deg']) ?>° angle
+ = e((string) $preset['intensity_pct']) ?>% intensity
+ = e(ucfirst((string) $preset['pattern_mode'])) ?> rumble
+ = e((string) $preset['duration_ms']) ?> ms
+
+
+
+
+
Profile tone
+
= e(preset_tone((int) $preset['angle_deg'])) ?>
+
Saved on = e(date('F j, Y H:i', strtotime((string) $preset['created_at']))) ?> UTC.
+
+
+
+
+
+
+
+
+ Settings overview
+
+
Recline angle = e((string) $preset['angle_deg']) ?>°
+
Vibration intensity = e((string) $preset['intensity_pct']) ?>%
+
Pattern = e(ucfirst((string) $preset['pattern_mode'])) ?>
+
Duration = e((string) $preset['duration_ms']) ?> ms
+
Saved at = e((string) $preset['created_at']) ?>
+
+
+ Operator notes
+ = e($preset['notes'] ?: 'No notes added for this preset.') ?>
+
+
+
+
+
+
Next action
+
Load this profile into the simulator
+
Jump back into the main workspace with these saved values prefilled. Then press Test vibration to drive the controller.
+
+
+
+
+
+
+
+
+
+