Karl ELHAJAL commited on
Commit
839b185
·
1 Parent(s): 4eb183c

Add CPU/GPU runtime toggle, inference timing logs, and UI redesign

Browse files
Files changed (5) hide show
  1. app.py +24 -6
  2. keyboard.html +101 -88
  3. midi_model.py +25 -0
  4. static/keyboard.js +72 -8
  5. static/styles.css +328 -308
app.py CHANGED
@@ -128,8 +128,17 @@ def save_midi_event_bridge(payload_text: str) -> str:
128
  return json.dumps(result)
129
 
130
 
 
 
 
 
 
 
 
 
 
131
  @spaces.GPU(duration=120)
132
- def process_engine_event_bridge(
133
  payload_text: str,
134
  request: "gr.Request | None" = None,
135
  ) -> str:
@@ -222,12 +231,21 @@ with gr.Blocks(title="Virtual MIDI Keyboard", css=css_content + hidden_bridge_cs
222
  )
223
 
224
  engine_input = gr.Textbox(value="{}", elem_id="vk_engine_input", show_label=False)
225
- engine_output = gr.Textbox(elem_id="vk_engine_output", show_label=False)
226
- engine_btn = gr.Button("process_engine", elem_id="vk_engine_btn")
227
- engine_btn.click(
228
- fn=process_engine_event_bridge,
 
 
 
 
 
 
 
 
 
229
  inputs=engine_input,
230
- outputs=engine_output,
231
  )
232
 
233
 
 
128
  return json.dumps(result)
129
 
130
 
131
+ def process_engine_event_bridge_cpu(
132
+ payload_text: str,
133
+ request: "gr.Request | None" = None,
134
+ ) -> str:
135
+ payload = _parse_json_payload(payload_text, {})
136
+ result = process_engine_payload(payload, request=request, device="cpu")
137
+ return json.dumps(result)
138
+
139
+
140
  @spaces.GPU(duration=120)
141
+ def process_engine_event_bridge_gpu(
142
  payload_text: str,
143
  request: "gr.Request | None" = None,
144
  ) -> str:
 
231
  )
232
 
233
  engine_input = gr.Textbox(value="{}", elem_id="vk_engine_input", show_label=False)
234
+
235
+ engine_cpu_output = gr.Textbox(elem_id="vk_engine_cpu_output", show_label=False)
236
+ engine_cpu_btn = gr.Button("process_engine_cpu", elem_id="vk_engine_cpu_btn")
237
+ engine_cpu_btn.click(
238
+ fn=process_engine_event_bridge_cpu,
239
+ inputs=engine_input,
240
+ outputs=engine_cpu_output,
241
+ )
242
+
243
+ engine_gpu_output = gr.Textbox(elem_id="vk_engine_gpu_output", show_label=False)
244
+ engine_gpu_btn = gr.Button("process_engine_gpu", elem_id="vk_engine_gpu_btn")
245
+ engine_gpu_btn.click(
246
+ fn=process_engine_event_bridge_gpu,
247
  inputs=engine_input,
248
+ outputs=engine_gpu_output,
249
  )
250
 
251
 
keyboard.html CHANGED
@@ -7,102 +7,115 @@
7
  <link rel="stylesheet" href="/file=static/styles.css" />
8
  </head>
9
  <body>
10
- <!-- Welcome Header -->
11
- <div class="welcome-header">
12
- <h1 class="neon-text">SYNTH<i>IA</i></h1>
13
- </div>
14
-
15
- <div id="mainContainer">
16
- <!-- Keyboard Section -->
17
- <div class="keyboard-section">
18
- <div id="keyboard"></div>
 
19
 
20
- <div class="controls">
21
- <label>
22
- Instrument:
23
- <select id="instrumentSelect">
24
- <option value="synth">Synth</option>
25
- <option value="piano">Piano</option>
26
- <option value="organ">Organ</option>
27
- <option value="bass">Bass</option>
28
- <option value="pluck">Pluck</option>
29
- <option value="fm">FM Synth</option>
30
- </select>
31
- </label>
 
32
 
33
- <label>
34
- AI Voice:
35
- <select id="aiInstrumentSelect">
36
- <option value="synth">Synth</option>
37
- <option value="piano">Piano</option>
38
- <option value="organ">Organ</option>
39
- <option value="bass">Bass</option>
40
- <option value="pluck">Pluck</option>
41
- <option value="fm" selected>FM Synth</option>
42
- </select>
43
- </label>
44
-
45
- <label>
46
- Engine:
47
- <select id="engineSelect">
48
- <option value="parrot">Parrot</option>
49
- <option value="reverse_parrot">Reverse Parrot</option>
50
- <option value="godzilla_continue">Godzilla</option>
51
- </select>
52
- </label>
53
 
54
- <label>
55
- AI Style:
56
- <select id="responseStyleSelect">
57
- <option value="melodic">Melodic</option>
58
- <option value="motif_echo">Motif Echo</option>
59
- <option value="playful">Playful</option>
60
- </select>
61
- </label>
62
 
63
- <label>
64
- Response Mode:
65
- <select id="responseModeSelect">
66
- <option value="raw_godzilla">Raw Godzilla</option>
67
- <option value="current_pipeline">Current Pipeline</option>
68
- <option value="musical_polish" selected>Musical Polish</option>
69
- </select>
70
- </label>
71
 
72
- <label>
73
- Response Length:
74
- <select id="responseLengthSelect">
75
- <option value="short" selected>Short</option>
76
- <option value="medium">Medium</option>
77
- <option value="long">Long</option>
78
- <option value="extended">Extended</option>
79
- </select>
80
- </label>
81
-
82
- <button id="recordBtn">Record</button>
83
- <button id="stopBtn" disabled>Stop</button>
84
- <button id="playbackBtn" disabled>Playback</button>
85
- <button id="gameStartBtn">Start Game</button>
86
- <button id="gameStopBtn" disabled>Stop Game</button>
87
- <button id="saveBtn" disabled>Save MIDI</button>
88
- <button id="panicBtn" style="background: #ff4444; color: white;">Panic 🚨</button>
89
-
90
- <label>
91
- <input type="checkbox" id="keyboardToggle">
92
- Enable Keyboard Input
93
- </label>
94
-
95
- <span id="status">Idle</span>
 
 
 
 
 
 
 
 
 
 
 
 
96
  </div>
97
- </div>
98
 
99
- <!-- MIDI Monitor Section -->
100
- <div class="monitor-section">
101
- <div class="terminal-header">
102
- <h4>MIDI MONITOR</h4>
103
- <button id="clearTerminal">Clear</button>
 
104
  </div>
105
- <div id="terminal"></div>
106
  </div>
107
  </div>
108
 
 
7
  <link rel="stylesheet" href="/file=static/styles.css" />
8
  </head>
9
  <body>
10
+ <div class="app-shell">
11
+ <div class="welcome-header">
12
+ <p class="eyebrow">Interactive AI Improvisation</p>
13
+ <h1 class="neon-text">SYNTH<i>IA</i></h1>
14
+ <p class="subtitle">Play a phrase. Let the model answer. Keep the conversation going.</p>
15
+ </div>
16
+
17
+ <div id="mainContainer">
18
+ <div class="keyboard-section card">
19
+ <div id="keyboard"></div>
20
 
21
+ <div class="controls card">
22
+ <div class="control-grid">
23
+ <label class="control-item">
24
+ Instrument
25
+ <select id="instrumentSelect">
26
+ <option value="synth">Synth</option>
27
+ <option value="piano">Piano</option>
28
+ <option value="organ">Organ</option>
29
+ <option value="bass">Bass</option>
30
+ <option value="pluck">Pluck</option>
31
+ <option value="fm">FM Synth</option>
32
+ </select>
33
+ </label>
34
 
35
+ <label class="control-item">
36
+ AI Voice
37
+ <select id="aiInstrumentSelect">
38
+ <option value="synth">Synth</option>
39
+ <option value="piano">Piano</option>
40
+ <option value="organ">Organ</option>
41
+ <option value="bass">Bass</option>
42
+ <option value="pluck">Pluck</option>
43
+ <option value="fm" selected>FM Synth</option>
44
+ </select>
45
+ </label>
46
+
47
+ <label class="control-item">
48
+ Engine
49
+ <select id="engineSelect">
50
+ <option value="parrot">Parrot</option>
51
+ <option value="reverse_parrot">Reverse Parrot</option>
52
+ <option value="godzilla_continue">Godzilla</option>
53
+ </select>
54
+ </label>
55
 
56
+ <label class="control-item">
57
+ Runtime
58
+ <select id="runtimeSelect">
59
+ <option value="cpu" selected>CPU</option>
60
+ <option value="gpu">ZeroGPU</option>
61
+ <option value="auto">Auto (GPU then CPU)</option>
62
+ </select>
63
+ </label>
64
 
65
+ <label class="control-item">
66
+ AI Style
67
+ <select id="responseStyleSelect">
68
+ <option value="melodic">Melodic</option>
69
+ <option value="motif_echo">Motif Echo</option>
70
+ <option value="playful">Playful</option>
71
+ </select>
72
+ </label>
73
 
74
+ <label class="control-item">
75
+ Response Mode
76
+ <select id="responseModeSelect">
77
+ <option value="raw_godzilla">Raw Godzilla</option>
78
+ <option value="current_pipeline">Current Pipeline</option>
79
+ <option value="musical_polish" selected>Musical Polish</option>
80
+ </select>
81
+ </label>
82
+
83
+ <label class="control-item">
84
+ Response Length
85
+ <select id="responseLengthSelect">
86
+ <option value="short" selected>Short</option>
87
+ <option value="medium">Medium</option>
88
+ <option value="long">Long</option>
89
+ <option value="extended">Extended</option>
90
+ </select>
91
+ </label>
92
+
93
+ <label class="control-item control-item-toggle">
94
+ <input type="checkbox" id="keyboardToggle">
95
+ <span>Enable Keyboard Input</span>
96
+ </label>
97
+ </div>
98
+
99
+ <div class="action-row">
100
+ <button id="recordBtn" class="btn btn-primary">Record</button>
101
+ <button id="stopBtn" class="btn btn-secondary" disabled>Stop</button>
102
+ <button id="playbackBtn" class="btn btn-secondary" disabled>Playback</button>
103
+ <button id="gameStartBtn" class="btn btn-game">Start Game</button>
104
+ <button id="gameStopBtn" class="btn btn-secondary" disabled>Stop Game</button>
105
+ <button id="saveBtn" class="btn btn-secondary" disabled>Save MIDI</button>
106
+ <button id="panicBtn" class="btn btn-danger">Panic</button>
107
+ <span id="status">Idle</span>
108
+ </div>
109
+ </div>
110
  </div>
 
111
 
112
+ <div class="monitor-section card">
113
+ <div class="terminal-header">
114
+ <h4>MIDI MONITOR</h4>
115
+ <button id="clearTerminal" class="btn btn-secondary">Clear</button>
116
+ </div>
117
+ <div id="terminal"></div>
118
  </div>
 
119
  </div>
120
  </div>
121
 
midi_model.py CHANGED
@@ -5,6 +5,7 @@ import subprocess
5
  import sys
6
  import inspect
7
  import re
 
8
  from dataclasses import dataclass
9
  from pathlib import Path
10
  from typing import Any, Iterable, Optional
@@ -805,6 +806,8 @@ def generate_from_events(
805
  best_score = -1e9
806
 
807
  candidate_count = max(1, min(6, int(num_candidates)))
 
 
808
  for idx in range(candidate_count):
809
  if seed is not None:
810
  sample_seed = int(seed) + idx
@@ -812,6 +815,7 @@ def generate_from_events(
812
  if resolved_device == "cuda":
813
  torch.cuda.manual_seed_all(sample_seed)
814
 
 
815
  tokens = generate_tokens_sample(
816
  model,
817
  prime_tensor,
@@ -820,6 +824,9 @@ def generate_from_events(
820
  top_p=top_p,
821
  eos_token=eos_token,
822
  )
 
 
 
823
  new_tokens = tokens[len(prime) :]
824
 
825
  candidate_events = tokens_to_events(
@@ -835,6 +842,24 @@ def generate_from_events(
835
  best_events = candidate_events
836
  best_tokens = new_tokens
837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  return best_events, best_tokens
839
 
840
 
 
5
  import sys
6
  import inspect
7
  import re
8
+ import time
9
  from dataclasses import dataclass
10
  from pathlib import Path
11
  from typing import Any, Iterable, Optional
 
806
  best_score = -1e9
807
 
808
  candidate_count = max(1, min(6, int(num_candidates)))
809
+ generation_started = time.perf_counter()
810
+ candidate_timings_ms: list[float] = []
811
  for idx in range(candidate_count):
812
  if seed is not None:
813
  sample_seed = int(seed) + idx
 
815
  if resolved_device == "cuda":
816
  torch.cuda.manual_seed_all(sample_seed)
817
 
818
+ candidate_started = time.perf_counter()
819
  tokens = generate_tokens_sample(
820
  model,
821
  prime_tensor,
 
824
  top_p=top_p,
825
  eos_token=eos_token,
826
  )
827
+ if resolved_device == "cuda":
828
+ torch.cuda.synchronize()
829
+ candidate_timings_ms.append((time.perf_counter() - candidate_started) * 1000.0)
830
  new_tokens = tokens[len(prime) :]
831
 
832
  candidate_events = tokens_to_events(
 
842
  best_events = candidate_events
843
  best_tokens = new_tokens
844
 
845
+ generation_total_ms = (time.perf_counter() - generation_started) * 1000.0
846
+ avg_candidate_ms = (
847
+ sum(candidate_timings_ms) / len(candidate_timings_ms)
848
+ if candidate_timings_ms
849
+ else 0.0
850
+ )
851
+ print(
852
+ "Godzilla generation timing (model load excluded):",
853
+ {
854
+ "device": resolved_device,
855
+ "generate_tokens": generate_tokens,
856
+ "candidates": candidate_count,
857
+ "candidate_ms": [round(ms, 2) for ms in candidate_timings_ms],
858
+ "avg_candidate_ms": round(avg_candidate_ms, 2),
859
+ "total_generation_ms": round(generation_total_ms, 2),
860
+ },
861
+ )
862
+
863
  return best_events, best_tokens
864
 
865
 
static/keyboard.js CHANGED
@@ -78,6 +78,7 @@ let keyboardToggle = null;
78
  let instrumentSelect = null;
79
  let aiInstrumentSelect = null;
80
  let engineSelect = null;
 
81
  let responseStyleSelect = null;
82
  let responseModeSelect = null;
83
  let responseLengthSelect = null;
@@ -386,8 +387,10 @@ async function waitForBridgeElements(timeoutMs = 20000) {
386
  { kind: 'field', id: 'vk_save_output' },
387
  { kind: 'button', id: 'vk_save_btn' },
388
  { kind: 'field', id: 'vk_engine_input' },
389
- { kind: 'field', id: 'vk_engine_output' },
390
- { kind: 'button', id: 'vk_engine_btn' }
 
 
391
  ];
392
 
393
  const started = Date.now();
@@ -417,6 +420,7 @@ function cacheUIElements() {
417
  instrumentSelect = document.getElementById('instrumentSelect');
418
  aiInstrumentSelect = document.getElementById('aiInstrumentSelect');
419
  engineSelect = document.getElementById('engineSelect');
 
420
  responseStyleSelect = document.getElementById('responseStyleSelect');
421
  responseModeSelect = document.getElementById('responseModeSelect');
422
  responseLengthSelect = document.getElementById('responseLengthSelect');
@@ -438,6 +442,7 @@ async function waitForKeyboardUIElements(timeoutMs = 20000) {
438
  'keyboardToggle',
439
  'instrumentSelect',
440
  'engineSelect',
 
441
  'terminal',
442
  'clearTerminal'
443
  ];
@@ -462,10 +467,15 @@ const BRIDGE_ACTIONS = {
462
  outputId: 'vk_save_output',
463
  buttonId: 'vk_save_btn'
464
  },
465
- process_engine: {
466
  inputId: 'vk_engine_input',
467
- outputId: 'vk_engine_output',
468
- buttonId: 'vk_engine_btn'
 
 
 
 
 
469
  }
470
  };
471
 
@@ -706,6 +716,11 @@ function getSelectedDecodingOptions() {
706
  return getDecodingOptionsForMode(mode.modeId);
707
  }
708
 
 
 
 
 
 
709
  function quantizeToStep(value, step) {
710
  if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) {
711
  return value;
@@ -1121,6 +1136,7 @@ async function processEventsThroughEngine(inputEvents, options = {}) {
1121
  }
1122
 
1123
  const requestOptions = { ...options };
 
1124
  if (
1125
  selectedEngineId === 'godzilla_continue'
1126
  && typeof requestOptions.generate_tokens !== 'number'
@@ -1128,11 +1144,45 @@ async function processEventsThroughEngine(inputEvents, options = {}) {
1128
  requestOptions.generate_tokens = RESPONSE_LENGTH_PRESETS.medium.generateTokens;
1129
  }
1130
 
1131
- const result = await callGradioBridge('process_engine', {
 
 
 
 
 
 
 
1132
  engine_id: selectedEngineId,
1133
  events: inputEvents,
1134
  options: requestOptions
1135
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
 
1137
  if (result && result.error) {
1138
  throw new Error(result.error);
@@ -1436,6 +1486,14 @@ function bindUIEventListeners() {
1436
  });
1437
  }
1438
 
 
 
 
 
 
 
 
 
1439
  if (responseStyleSelect) {
1440
  responseStyleSelect.addEventListener('change', () => {
1441
  const preset = getSelectedStylePreset();
@@ -1591,6 +1649,9 @@ async function init() {
1591
  if (responseLengthSelect && !responseLengthSelect.value) {
1592
  responseLengthSelect.value = 'short';
1593
  }
 
 
 
1594
  if (aiInstrumentSelect && !aiInstrumentSelect.value) {
1595
  aiInstrumentSelect.value = 'fm';
1596
  }
@@ -1601,7 +1662,10 @@ async function init() {
1601
 
1602
  // Setup keyboard event listeners and UI
1603
  attachPointerEvents();
1604
- initTerminal();
 
 
 
1605
  // Set initial button states
1606
  recordBtn.disabled = false;
1607
  stopBtn.disabled = true;
 
78
  let instrumentSelect = null;
79
  let aiInstrumentSelect = null;
80
  let engineSelect = null;
81
+ let runtimeSelect = null;
82
  let responseStyleSelect = null;
83
  let responseModeSelect = null;
84
  let responseLengthSelect = null;
 
387
  { kind: 'field', id: 'vk_save_output' },
388
  { kind: 'button', id: 'vk_save_btn' },
389
  { kind: 'field', id: 'vk_engine_input' },
390
+ { kind: 'field', id: 'vk_engine_cpu_output' },
391
+ { kind: 'button', id: 'vk_engine_cpu_btn' },
392
+ { kind: 'field', id: 'vk_engine_gpu_output' },
393
+ { kind: 'button', id: 'vk_engine_gpu_btn' }
394
  ];
395
 
396
  const started = Date.now();
 
420
  instrumentSelect = document.getElementById('instrumentSelect');
421
  aiInstrumentSelect = document.getElementById('aiInstrumentSelect');
422
  engineSelect = document.getElementById('engineSelect');
423
+ runtimeSelect = document.getElementById('runtimeSelect');
424
  responseStyleSelect = document.getElementById('responseStyleSelect');
425
  responseModeSelect = document.getElementById('responseModeSelect');
426
  responseLengthSelect = document.getElementById('responseLengthSelect');
 
442
  'keyboardToggle',
443
  'instrumentSelect',
444
  'engineSelect',
445
+ 'runtimeSelect',
446
  'terminal',
447
  'clearTerminal'
448
  ];
 
467
  outputId: 'vk_save_output',
468
  buttonId: 'vk_save_btn'
469
  },
470
+ process_engine_cpu: {
471
  inputId: 'vk_engine_input',
472
+ outputId: 'vk_engine_cpu_output',
473
+ buttonId: 'vk_engine_cpu_btn'
474
+ },
475
+ process_engine_gpu: {
476
+ inputId: 'vk_engine_input',
477
+ outputId: 'vk_engine_gpu_output',
478
+ buttonId: 'vk_engine_gpu_btn'
479
  }
480
  };
481
 
 
716
  return getDecodingOptionsForMode(mode.modeId);
717
  }
718
 
719
+ function getSelectedRuntime() {
720
+ if (!runtimeSelect || !runtimeSelect.value) return 'cpu';
721
+ return runtimeSelect.value;
722
+ }
723
+
724
  function quantizeToStep(value, step) {
725
  if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) {
726
  return value;
 
1136
  }
1137
 
1138
  const requestOptions = { ...options };
1139
+ const runtimeMode = getSelectedRuntime();
1140
  if (
1141
  selectedEngineId === 'godzilla_continue'
1142
  && typeof requestOptions.generate_tokens !== 'number'
 
1144
  requestOptions.generate_tokens = RESPONSE_LENGTH_PRESETS.medium.generateTokens;
1145
  }
1146
 
1147
+ let bridgeAction = 'process_engine_cpu';
1148
+ if (selectedEngineId === 'godzilla_continue') {
1149
+ if (runtimeMode === 'gpu' || runtimeMode === 'auto') {
1150
+ bridgeAction = 'process_engine_gpu';
1151
+ }
1152
+ }
1153
+
1154
+ const requestPayload = {
1155
  engine_id: selectedEngineId,
1156
  events: inputEvents,
1157
  options: requestOptions
1158
+ };
1159
+
1160
+ let result;
1161
+ try {
1162
+ result = await callGradioBridge(bridgeAction, requestPayload);
1163
+ } catch (err) {
1164
+ if (
1165
+ selectedEngineId === 'godzilla_continue'
1166
+ && runtimeMode === 'auto'
1167
+ && bridgeAction === 'process_engine_gpu'
1168
+ ) {
1169
+ logToTerminal('Runtime auto: ZeroGPU failed, retrying on CPU.', 'timestamp');
1170
+ result = await callGradioBridge('process_engine_cpu', requestPayload);
1171
+ } else {
1172
+ throw err;
1173
+ }
1174
+ }
1175
+
1176
+ if (
1177
+ result
1178
+ && result.error
1179
+ && selectedEngineId === 'godzilla_continue'
1180
+ && runtimeMode === 'auto'
1181
+ && bridgeAction === 'process_engine_gpu'
1182
+ ) {
1183
+ logToTerminal(`Runtime auto: ZeroGPU error (${result.error}), retrying on CPU.`, 'timestamp');
1184
+ result = await callGradioBridge('process_engine_cpu', requestPayload);
1185
+ }
1186
 
1187
  if (result && result.error) {
1188
  throw new Error(result.error);
 
1486
  });
1487
  }
1488
 
1489
+ if (runtimeSelect) {
1490
+ runtimeSelect.addEventListener('change', () => {
1491
+ const mode = getSelectedRuntime();
1492
+ const runtimeLabel = mode === 'gpu' ? 'ZeroGPU' : (mode === 'auto' ? 'Auto (GPU->CPU)' : 'CPU');
1493
+ logToTerminal(`Runtime switched to: ${runtimeLabel}`, 'timestamp');
1494
+ });
1495
+ }
1496
+
1497
  if (responseStyleSelect) {
1498
  responseStyleSelect.addEventListener('change', () => {
1499
  const preset = getSelectedStylePreset();
 
1649
  if (responseLengthSelect && !responseLengthSelect.value) {
1650
  responseLengthSelect.value = 'short';
1651
  }
1652
+ if (runtimeSelect && !runtimeSelect.value) {
1653
+ runtimeSelect.value = 'cpu';
1654
+ }
1655
  if (aiInstrumentSelect && !aiInstrumentSelect.value) {
1656
  aiInstrumentSelect.value = 'fm';
1657
  }
 
1662
 
1663
  // Setup keyboard event listeners and UI
1664
  attachPointerEvents();
1665
+ initTerminal();
1666
+ const runtimeMode = getSelectedRuntime();
1667
+ const runtimeLabel = runtimeMode === 'gpu' ? 'ZeroGPU' : (runtimeMode === 'auto' ? 'Auto (GPU->CPU)' : 'CPU');
1668
+ logToTerminal(`Runtime mode: ${runtimeLabel}`, 'timestamp');
1669
  // Set initial button states
1670
  recordBtn.disabled = false;
1671
  stopBtn.disabled = true;
static/styles.css CHANGED
@@ -1,418 +1,438 @@
1
- /* Virtual MIDI Keyboard - Retro Synth Theme */
2
-
3
- /* Layout */
4
- body {
5
- font-family: 'Courier New', 'Consolas', monospace;
6
- padding: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  margin: 0;
8
- background: linear-gradient(180deg, #0a0a0f 0%, #1a0a1f 100%);
9
- color: #e0e0ff;
10
  min-height: 100vh;
11
- overflow-x: hidden;
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
- /* Welcome Header */
15
  .welcome-header {
16
  text-align: center;
17
- padding: 30px 20px 20px;
18
- background: linear-gradient(180deg, rgba(138, 43, 226, 0.1) 0%, transparent 100%);
19
- border-bottom: 2px solid rgba(138, 43, 226, 0.3);
 
 
 
 
 
 
 
20
  }
21
 
22
  .neon-text {
23
- font-size: 2.5em;
24
- font-weight: bold;
25
- margin: 0 0 10px;
26
- color: #ff00ff;
27
- text-shadow:
28
- 0 0 10px #ff00ff,
29
- 0 0 20px #ff00ff,
30
- 0 0 30px #ff00ff,
31
- 0 0 40px #8b00ff,
32
- 0 0 70px #8b00ff;
33
- letter-spacing: 2px;
34
- }
35
-
36
- #mainContainer {
37
- display: flex;
38
- flex-direction: column;
39
- align-items: center;
40
- gap: 30px;
41
- padding: 20px;
42
- max-width: 1400px;
43
- margin: 0 auto;
44
  }
45
 
46
- .keyboard-section {
47
- width: 100%;
48
- display: flex;
49
- flex-direction: column;
50
- align-items: center;
51
- gap: 15px;
52
  }
53
 
54
- .monitor-section {
55
- width: 100%;
56
- max-width: 1200px;
 
57
  }
58
 
59
- /* Keyboard */
60
- #keyboard {
61
- display: flex;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  user-select: none;
63
  touch-action: none;
64
- padding: 20px;
65
- background: rgba(0, 0, 0, 0.4);
66
- border-radius: 15px;
67
- border: 2px solid rgba(138, 43, 226, 0.5);
68
- box-shadow:
69
- 0 0 20px rgba(138, 43, 226, 0.3),
70
- inset 0 0 30px rgba(0, 0, 0, 0.5);
71
- }
72
-
73
- .key {
74
- width: 42px;
75
- height: 180px;
76
- border: 2px solid #ff00ff;
77
- margin: 0 1px;
78
- background: linear-gradient(180deg, #fff 0%, #f0f0ff 100%);
79
- position: relative;
80
- display: flex;
81
- align-items: flex-end;
82
- justify-content: center;
83
  cursor: pointer;
84
- touch-action: none;
85
- transition: all 0.1s ease;
86
- box-shadow:
87
- 0 4px 8px rgba(0, 0, 0, 0.3),
88
- inset 0 -2px 5px rgba(0, 0, 0, 0.1);
89
- color: #2a0050;
90
- font-weight: bold;
91
  }
92
 
93
- .key .shortcut-hint {
94
- font-size: 10px;
95
- opacity: 0;
96
- color: #5a00cc;
97
- font-weight: bold;
98
- text-shadow: 0 0 2px rgba(90, 0, 204, 0.3);
99
- transition: opacity 0.2s ease;
100
  }
101
 
102
  .key.black {
103
- color: #00ffff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  }
105
 
106
  .key.black .shortcut-hint {
107
- color: #00ffff;
108
- text-shadow: 0 0 3px rgba(0, 255, 255, 0.5);
109
  }
110
 
111
  .shortcuts-visible .key .shortcut-hint {
112
  opacity: 1;
113
  }
114
 
115
- .key:hover {
116
- box-shadow:
117
- 0 0 15px rgba(255, 0, 255, 0.5),
118
- inset 0 -2px 5px rgba(0, 0, 0, 0.1);
119
- }
120
-
121
- .key.black {
122
- width: 30px;
123
- height: 110px;
124
- background: linear-gradient(180deg, #1a0a2e 0%, #0a0a1f 100%);
125
- border: 2px solid #8b00ff;
126
- color: #00ffff;
127
- margin-left: -15px;
128
- margin-right: -15px;
129
- z-index: 2;
130
- position: relative;
131
- box-shadow:
132
- 0 4px 8px rgba(0, 0, 0, 0.5),
133
- inset 0 -2px 5px rgba(138, 43, 226, 0.3);
134
  }
135
 
136
- .key.black:hover {
137
- box-shadow:
138
- 0 0 15px rgba(0, 255, 255, 0.5),
139
- inset 0 -2px 5px rgba(138, 43, 226, 0.3);
140
  }
141
 
142
- /* Controls */
143
- .controls {
144
- display: flex;
145
- gap: 10px;
146
- align-items: center;
147
- flex-wrap: wrap;
148
- justify-content: center;
149
- padding: 15px;
150
- background: rgba(0, 0, 0, 0.3);
151
- border-radius: 10px;
152
- border: 1px solid rgba(138, 43, 226, 0.3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
 
155
- .controls label {
156
- color: #00ffff;
157
- font-weight: bold;
 
 
 
 
 
158
  display: flex;
 
159
  align-items: center;
160
- gap: 5px;
161
  }
162
 
163
- select,
164
- button {
165
- padding: 8px 16px;
166
- background: linear-gradient(180deg, #8b00ff 0%, #5a00cc 100%);
167
- color: #00ffff;
168
- border: 2px solid #ff00ff;
169
- border-radius: 5px;
170
- font-family: 'Courier New', monospace;
171
- font-weight: bold;
172
  cursor: pointer;
173
- transition: all 0.2s ease;
174
- box-shadow: 0 0 10px rgba(138, 43, 226, 0.3);
175
  }
176
 
177
- select:hover,
178
- button:hover:not(:disabled) {
179
- background: linear-gradient(180deg, #a000ff 0%, #7000ee 100%);
180
- box-shadow:
181
- 0 0 20px rgba(255, 0, 255, 0.6),
182
- 0 0 30px rgba(138, 43, 226, 0.4);
183
  transform: translateY(-1px);
184
  }
185
 
186
- button:active:not(:disabled) {
187
- transform: translateY(0);
188
- box-shadow: 0 0 10px rgba(138, 43, 226, 0.3);
189
  }
190
 
191
- button:disabled {
192
- opacity: 0.3;
193
- cursor: not-allowed;
194
  }
195
 
196
- input[type="checkbox"] {
197
- cursor: pointer;
198
- width: 18px;
199
- height: 18px;
200
- accent-color: #ff00ff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  }
202
 
203
- #status {
204
- color: #00ff00;
205
- font-weight: bold;
206
- text-shadow: 0 0 5px #00ff00;
207
- padding: 5px 10px;
208
- background: rgba(0, 0, 0, 0.5);
209
- border-radius: 5px;
210
  }
211
 
212
- /* MIDI Terminal */
213
- .terminal-header {
214
- display: flex;
215
- justify-content: space-between;
216
- align-items: center;
217
- padding: 10px 15px;
218
- background: linear-gradient(90deg, rgba(138, 43, 226, 0.2) 0%, rgba(0, 255, 255, 0.1) 100%);
219
- border: 2px solid rgba(138, 43, 226, 0.5);
220
- border-bottom: none;
221
- border-radius: 10px 10px 0 0;
222
  }
223
 
224
  .terminal-header h4 {
225
  margin: 0;
226
- color: #ff00ff;
227
- font-size: 1.1em;
228
- letter-spacing: 2px;
229
- text-shadow: 0 0 10px #ff00ff;
230
- }
231
-
232
- .terminal-header button {
233
- padding: 6px 12px;
234
- font-size: 0.85em;
235
- }
236
-
237
- #terminal {
238
- background: #0a0a0f;
239
- color: #00ff00;
240
- font-family: 'Courier New', monospace;
241
- font-size: 13px;
242
- padding: 15px;
243
- height: 250px;
244
- overflow-y: auto;
245
- border: 2px solid rgba(138, 43, 226, 0.5);
246
- border-top: 1px solid rgba(138, 43, 226, 0.3);
247
- border-radius: 0 0 10px 10px;
248
  white-space: pre-wrap;
249
  word-wrap: break-word;
250
- box-shadow:
251
- inset 0 0 30px rgba(0, 0, 0, 0.8),
252
- 0 0 20px rgba(138, 43, 226, 0.2);
 
 
253
  }
254
 
255
- #terminal .note-on {
256
- color: #00ff00;
257
- text-shadow: 0 0 5px #00ff00;
258
  }
259
 
260
- #terminal .note-off {
261
- color: #ff00ff;
262
- text-shadow: 0 0 5px #ff00ff;
263
  }
264
 
265
- #terminal .timestamp {
266
- color: #00ffff;
267
- text-shadow: 0 0 3px #00ffff;
268
  }
269
 
270
- /* Scrollbar styling */
271
  #terminal::-webkit-scrollbar {
272
  width: 10px;
273
  }
274
 
275
  #terminal::-webkit-scrollbar-track {
276
- background: rgba(0, 0, 0, 0.5);
277
  }
278
 
279
  #terminal::-webkit-scrollbar-thumb {
280
- background: linear-gradient(180deg, #ff00ff 0%, #8b00ff 100%);
281
- border-radius: 5px;
282
  }
283
 
284
- #terminal::-webkit-scrollbar-thumb:hover {
285
- background: linear-gradient(180deg, #ff33ff 0%, #aa00ff 100%);
286
- }
287
- /* =============================================================================
288
- RESPONSIVE DESIGN - MOBILE & TABLET
289
- ============================================================================= */
 
 
290
 
291
- /* Tablet (landscape) */
292
- @media (max-width: 1024px) {
293
- .welcome-header h1 {
294
- font-size: 2em;
295
  }
296
-
297
  .key {
298
  width: 35px;
299
- height: 150px;
 
300
  }
301
-
302
  .key.black {
303
- width: 25px;
304
- height: 95px;
305
  margin-left: -12px;
306
  margin-right: -12px;
307
  }
308
- }
309
 
310
- /* Mobile (portrait and small screens) */
311
- @media (max-width: 768px) {
312
- body {
313
- padding: 10px;
314
  }
315
-
 
 
316
  .welcome-header {
317
- padding: 15px;
318
- margin-bottom: 15px;
319
- }
320
-
321
- .welcome-header h1 {
322
- font-size: 1.8em;
323
- letter-spacing: 3px;
324
- }
325
-
326
- #mainContainer {
327
- gap: 10px;
328
  }
329
-
330
- #keyboard {
331
- padding: 10px;
332
- overflow-x: auto;
333
- overflow-y: hidden;
334
- -webkit-overflow-scrolling: touch;
335
- }
336
-
337
- .key {
338
- width: 28px;
339
- height: 120px;
340
- font-size: 9px;
341
- }
342
-
343
- .key.black {
344
- width: 20px;
345
- height: 75px;
346
- margin-left: -10px;
347
- margin-right: -10px;
348
- }
349
-
350
- .key .shortcut-hint {
351
- font-size: 8px;
352
  }
353
-
354
- .controls {
355
- gap: 8px;
356
- padding: 10px;
357
- font-size: 14px;
358
  }
359
-
360
- button, select {
361
- font-size: 12px;
362
- padding: 6px 10px;
363
  }
364
-
365
  #status {
366
- font-size: 12px;
367
- }
368
-
369
- .terminal-header h4 {
370
- font-size: 14px;
371
  }
372
-
373
  #terminal {
374
- height: 150px;
375
  font-size: 11px;
376
  }
377
  }
378
 
379
- /* Very small mobile (portrait) */
380
  @media (max-width: 480px) {
381
- .welcome-header h1 {
382
- font-size: 1.5em;
383
- letter-spacing: 2px;
 
 
 
384
  }
385
-
386
  .key {
387
- width: 22px;
388
- height: 100px;
389
  font-size: 8px;
390
  }
391
-
392
  .key.black {
393
- width: 16px;
394
- height: 65px;
395
- margin-left: -8px;
396
- margin-right: -8px;
397
  }
398
-
399
- .key .shortcut-hint {
400
- font-size: 7px;
401
- }
402
-
403
- .controls {
404
- gap: 6px;
405
- padding: 8px;
406
- font-size: 12px;
407
- }
408
-
409
- button, select {
410
- font-size: 11px;
411
- padding: 5px 8px;
412
- }
413
-
414
- #terminal {
415
- height: 120px;
416
- font-size: 10px;
417
- }
418
- }
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
2
+
3
+ :root {
4
+ --bg-1: #f5f7fb;
5
+ --bg-2: #e6eef9;
6
+ --panel: rgba(255, 255, 255, 0.92);
7
+ --panel-border: rgba(17, 56, 106, 0.16);
8
+ --panel-shadow: 0 28px 52px rgba(12, 34, 64, 0.14);
9
+ --ink: #132238;
10
+ --ink-soft: #4d607c;
11
+ --accent: #1677ff;
12
+ --accent-strong: #0f5ed2;
13
+ --accent-soft: #d9e8ff;
14
+ --success: #11a67b;
15
+ --danger: #d73746;
16
+ --keyboard-stage: linear-gradient(160deg, #f8fbff 0%, #edf4ff 100%);
17
+ --terminal-bg: #0f1725;
18
+ --terminal-ink: #91ffc0;
19
+ --terminal-note-off: #9ed2ff;
20
+ --terminal-time: #ffe089;
21
+ --radius-l: 20px;
22
+ --radius-m: 14px;
23
+ --radius-s: 10px;
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ body {
31
  margin: 0;
32
+ padding: 0;
 
33
  min-height: 100vh;
34
+ color: var(--ink);
35
+ background:
36
+ radial-gradient(1200px 600px at 10% -10%, rgba(45, 135, 255, 0.16), transparent 60%),
37
+ radial-gradient(900px 500px at 100% 0%, rgba(17, 166, 123, 0.12), transparent 62%),
38
+ linear-gradient(170deg, var(--bg-1), var(--bg-2));
39
+ font-family: 'Space Grotesk', system-ui, sans-serif;
40
+ }
41
+
42
+ .app-shell {
43
+ max-width: 1360px;
44
+ margin: 0 auto;
45
+ padding: 20px 18px 30px;
46
  }
47
 
 
48
  .welcome-header {
49
  text-align: center;
50
+ padding: 14px 18px 18px;
51
+ }
52
+
53
+ .eyebrow {
54
+ margin: 0;
55
+ font-size: 12px;
56
+ letter-spacing: 0.16em;
57
+ text-transform: uppercase;
58
+ color: var(--accent-strong);
59
+ font-weight: 700;
60
  }
61
 
62
  .neon-text {
63
+ margin: 8px 0 10px;
64
+ font-size: clamp(2rem, 5vw, 3.3rem);
65
+ line-height: 1;
66
+ letter-spacing: 0.06em;
67
+ color: #0e2a4f;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
+ .neon-text i {
71
+ color: var(--accent);
72
+ font-style: normal;
 
 
 
73
  }
74
 
75
+ .subtitle {
76
+ margin: 0;
77
+ color: var(--ink-soft);
78
+ font-size: clamp(0.95rem, 2vw, 1.1rem);
79
  }
80
 
81
+ #mainContainer {
82
+ display: grid;
83
+ grid-template-columns: minmax(0, 1fr);
84
+ gap: 20px;
85
+ }
86
+
87
+ .card {
88
+ background: var(--panel);
89
+ border: 1px solid var(--panel-border);
90
+ border-radius: var(--radius-l);
91
+ box-shadow: var(--panel-shadow);
92
+ }
93
+
94
+ .keyboard-section {
95
+ padding: 18px;
96
+ }
97
+
98
+ #keyboard {
99
+ display: flex;
100
+ justify-content: center;
101
+ width: 100%;
102
+ padding: 18px 14px;
103
+ border-radius: 16px;
104
+ background: var(--keyboard-stage);
105
+ border: 1px solid rgba(22, 119, 255, 0.25);
106
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 10px 24px rgba(22, 119, 255, 0.1);
107
  user-select: none;
108
  touch-action: none;
109
+ overflow-x: auto;
110
+ }
111
+
112
+ .key {
113
+ width: 44px;
114
+ height: 190px;
115
+ margin: 0 1px;
116
+ border: 1px solid rgba(19, 34, 56, 0.16);
117
+ border-bottom-width: 4px;
118
+ border-radius: 0 0 11px 11px;
119
+ background: linear-gradient(180deg, #ffffff, #f2f7ff);
120
+ position: relative;
121
+ display: flex;
122
+ align-items: flex-end;
123
+ justify-content: center;
 
 
 
 
124
  cursor: pointer;
125
+ transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
126
+ color: #1f3250;
127
+ font-size: 11px;
128
+ font-weight: 600;
 
 
 
129
  }
130
 
131
+ .key:hover {
132
+ transform: translateY(1px);
133
+ box-shadow: inset 0 -6px 10px rgba(22, 119, 255, 0.08);
 
 
 
 
134
  }
135
 
136
  .key.black {
137
+ width: 30px;
138
+ height: 118px;
139
+ margin-left: -15px;
140
+ margin-right: -15px;
141
+ z-index: 3;
142
+ border-radius: 0 0 10px 10px;
143
+ border-color: rgba(9, 22, 39, 0.88);
144
+ border-bottom-width: 3px;
145
+ background: linear-gradient(180deg, #2a3550, #111a29);
146
+ color: #d9ecff;
147
+ box-shadow: 0 6px 10px rgba(12, 23, 37, 0.35);
148
+ }
149
+
150
+ .key.black:hover {
151
+ box-shadow: 0 8px 14px rgba(12, 23, 37, 0.42);
152
+ }
153
+
154
+ .key .shortcut-hint {
155
+ display: block;
156
+ opacity: 0;
157
+ font-size: 10px;
158
+ letter-spacing: 0.02em;
159
+ color: var(--accent-strong);
160
+ transition: opacity 160ms ease;
161
  }
162
 
163
  .key.black .shortcut-hint {
164
+ color: #8cd2ff;
 
165
  }
166
 
167
  .shortcuts-visible .key .shortcut-hint {
168
  opacity: 1;
169
  }
170
 
171
+ .controls {
172
+ margin-top: 16px;
173
+ padding: 16px;
174
+ border-radius: var(--radius-m);
175
+ background: rgba(250, 252, 255, 0.9);
176
+ border: 1px solid rgba(22, 119, 255, 0.17);
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  }
178
 
179
+ .control-grid {
180
+ display: grid;
181
+ grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
182
+ gap: 10px;
183
  }
184
 
185
+ .control-item {
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 7px;
189
+ padding: 10px;
190
+ border: 1px solid rgba(19, 34, 56, 0.1);
191
+ border-radius: var(--radius-s);
192
+ background: #fff;
193
+ color: var(--ink-soft);
194
+ font-size: 12px;
195
+ font-weight: 700;
196
+ letter-spacing: 0.01em;
197
+ }
198
+
199
+ .control-item-toggle {
200
+ flex-direction: row;
201
+ align-items: center;
202
+ justify-content: flex-start;
203
+ gap: 10px;
204
+ font-size: 13px;
205
+ }
206
+
207
+ select,
208
+ button {
209
+ font-family: 'IBM Plex Mono', monospace;
210
+ }
211
+
212
+ select {
213
+ width: 100%;
214
+ padding: 8px 10px;
215
+ border: 1px solid rgba(19, 34, 56, 0.22);
216
+ border-radius: 9px;
217
+ background: #fff;
218
+ color: #173257;
219
+ font-size: 13px;
220
+ }
221
+
222
+ select:focus,
223
+ button:focus,
224
+ input[type='checkbox']:focus {
225
+ outline: 2px solid var(--accent-soft);
226
+ outline-offset: 1px;
227
  }
228
 
229
+ input[type='checkbox'] {
230
+ width: 18px;
231
+ height: 18px;
232
+ accent-color: var(--accent);
233
+ }
234
+
235
+ .action-row {
236
+ margin-top: 14px;
237
  display: flex;
238
+ flex-wrap: wrap;
239
  align-items: center;
240
+ gap: 8px;
241
  }
242
 
243
+ .btn {
244
+ border: 0;
245
+ border-radius: 10px;
246
+ padding: 9px 14px;
247
+ font-size: 13px;
248
+ font-weight: 600;
249
+ color: #fff;
 
 
250
  cursor: pointer;
251
+ transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
 
252
  }
253
 
254
+ .btn:hover:not(:disabled) {
 
 
 
 
 
255
  transform: translateY(-1px);
256
  }
257
 
258
+ .btn:disabled {
259
+ opacity: 0.42;
260
+ cursor: not-allowed;
261
  }
262
 
263
+ .btn-primary {
264
+ background: linear-gradient(180deg, #1a87ff, #1267da);
265
+ box-shadow: 0 8px 16px rgba(18, 103, 218, 0.24);
266
  }
267
 
268
+ .btn-secondary {
269
+ background: linear-gradient(180deg, #67768f, #4f5e77);
270
+ box-shadow: 0 8px 16px rgba(79, 94, 119, 0.24);
271
+ }
272
+
273
+ .btn-game {
274
+ background: linear-gradient(180deg, #14b784, #0f976e);
275
+ box-shadow: 0 8px 16px rgba(15, 151, 110, 0.24);
276
+ }
277
+
278
+ .btn-danger {
279
+ background: linear-gradient(180deg, #e54e5d, #c73444);
280
+ box-shadow: 0 8px 16px rgba(199, 52, 68, 0.24);
281
+ }
282
+
283
+ #status {
284
+ margin-left: auto;
285
+ padding: 8px 12px;
286
+ border-radius: 999px;
287
+ background: #f0f6ff;
288
+ color: #0f5ed2;
289
+ border: 1px solid rgba(15, 94, 210, 0.24);
290
+ font-family: 'IBM Plex Mono', monospace;
291
+ font-size: 12px;
292
+ font-weight: 600;
293
  }
294
 
295
+ .monitor-section {
296
+ overflow: hidden;
 
 
 
 
 
297
  }
298
 
299
+ .terminal-header {
300
+ display: flex;
301
+ justify-content: space-between;
302
+ align-items: center;
303
+ gap: 12px;
304
+ padding: 12px 14px;
305
+ border-bottom: 1px solid rgba(19, 34, 56, 0.16);
306
+ background: linear-gradient(180deg, rgba(22, 119, 255, 0.08), rgba(22, 119, 255, 0.02));
 
 
307
  }
308
 
309
  .terminal-header h4 {
310
  margin: 0;
311
+ color: #173257;
312
+ letter-spacing: 0.08em;
313
+ font-size: 14px;
314
+ }
315
+
316
+ #terminal {
317
+ margin: 0;
318
+ min-height: 220px;
319
+ max-height: 300px;
320
+ overflow-y: auto;
321
+ padding: 14px;
 
 
 
 
 
 
 
 
 
 
 
322
  white-space: pre-wrap;
323
  word-wrap: break-word;
324
+ background: radial-gradient(800px 260px at 100% 0%, rgba(32, 74, 130, 0.35), transparent 55%), var(--terminal-bg);
325
+ color: var(--terminal-ink);
326
+ font-family: 'IBM Plex Mono', monospace;
327
+ font-size: 12px;
328
+ line-height: 1.35;
329
  }
330
 
331
+ #terminal .note-on {
332
+ color: var(--terminal-ink);
 
333
  }
334
 
335
+ #terminal .note-off {
336
+ color: var(--terminal-note-off);
 
337
  }
338
 
339
+ #terminal .timestamp {
340
+ color: var(--terminal-time);
 
341
  }
342
 
 
343
  #terminal::-webkit-scrollbar {
344
  width: 10px;
345
  }
346
 
347
  #terminal::-webkit-scrollbar-track {
348
+ background: rgba(15, 23, 37, 0.55);
349
  }
350
 
351
  #terminal::-webkit-scrollbar-thumb {
352
+ border-radius: 10px;
353
+ background: linear-gradient(180deg, #4a8dff, #2f66bf);
354
  }
355
 
356
+ @media (max-width: 980px) {
357
+ .app-shell {
358
+ padding: 16px 12px 24px;
359
+ }
360
+
361
+ .keyboard-section {
362
+ padding: 12px;
363
+ }
364
 
365
+ .controls {
366
+ padding: 12px;
 
 
367
  }
368
+
369
  .key {
370
  width: 35px;
371
+ height: 158px;
372
+ font-size: 9px;
373
  }
374
+
375
  .key.black {
376
+ width: 24px;
377
+ height: 98px;
378
  margin-left: -12px;
379
  margin-right: -12px;
380
  }
 
381
 
382
+ .control-grid {
383
+ grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
 
 
384
  }
385
+ }
386
+
387
+ @media (max-width: 640px) {
388
  .welcome-header {
389
+ padding: 8px 4px 14px;
 
 
 
 
 
 
 
 
 
 
390
  }
391
+
392
+ .subtitle {
393
+ max-width: 92%;
394
+ margin: 0 auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  }
396
+
397
+ .control-grid {
398
+ grid-template-columns: 1fr 1fr;
 
 
399
  }
400
+
401
+ .action-row {
402
+ gap: 7px;
 
403
  }
404
+
405
  #status {
406
+ margin-left: 0;
407
+ width: 100%;
408
+ text-align: center;
 
 
409
  }
410
+
411
  #terminal {
412
+ min-height: 190px;
413
  font-size: 11px;
414
  }
415
  }
416
 
 
417
  @media (max-width: 480px) {
418
+ .control-grid {
419
+ grid-template-columns: 1fr;
420
+ }
421
+
422
+ #keyboard {
423
+ justify-content: flex-start;
424
  }
425
+
426
  .key {
427
+ width: 27px;
428
+ height: 126px;
429
  font-size: 8px;
430
  }
431
+
432
  .key.black {
433
+ width: 18px;
434
+ height: 76px;
435
+ margin-left: -9px;
436
+ margin-right: -9px;
437
  }
438
+ }