| @@ -5,63 +5,52 @@ | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <title>fm-rds-tx</title> | |||
| <style> | |||
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Archivo+Black&display=swap'); | |||
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap'); | |||
| :root { | |||
| --bg: #0a0a0c; | |||
| --bg-2: #0f1015; | |||
| --surface: #111116; | |||
| --surface2: #18181e; | |||
| --surface3: #1f2028; | |||
| --border: #2a2a35; | |||
| --border-strong: #3a3a49; | |||
| --text: #d4d4dc; | |||
| --text-dim: #8b8b99; | |||
| --text-muted: #666674; | |||
| --accent: #ff3b30; | |||
| --accent-glow: #ff3b3044; | |||
| --green: #30d158; | |||
| --green-glow: #30d15844; | |||
| --amber: #ff9f0a; | |||
| --amber-glow: #ff9f0a44; | |||
| --blue: #0a84ff; | |||
| --blue-glow: #0a84ff33; | |||
| --bg: #f7f8fb; | |||
| --bg-2: #eef0f3; | |||
| --surface: #ffffff; | |||
| --surface2: #f4f6f8; | |||
| --surface3: #e8ecf1; | |||
| --border: #d7dcdf; | |||
| --border-strong: #bcc5ce; | |||
| --text: #111724; | |||
| --text-dim: #4c5a6a; | |||
| --text-muted: #6b7683; | |||
| --accent: #1f4d9d; | |||
| --accent-soft: rgba(31, 77, 157, 0.08); | |||
| --green: #0d944a; | |||
| --green-soft: rgba(13, 148, 74, 0.1); | |||
| --amber: #b7791f; | |||
| --amber-soft: rgba(183, 121, 31, 0.12); | |||
| --red: #b03030; | |||
| --red-soft: rgba(176, 48, 48, 0.1); | |||
| --mono: 'JetBrains Mono', monospace; | |||
| --display: 'Archivo Black', sans-serif; | |||
| --display: 'Inter', sans-serif; | |||
| --radius: 8px; | |||
| --shadow: 0 10px 30px rgba(0,0,0,.25); | |||
| --shadow: 0 8px 24px rgba(15,23,42,0.08); | |||
| } | |||
| * { box-sizing: border-box; margin: 0; padding: 0; } | |||
| html { color-scheme: dark; } | |||
| html { color-scheme: light; } | |||
| body { | |||
| background: | |||
| radial-gradient(circle at top right, rgba(10,132,255,.06), transparent 28%), | |||
| radial-gradient(circle at top left, rgba(255,59,48,.06), transparent 30%), | |||
| var(--bg); | |||
| background: linear-gradient(180deg, #fbfcfe 0%, var(--bg) 100%); | |||
| color: var(--text); | |||
| font-family: var(--mono); | |||
| font-size: 13px; | |||
| font-family: var(--display); | |||
| font-size: 14px; | |||
| line-height: 1.5; | |||
| min-height: 100vh; | |||
| overflow-x: hidden; | |||
| } | |||
| body::before { | |||
| content: ''; | |||
| position: fixed; inset: 0; | |||
| background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.015) 2px, rgba(255,255,255,0.015) 4px); | |||
| pointer-events: none; | |||
| z-index: 1000; | |||
| } | |||
| button, input { font: inherit; } | |||
| button { user-select: none; } | |||
| .app { | |||
| max-width: 1120px; | |||
| max-width: 1200px; | |||
| margin: 0 auto; | |||
| padding: 18px; | |||
| padding: 24px; | |||
| } | |||
| .header { | |||
| @@ -69,9 +58,9 @@ button { user-select: none; } | |||
| align-items: flex-start; | |||
| justify-content: space-between; | |||
| gap: 18px; | |||
| padding: 8px 0 22px; | |||
| padding: 4px 0 20px; | |||
| border-bottom: 1px solid var(--border); | |||
| margin-bottom: 18px; | |||
| margin-bottom: 20px; | |||
| } | |||
| .header-main { | |||
| display: flex; | |||
| @@ -80,11 +69,15 @@ button { user-select: none; } | |||
| } | |||
| .header h1 { | |||
| font-family: var(--display); | |||
| font-size: 24px; | |||
| letter-spacing: 2px; | |||
| text-transform: uppercase; | |||
| color: var(--accent); | |||
| text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow); | |||
| font-size: 28px; | |||
| font-weight: 800; | |||
| letter-spacing: -0.03em; | |||
| text-transform: none; | |||
| color: var(--text); | |||
| } | |||
| .header-note { | |||
| font-size: 13px; | |||
| color: var(--text-muted); | |||
| } | |||
| .header-sub { | |||
| display: flex; | |||
| @@ -99,11 +92,11 @@ button { user-select: none; } | |||
| padding: 0 10px; | |||
| border: 1px solid var(--border); | |||
| border-radius: 999px; | |||
| background: rgba(255,255,255,0.02); | |||
| background: var(--surface2); | |||
| color: var(--text-dim); | |||
| font-size: 11px; | |||
| text-transform: uppercase; | |||
| letter-spacing: .8px; | |||
| letter-spacing: .08em; | |||
| } | |||
| .badge strong { | |||
| color: var(--text); | |||
| @@ -127,50 +120,56 @@ button { user-select: none; } | |||
| } | |||
| .led.on-green { | |||
| background: var(--green); | |||
| box-shadow: 0 0 10px rgba(46, 125, 50, 0.45); | |||
| box-shadow: 0 0 0 3px rgba(13,148,74,0.16); | |||
| } | |||
| .led.on-red { | |||
| background: var(--accent); | |||
| box-shadow: 0 0 10px rgba(180, 35, 24, 0.45); | |||
| background: var(--red); | |||
| box-shadow: 0 0 0 3px rgba(176,48,48,0.14); | |||
| } | |||
| .led.on-amber { | |||
| background: var(--amber); | |||
| box-shadow: 0 0 10px rgba(178, 106, 0, 0.45); | |||
| box-shadow: 0 0 0 3px rgba(183,121,31,0.14); | |||
| } | |||
| .led.on-blue { | |||
| background: var(--blue); | |||
| box-shadow: 0 0 10px rgba(41, 98, 179, 0.45); | |||
| background: var(--accent); | |||
| box-shadow: 0 0 0 3px rgba(31,77,157,0.14); | |||
| } | |||
| .status-text { | |||
| font-size: 10px; | |||
| color: var(--text-dim); | |||
| text-transform: uppercase; | |||
| letter-spacing: 1.2px; | |||
| letter-spacing: .08em; | |||
| } | |||
| .tab-bar { | |||
| display: flex; | |||
| gap: 10px; | |||
| gap: 6px; | |||
| border-bottom: 1px solid var(--border); | |||
| padding-bottom: 10px; | |||
| margin: 0 -24px 18px; | |||
| padding-bottom: 0; | |||
| margin: 0 0 20px; | |||
| flex-wrap: wrap; | |||
| position: sticky; | |||
| top: 0; | |||
| background: rgba(247,248,251,.92); | |||
| backdrop-filter: blur(8px); | |||
| z-index: 10; | |||
| } | |||
| .tab-btn { | |||
| border: 1px solid var(--border); | |||
| border-radius: var(--radius); | |||
| border: none; | |||
| border-bottom: 3px solid transparent; | |||
| border-radius: 0; | |||
| background: transparent; | |||
| color: var(--text-dim); | |||
| font-size: 12px; | |||
| font-weight: 600; | |||
| padding: 8px 18px; | |||
| font-size: 13px; | |||
| font-weight: 700; | |||
| padding: 12px 16px 11px; | |||
| cursor: pointer; | |||
| transition: all .16s ease; | |||
| transition: color .16s ease, border-color .16s ease, background-color .16s ease; | |||
| } | |||
| .tab-btn.active { | |||
| border-color: var(--accent); | |||
| background: var(--surface); | |||
| border-bottom-color: var(--accent); | |||
| background: transparent; | |||
| color: var(--accent); | |||
| } | |||
| .tab-btn:focus-visible { | |||
| @@ -212,10 +211,6 @@ button { user-select: none; } | |||
| .hero { | |||
| padding: 16px; | |||
| } | |||
| @keyframes blinkSoft { | |||
| 0%, 100% { opacity: 1; } | |||
| 50% { opacity: .55; } | |||
| } | |||
| .tx-bar { | |||
| position: relative; | |||
| @@ -234,16 +229,16 @@ button { user-select: none; } | |||
| .freq-display-label { | |||
| font-size: 10px; | |||
| text-transform: uppercase; | |||
| letter-spacing: 1.4px; | |||
| letter-spacing: .08em; | |||
| color: var(--text-dim); | |||
| } | |||
| .freq-display { | |||
| font-family: var(--display); | |||
| font-size: 38px; | |||
| font-size: 40px; | |||
| color: var(--text); | |||
| letter-spacing: 0.6px; | |||
| letter-spacing: -0.04em; | |||
| line-height: 1; | |||
| font-weight: 600; | |||
| font-weight: 800; | |||
| } | |||
| .freq-display .unit { | |||
| font-family: var(--mono); | |||
| @@ -259,7 +254,7 @@ button { user-select: none; } | |||
| font-size: 11px; | |||
| color: var(--text-muted); | |||
| text-transform: uppercase; | |||
| letter-spacing: 1px; | |||
| letter-spacing: .08em; | |||
| } | |||
| .freq-note-item { | |||
| display: inline-flex; | |||
| @@ -287,7 +282,7 @@ button { user-select: none; } | |||
| font-size: 12px; | |||
| font-weight: 700; | |||
| text-transform: uppercase; | |||
| letter-spacing: 1px; | |||
| letter-spacing: .08em; | |||
| transition: all .16s ease; | |||
| } | |||
| .tx-btn:hover, .ghost-btn:hover, .apply-btn:hover, .preset-btn:hover, .danger-btn:hover { | |||
| @@ -299,18 +294,18 @@ button { user-select: none; } | |||
| cursor: not-allowed; | |||
| transform: none; | |||
| } | |||
| .tx-btn.start { border-color: var(--green); color: var(--green); } | |||
| .tx-btn.start:hover:not(:disabled) { background: rgba(48,209,88,.1); } | |||
| .tx-btn.stop { border-color: var(--accent); color: var(--accent); } | |||
| .tx-btn.stop:hover:not(:disabled) { background: rgba(255,59,48,.1); } | |||
| .tx-btn.start { background: var(--green); color: #fff; border-color: transparent; } | |||
| .tx-btn.start:hover:not(:disabled) { background: #0b7f40; } | |||
| .tx-btn.stop { background: #fff; color: var(--red); border-color: rgba(176,48,48,.35); } | |||
| .tx-btn.stop:hover:not(:disabled) { background: var(--red-soft); } | |||
| .ghost-btn { color: var(--text-dim); } | |||
| .danger-btn { | |||
| border-color: rgba(255,59,48,.45); | |||
| color: var(--accent); | |||
| background: rgba(255,59,48,.04); | |||
| border-color: rgba(176,48,48,.35); | |||
| color: var(--red); | |||
| background: var(--red-soft); | |||
| } | |||
| .danger-btn:hover:not(:disabled) { | |||
| background: rgba(255,59,48,.12); | |||
| background: rgba(176,48,48,.16); | |||
| } | |||
| .tx-state-wrap { | |||
| @@ -325,10 +320,10 @@ button { user-select: none; } | |||
| letter-spacing: 2px; | |||
| color: var(--text-dim); | |||
| } | |||
| .tx-state.running { color: var(--green); animation: blinkSoft 2s ease-in-out infinite; } | |||
| .tx-state.running { color: var(--green); } | |||
| .tx-state.idle, .tx-state.stopped { color: var(--text-dim); } | |||
| .tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); animation: blinkSoft 1.1s ease-in-out infinite; } | |||
| .tx-state.error { color: var(--accent); } | |||
| .tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); } | |||
| .tx-state.error { color: var(--red); } | |||
| .status-hint { | |||
| font-size: 10px; | |||
| color: var(--text-muted); | |||
| @@ -347,12 +342,12 @@ button { user-select: none; } | |||
| padding: 12px; | |||
| border: 1px solid var(--border); | |||
| border-radius: var(--radius); | |||
| background: var(--bg-2); | |||
| background: var(--surface2); | |||
| } | |||
| .quick-item .label { | |||
| font-size: 9px; | |||
| text-transform: uppercase; | |||
| letter-spacing: 1.4px; | |||
| letter-spacing: .08em; | |||
| color: var(--text-dim); | |||
| margin-bottom: 6px; | |||
| } | |||
| @@ -377,7 +372,7 @@ button { user-select: none; } | |||
| padding: 12px; | |||
| border: 1px solid var(--border); | |||
| border-radius: var(--radius); | |||
| background: var(--bg-2); | |||
| background: var(--surface2); | |||
| } | |||
| .signal-head { | |||
| display: flex; | |||
| @@ -390,7 +385,7 @@ button { user-select: none; } | |||
| font-size: 10px; | |||
| color: var(--text-dim); | |||
| text-transform: uppercase; | |||
| letter-spacing: 1.2px; | |||
| letter-spacing: .08em; | |||
| } | |||
| .signal-value { | |||
| font-size: 11px; | |||
| @@ -401,7 +396,7 @@ button { user-select: none; } | |||
| width: 100%; | |||
| height: 10px; | |||
| border-radius: 999px; | |||
| background: #171821; | |||
| background: #e7ebf0; | |||
| border: 1px solid var(--border); | |||
| overflow: hidden; | |||
| } | |||
| @@ -409,21 +404,21 @@ button { user-select: none; } | |||
| height: 100%; | |||
| width: 0%; | |||
| transition: width .25s ease, background-color .25s ease; | |||
| background: linear-gradient(90deg, var(--green), #5cff90); | |||
| background: linear-gradient(90deg, var(--green), #3dbd75); | |||
| } | |||
| .meter-fill.warn { | |||
| background: linear-gradient(90deg, var(--amber), #ffc45b); | |||
| background: linear-gradient(90deg, var(--amber), #d9a14a); | |||
| } | |||
| .meter-fill.err { | |||
| background: linear-gradient(90deg, var(--accent), #ff6b63); | |||
| background: linear-gradient(90deg, var(--red), #d85c5c); | |||
| } | |||
| .spark { | |||
| width: 100%; | |||
| height: 34px; | |||
| margin-top: 10px; | |||
| border-radius: 6px; | |||
| background: rgba(255,255,255,0.01); | |||
| border: 1px solid rgba(255,255,255,0.03); | |||
| background: #f7f9fb; | |||
| border: 1px solid #e3e8ee; | |||
| } | |||
| .spark path.line { | |||
| fill: none; | |||
| @@ -494,7 +489,7 @@ button { user-select: none; } | |||
| justify-content: space-between; | |||
| gap: 12px; | |||
| padding: 7px 0; | |||
| border-bottom: 1px solid #1a1a22; | |||
| border-bottom: 1px solid #e6eaef; | |||
| } | |||
| .shortcut-line:last-child { border-bottom: none; } | |||
| .shortcut-line .name { font-size: 11px; color: var(--text-dim); } | |||
| @@ -509,7 +504,7 @@ button { user-select: none; } | |||
| border: 1px solid var(--border); | |||
| border-bottom-width: 2px; | |||
| border-radius: 6px; | |||
| background: var(--bg-2); | |||
| background: var(--surface2); | |||
| font-size: 10px; | |||
| color: var(--text); | |||
| text-align: center; | |||
| @@ -525,13 +520,13 @@ button { user-select: none; } | |||
| min-height: 34px; | |||
| padding: 0 12px; | |||
| font-size: 11px; | |||
| letter-spacing: .8px; | |||
| letter-spacing: .08em; | |||
| color: var(--text-dim); | |||
| } | |||
| .preset-btn.active { | |||
| border-color: var(--blue); | |||
| color: var(--blue); | |||
| background: rgba(10,132,255,.08); | |||
| border-color: var(--accent); | |||
| color: var(--accent); | |||
| background: var(--accent-soft); | |||
| } | |||
| .preset-btn.rds { | |||
| text-transform: none; | |||
| @@ -543,7 +538,7 @@ button { user-select: none; } | |||
| align-items: center; | |||
| gap: 12px; | |||
| padding: 10px 0; | |||
| border-bottom: 1px solid #1a1a22; | |||
| border-bottom: 1px solid #e6eaef; | |||
| } | |||
| .ctrl-row:last-child { border-bottom: none; } | |||
| .ctrl-label-wrap { | |||
| @@ -593,7 +588,7 @@ input[type="range"]::-webkit-slider-thumb:hover { | |||
| transform: scale(1.06); | |||
| } | |||
| input[type="number"], input[type="text"] { | |||
| background: var(--bg); | |||
| background: #fff; | |||
| border: 1px solid var(--border); | |||
| border-radius: 6px; | |||
| color: var(--text); | |||
| @@ -608,16 +603,16 @@ input[type="number"] { | |||
| input[type="text"] { width: 100%; } | |||
| input:focus { | |||
| border-color: var(--accent); | |||
| box-shadow: 0 0 0 3px rgba(255,59,48,.12); | |||
| box-shadow: 0 0 0 3px rgba(31,77,157,.12); | |||
| } | |||
| input.input-dirty { | |||
| border-color: var(--amber); | |||
| box-shadow: 0 0 0 3px rgba(255,159,10,.08); | |||
| box-shadow: 0 0 0 3px rgba(183,121,31,.08); | |||
| } | |||
| input.input-error { | |||
| border-color: var(--accent); | |||
| box-shadow: 0 0 0 3px rgba(255,59,48,.14); | |||
| background: rgba(255,59,48,.04); | |||
| border-color: var(--red); | |||
| box-shadow: 0 0 0 3px rgba(176,48,48,.14); | |||
| background: rgba(176,48,48,.04); | |||
| } | |||
| .val-display { | |||
| min-width: 64px; | |||
| @@ -711,10 +706,10 @@ input.input-error { | |||
| } | |||
| .rds-input { | |||
| width: 100%; | |||
| background: var(--bg); | |||
| background: #fff; | |||
| border: 1px solid var(--border); | |||
| border-radius: 6px; | |||
| color: var(--green); | |||
| color: var(--accent); | |||
| font-family: var(--mono); | |||
| font-size: 15px; | |||
| font-weight: 700; | |||
| @@ -765,7 +760,7 @@ input.input-error { | |||
| .sidebar-title { | |||
| font-size: 11px; | |||
| text-transform: uppercase; | |||
| letter-spacing: 1.4px; | |||
| letter-spacing: .08em; | |||
| color: var(--text-dim); | |||
| margin-bottom: 10px; | |||
| } | |||
| @@ -779,7 +774,7 @@ input.input-error { | |||
| font-size: 10px; | |||
| color: var(--text-muted); | |||
| text-transform: uppercase; | |||
| letter-spacing: 1px; | |||
| letter-spacing: .08em; | |||
| } | |||
| .kv .v { | |||
| font-size: 12px; | |||
| @@ -813,7 +808,7 @@ input.input-error { | |||
| .health-trend-label { | |||
| font-size: 10px; | |||
| text-transform: uppercase; | |||
| letter-spacing: 1px; | |||
| letter-spacing: .08em; | |||
| color: var(--text-muted); | |||
| margin-bottom: 6px; | |||
| } | |||
| @@ -823,7 +818,7 @@ input.input-error { | |||
| padding: 10px; | |||
| border: 1px solid var(--border); | |||
| border-radius: 6px; | |||
| background: var(--surface1); | |||
| background: #fbfcfd; | |||
| font-size: 11px; | |||
| max-height: 180px; | |||
| overflow-y: auto; | |||
| @@ -902,11 +897,11 @@ input.input-error { | |||
| } | |||
| .log { | |||
| background: var(--bg); | |||
| background: #fbfcfd; | |||
| border: 1px solid var(--border); | |||
| border-radius: 6px; | |||
| padding: 10px; | |||
| font-size: 10px; | |||
| font-size: 11px; | |||
| color: var(--text-dim); | |||
| max-height: 220px; | |||
| overflow-y: auto; | |||
| @@ -995,7 +990,8 @@ input.input-error { | |||
| <div class="app"> | |||
| <div class="header"> | |||
| <div class="header-main"> | |||
| <h1>FM-RDS-TX</h1> | |||
| <h1>FM-RDS-TX Control Plane</h1> | |||
| <div class="header-note">Overview first, controls second, diagnostics when needed.</div> | |||
| <div class="header-sub"> | |||
| <div class="badge"><span>Backend</span><strong id="badge-backend">--</strong></div> | |||
| <div class="badge"><span>Mode</span><strong id="badge-mode">Control Plane</strong></div> | |||
| @@ -1011,9 +1007,9 @@ input.input-error { | |||
| <div class="tab-bar"> | |||
| <button class="tab-btn active" data-tab="overview" type="button">Overview</button> | |||
| <button class="tab-btn" data-tab="control" type="button">Control</button> | |||
| <button class="tab-btn" data-tab="diagnostics" type="button">Diagnostics</button> | |||
| <button class="tab-btn" data-tab="activity" type="button">Activity</button> | |||
| <button class="tab-btn" data-tab="control" type="button">Transmission Control</button> | |||
| <button class="tab-btn" data-tab="diagnostics" type="button">Diagnostics & Health</button> | |||
| <button class="tab-btn" data-tab="activity" type="button">Activity & Logs</button> | |||
| </div> | |||
| <div class="tab-panels"> | |||
| @@ -1099,16 +1095,24 @@ input.input-error { | |||
| <div class="stack"> | |||
| <div class="card sidebar-card"> | |||
| <div class="sidebar-section"> | |||
| <div class="sidebar-title">System Snapshot</div> | |||
| <div class="sidebar-title">Runtime Snapshot</div> | |||
| <div class="kv"> | |||
| <div class="k">Backend</div><div class="v" id="info-backend">--</div> | |||
| <div class="k">Frequency</div><div class="v" id="info-freq">--</div> | |||
| <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div> | |||
| <div class="k">FM Mod</div><div class="v" id="info-fmmod">--</div> | |||
| <div class="k">Runtime Age</div><div class="v" id="info-runtime-age">--</div> | |||
| <div class="k">Last Alert</div><div class="v" id="info-last-alert">--</div> | |||
| <div class="k">Live Config</div><div class="v" id="info-live">--</div> | |||
| </div> | |||
| </div> | |||
| <div class="sidebar-section"> | |||
| <div class="sidebar-title">Signal Notes</div> | |||
| <div class="section-note">Overview stays compact: primary state here, deep diagnostics in the dedicated tab.</div> | |||
| <div class="kv"> | |||
| <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div> | |||
| <div class="k">FM Mod</div><div class="v" id="info-fmmod">--</div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -1249,7 +1253,7 @@ input.input-error { | |||
| <span class="chevron">▼</span> | |||
| </div> | |||
| <div class="panel-body"> | |||
| <div class="section-note">Fast control, as long as you're not typing in an input field.</div> | |||
| <div class="section-note">Fast control reference. Shortcuts stay out of the main operator path.</div> | |||
| <div class="shortcuts-grid"> | |||
| <div> | |||
| <div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div> | |||
| @@ -2097,6 +2101,8 @@ function render() { | |||
| updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz)); | |||
| updateText('info-preemph', cfg.fm?.preEmphasisTauUS ? `${cfg.fm.preEmphasisTauUS} µs` : 'Off'); | |||
| updateText('info-fmmod', fmtBool(cfg.fm?.fmModulationEnabled)); | |||
| updateText('info-runtime-age', ageString(state.server.lastRuntimeAt)); | |||
| updateText('info-last-alert', engine.runtimeAlert ? engine.runtimeAlert : (engine.lastError ? engine.lastError : 'None')); | |||
| updateText('info-live', engine.state ? `${String(engine.state).toUpperCase()} / ${state.server.runtimeOk ? 'runtime ok' : 'runtime pending'}` : (state.server.configOk ? 'config only' : '--')); | |||
| updateHealth(engine, driver, audioStream); | |||
| @@ -2486,6 +2492,16 @@ function log(message, type = '') { | |||
| logEl.scrollTop = logEl.scrollHeight; | |||
| } | |||
| function bindTabs() { | |||
| const buttons = Array.from(document.querySelectorAll('.tab-btn[data-tab]')); | |||
| const panels = Array.from(document.querySelectorAll('.tab-panel[data-tab-panel]')); | |||
| const activate = (tab) => { | |||
| buttons.forEach((btn) => btn.classList.toggle('active', btn.dataset.tab === tab)); | |||
| panels.forEach((panel) => panel.classList.toggle('active', panel.dataset.tabPanel === tab)); | |||
| }; | |||
| buttons.forEach((btn) => btn.addEventListener('click', () => activate(btn.dataset.tab))); | |||
| } | |||
| function bindPanels() { | |||
| document.querySelectorAll('[data-panel]').forEach((head) => { | |||
| head.addEventListener('click', () => { | |||
| @@ -2645,6 +2661,7 @@ function bindKeyboardShortcuts() { | |||
| } | |||
| async function init() { | |||
| bindTabs(); | |||
| bindPanels(); | |||
| bindInputs(); | |||
| bindResponsiveBehavior(); | |||