@brandonlukas/luminar 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ import { BufferAttribute, BufferGeometry } from 'three'
2
+ import { SimplexNoise } from 'three/examples/jsm/math/SimplexNoise.js'
3
+ import type { VectorSample, VectorDatum, ColorPreset, ParticleParams, FieldTransform } from '../lib/types'
4
+ import { WORLD_EXTENT, FLOW_SCALE, SPEED_TO_GLOW, JITTER, DEFAULT_COLOR_PRESET, COLOR_PRESETS } from '../lib/constants'
5
+
6
+ export class ParticleSystem {
7
+ // Particle data buffers
8
+ private positions: Float32Array
9
+ private colors: Float32Array
10
+ private lifetimes: Float32Array
11
+
12
+ // Field data and transformation
13
+ private fieldData: VectorDatum[] | null = null
14
+ private fieldTransform: FieldTransform = { scale: 1, offsetX: 0, offsetY: 0 }
15
+
16
+ // Spatial grid for O(1) field lookups
17
+ private grid: Map<string, VectorDatum[]> = new Map()
18
+ private gridCellSize = 0.1
19
+
20
+ // Three.js resources
21
+ public geometry: BufferGeometry
22
+ private params: ParticleParams
23
+
24
+ // View and rendering state
25
+ private viewOffsetX: number
26
+ private activePalette: ColorPreset | null = null
27
+
28
+ // Noise generation
29
+ private noise: SimplexNoise
30
+ private readonly noiseScale = 0.9
31
+ private readonly noiseTimeScale = 0.15
32
+
33
+ constructor(
34
+ geometry: BufferGeometry,
35
+ params: ParticleParams,
36
+ ) {
37
+ this.geometry = geometry
38
+ this.params = params
39
+ this.viewOffsetX = 0
40
+ this.positions = new Float32Array(params.particleCount * 3)
41
+ this.colors = new Float32Array(params.particleCount * 3)
42
+ this.lifetimes = new Float32Array(params.particleCount)
43
+ this.noise = new SimplexNoise()
44
+
45
+ this.geometry.setAttribute('position', new BufferAttribute(this.positions, 3))
46
+ this.geometry.setAttribute('color', new BufferAttribute(this.colors, 3))
47
+ }
48
+
49
+ init() {
50
+ for (let i = 0; i < this.params.particleCount; i += 1) {
51
+ this.resetParticle(i)
52
+ }
53
+ this.updateBuffers()
54
+ }
55
+
56
+ setFieldData(data: VectorDatum[] | null, transform: FieldTransform) {
57
+ this.fieldData = data
58
+ this.fieldTransform = transform
59
+ this.buildSpatialGrid()
60
+ }
61
+
62
+ hasFieldData(): boolean {
63
+ return this.fieldData !== null && this.fieldData.length > 0
64
+ }
65
+
66
+ setViewOffset(offsetX: number) {
67
+ if (this.viewOffsetX !== offsetX) {
68
+ this.viewOffsetX = offsetX
69
+ // Reseed particles when offset changes to prevent gaps
70
+ for (let i = 0; i < this.params.particleCount; i += 1) {
71
+ this.resetParticle(i)
72
+ }
73
+ this.updateBuffers()
74
+ }
75
+ }
76
+
77
+ update(dt: number, palette: ColorPreset) {
78
+ const time = performance.now() * 0.001
79
+ this.activePalette = palette
80
+ const noiseEnabled = this.params.noiseStrength > 0
81
+ const speedMultiplier = FLOW_SCALE * this.params.speed * dt
82
+ const jitterRange = JITTER * dt
83
+
84
+ for (let i = 0; i < this.params.particleCount; i += 1) {
85
+ const i3 = i * 3
86
+ let x = this.positions[i3]
87
+ let y = this.positions[i3 + 1]
88
+
89
+ const v = this.sampleField(x, y, time)
90
+ if (!v) {
91
+ this.resetParticle(i)
92
+ continue
93
+ }
94
+
95
+ if (noiseEnabled) {
96
+ this.applyNoise(v, x, y, time)
97
+ }
98
+
99
+ x += v.x * speedMultiplier + this.randomRange(-jitterRange, jitterRange)
100
+ y += v.y * speedMultiplier + this.randomRange(-jitterRange, jitterRange)
101
+
102
+ const speed = Math.hypot(v.x, v.y)
103
+ const glow = Math.min(1, speed * SPEED_TO_GLOW)
104
+
105
+ this.applyColor(i3, glow, palette, false)
106
+
107
+ this.lifetimes[i] -= dt
108
+
109
+ if (this.shouldResetParticle(i, x, y)) {
110
+ this.resetParticle(i)
111
+ } else {
112
+ this.positions[i3] = x
113
+ this.positions[i3 + 1] = y
114
+ }
115
+ }
116
+
117
+ this.updateBuffers()
118
+ }
119
+
120
+ resizeBuffers(newCount: number) {
121
+ this.params.particleCount = newCount
122
+ this.positions = new Float32Array(newCount * 3)
123
+ this.colors = new Float32Array(newCount * 3)
124
+ this.lifetimes = new Float32Array(newCount)
125
+ this.geometry.setAttribute('position', new BufferAttribute(this.positions, 3))
126
+ this.geometry.setAttribute('color', new BufferAttribute(this.colors, 3))
127
+ this.init()
128
+ }
129
+
130
+ reseedLifetimes() {
131
+ for (let i = 0; i < this.params.particleCount; i += 1) {
132
+ this.lifetimes[i] = this.randomRange(this.params.lifeMin, this.params.lifeMax)
133
+ }
134
+ }
135
+
136
+ private shouldResetParticle(i: number, x: number, y: number): boolean {
137
+ return (
138
+ this.lifetimes[i] <= 0 ||
139
+ Math.abs(x - this.viewOffsetX) > WORLD_EXTENT ||
140
+ Math.abs(y) > WORLD_EXTENT
141
+ )
142
+ }
143
+
144
+ private applyNoise(velocity: VectorSample, x: number, y: number, time: number) {
145
+ const nX = this.noise.noise3d(x * this.noiseScale, y * this.noiseScale, time * this.noiseTimeScale)
146
+ const nY = this.noise.noise3d((x + 10) * this.noiseScale, (y + 10) * this.noiseScale, time * this.noiseTimeScale)
147
+ velocity.x += nX * this.params.noiseStrength
148
+ velocity.y += nY * this.params.noiseStrength
149
+ }
150
+
151
+ private resetParticle(i: number) {
152
+ const i3 = i * 3
153
+ const palette = this.activePalette ?? this.getActiveColorPreset()
154
+
155
+ if (this.hasFieldData()) {
156
+ this.resetParticleWithinField(i3)
157
+ } else {
158
+ this.resetParticleRandomly(i3)
159
+ }
160
+
161
+ this.lifetimes[i] = this.randomRange(this.params.lifeMin, this.params.lifeMax)
162
+ const glow = 0.4 + Math.random() * 0.2
163
+ this.applyColor(i3, glow, palette, true)
164
+ }
165
+
166
+ private resetParticleWithinField(i3: number) {
167
+ const randomPoint = this.fieldData![Math.floor(Math.random() * this.fieldData!.length)]
168
+ const jitterRange = this.params.fieldValidDistance * 0.3
169
+ const dataX = randomPoint.x + this.randomRange(-jitterRange, jitterRange) / this.fieldTransform.scale
170
+ const dataY = randomPoint.y + this.randomRange(-jitterRange, jitterRange) / this.fieldTransform.scale
171
+
172
+ this.positions[i3] = this.dataToWorldX(dataX)
173
+ this.positions[i3 + 1] = this.dataToWorldY(dataY)
174
+ this.positions[i3 + 2] = 0
175
+ }
176
+
177
+ private resetParticleRandomly(i3: number) {
178
+ this.positions[i3] = this.randomRange(-WORLD_EXTENT, WORLD_EXTENT) + this.viewOffsetX
179
+ this.positions[i3 + 1] = this.randomRange(-WORLD_EXTENT, WORLD_EXTENT)
180
+ this.positions[i3 + 2] = 0
181
+ }
182
+
183
+ private dataToWorldX(dataX: number): number {
184
+ return dataX * this.fieldTransform.scale + this.fieldTransform.offsetX + this.viewOffsetX
185
+ }
186
+
187
+ private dataToWorldY(dataY: number): number {
188
+ return dataY * this.fieldTransform.scale + this.fieldTransform.offsetY
189
+ }
190
+
191
+ private worldToDataX(worldX: number): number {
192
+ return (worldX - this.viewOffsetX - this.fieldTransform.offsetX) / this.fieldTransform.scale
193
+ }
194
+
195
+ private worldToDataY(worldY: number): number {
196
+ return (worldY - this.fieldTransform.offsetY) / this.fieldTransform.scale
197
+ }
198
+
199
+ private buildSpatialGrid() {
200
+ this.grid.clear()
201
+
202
+ if (!this.fieldData || this.fieldData.length === 0) {
203
+ return
204
+ }
205
+
206
+ // Find bounds
207
+ let minX = this.fieldData[0].x
208
+ let maxX = this.fieldData[0].x
209
+ let minY = this.fieldData[0].y
210
+ let maxY = this.fieldData[0].y
211
+
212
+ for (const d of this.fieldData) {
213
+ if (d.x < minX) minX = d.x
214
+ if (d.x > maxX) maxX = d.x
215
+ if (d.y < minY) minY = d.y
216
+ if (d.y > maxY) maxY = d.y
217
+ }
218
+
219
+ // Auto-adjust cell size based on data density
220
+ const width = maxX - minX
221
+ const height = maxY - minY
222
+ const avgDim = (width + height) / 2
223
+ const targetCellsPerDim = Math.ceil(Math.sqrt(this.fieldData.length))
224
+ this.gridCellSize = Math.max(0.01, avgDim / targetCellsPerDim)
225
+
226
+ // Populate grid
227
+ for (const datum of this.fieldData) {
228
+ const key = this.getGridKey(datum.x, datum.y)
229
+ const cell = this.grid.get(key)
230
+ if (cell) {
231
+ cell.push(datum)
232
+ } else {
233
+ this.grid.set(key, [datum])
234
+ }
235
+ }
236
+ }
237
+
238
+ private getGridKey(x: number, y: number): string {
239
+ const cellX = Math.floor(x / this.gridCellSize)
240
+ const cellY = Math.floor(y / this.gridCellSize)
241
+ return `${cellX},${cellY}`
242
+ }
243
+
244
+ private sampleField(x: number, y: number, _time: number): VectorSample | null {
245
+ if (!this.hasFieldData()) {
246
+ return { x: 1, y: 0 }
247
+ }
248
+
249
+ const dataX = this.worldToDataX(x)
250
+ const dataY = this.worldToDataY(y)
251
+ const nearest = this.findNearestFieldPoint(dataX, dataY)
252
+
253
+ if (!nearest) {
254
+ return null
255
+ }
256
+
257
+ return {
258
+ x: nearest.dx * this.fieldTransform.scale,
259
+ y: nearest.dy * this.fieldTransform.scale,
260
+ }
261
+ }
262
+
263
+ private findNearestFieldPoint(dataX: number, dataY: number): VectorDatum | null {
264
+ let nearest: VectorDatum | null = null
265
+ let bestDistSq = Number.MAX_VALUE
266
+ const thresholdSq = Math.pow(this.params.fieldValidDistance / this.fieldTransform.scale, 2)
267
+
268
+ // Search 3x3 grid of cells (current + 8 neighbors)
269
+ const cellX = Math.floor(dataX / this.gridCellSize)
270
+ const cellY = Math.floor(dataY / this.gridCellSize)
271
+
272
+ for (let dx = -1; dx <= 1; dx += 1) {
273
+ for (let dy = -1; dy <= 1; dy += 1) {
274
+ const cell = this.grid.get(`${cellX + dx},${cellY + dy}`)
275
+ if (!cell) continue
276
+
277
+ for (const d of cell) {
278
+ const diffX = d.x - dataX
279
+ const diffY = d.y - dataY
280
+ const distSq = diffX * diffX + diffY * diffY
281
+ if (distSq < bestDistSq) {
282
+ bestDistSq = distSq
283
+ nearest = d
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ return bestDistSq <= thresholdSq ? nearest : null
290
+ }
291
+
292
+ private applyColor(i3: number, glow: number, palette: ColorPreset, isSpawn: boolean) {
293
+ const clampedGlow = Math.min(1, Math.max(0, glow))
294
+
295
+ // Preserve legacy look for the default palette
296
+ if (palette.key === DEFAULT_COLOR_PRESET) {
297
+ if (isSpawn) {
298
+ this.colors[i3] = 0.6 * clampedGlow
299
+ this.colors[i3 + 1] = 0.25 * clampedGlow
300
+ this.colors[i3 + 2] = 0.9 * clampedGlow
301
+ } else {
302
+ this.colors[i3] = 0.35 + clampedGlow * 0.9
303
+ this.colors[i3 + 1] = 0.18 + clampedGlow * 0.45
304
+ this.colors[i3 + 2] = 0.6 + clampedGlow * 0.35
305
+ }
306
+ return
307
+ }
308
+
309
+ const brightness = isSpawn ? clampedGlow : 0.35 + clampedGlow * 0.65
310
+ const [r, g, b] = palette.rgb
311
+ this.colors[i3] = r * brightness
312
+ this.colors[i3 + 1] = g * brightness
313
+ this.colors[i3 + 2] = b * brightness
314
+ }
315
+
316
+ private getActiveColorPreset(): ColorPreset {
317
+ return COLOR_PRESETS.find((preset) => preset.key === this.params.colorPresetA) ?? COLOR_PRESETS[0]
318
+ }
319
+
320
+ private randomRange(min: number, max: number) {
321
+ return min + Math.random() * (max - min)
322
+ }
323
+
324
+ private updateBuffers() {
325
+ this.geometry.attributes.position.needsUpdate = true
326
+ this.geometry.attributes.color.needsUpdate = true
327
+ this.geometry.computeBoundingSphere()
328
+ }
329
+ }
@@ -0,0 +1,227 @@
1
+ import { WebGLRenderer } from 'three'
2
+ import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
3
+ import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
4
+
5
+ export class RecordingManager {
6
+ private mediaRecorder: MediaRecorder | null = null
7
+ private recordedChunks: Blob[] = []
8
+ private isRecording = false
9
+ private recordingStartTime = 0
10
+ private recordingDuration = 5
11
+ private recordingResolution: 'current' | '1080p' | '1440p' | '4k' = 'current'
12
+ private originalCanvasSize: { width: number; height: number } | null = null
13
+ private recordButton: HTMLButtonElement | null = null
14
+ private recordStatus: HTMLDivElement | null = null
15
+ private renderer: WebGLRenderer
16
+ private composer: EffectComposer
17
+ private bloomPass: UnrealBloomPass
18
+ private onResize: () => void
19
+
20
+ constructor(
21
+ renderer: WebGLRenderer,
22
+ composer: EffectComposer,
23
+ bloomPass: UnrealBloomPass,
24
+ onResize: () => void,
25
+ ) {
26
+ this.renderer = renderer
27
+ this.composer = composer
28
+ this.bloomPass = bloomPass
29
+ this.onResize = onResize
30
+ }
31
+
32
+ createControls(container: HTMLElement) {
33
+ const recordingSection = document.createElement('div')
34
+ recordingSection.className = 'controls__section'
35
+ recordingSection.innerHTML = '<div class="controls__subtitle">Export (WebM)</div>'
36
+
37
+ // Resolution selector
38
+ const resolutionRow = document.createElement('label')
39
+ resolutionRow.className = 'controls__row'
40
+ const resolutionLabel = document.createElement('span')
41
+ resolutionLabel.textContent = 'Resolution'
42
+ const resolutionSelect = document.createElement('select')
43
+ resolutionSelect.className = 'controls__select'
44
+ resolutionSelect.innerHTML = '<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>'
45
+ resolutionSelect.value = this.recordingResolution
46
+ resolutionSelect.addEventListener('change', (e) => {
47
+ this.recordingResolution = (e.target as HTMLSelectElement).value as typeof this.recordingResolution
48
+ })
49
+ resolutionRow.appendChild(resolutionLabel)
50
+ resolutionRow.appendChild(resolutionSelect)
51
+ recordingSection.appendChild(resolutionRow)
52
+
53
+ // Duration selector
54
+ const durationRow = document.createElement('label')
55
+ durationRow.className = 'controls__row'
56
+ const durationLabel = document.createElement('span')
57
+ durationLabel.textContent = 'Duration'
58
+ const durationSelect = document.createElement('select')
59
+ durationSelect.className = 'controls__select'
60
+ durationSelect.innerHTML = '<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>'
61
+ durationSelect.value = String(this.recordingDuration)
62
+ durationSelect.addEventListener('change', (e) => {
63
+ this.recordingDuration = parseInt((e.target as HTMLSelectElement).value)
64
+ })
65
+ durationRow.appendChild(durationLabel)
66
+ durationRow.appendChild(durationSelect)
67
+ recordingSection.appendChild(durationRow)
68
+
69
+ // Record button
70
+ this.recordButton = document.createElement('button')
71
+ this.recordButton.type = 'button'
72
+ this.recordButton.className = 'controls__button controls__button--record'
73
+ this.recordButton.textContent = '⏺ Start recording'
74
+ this.recordButton.addEventListener('click', () => {
75
+ if (this.isRecording) {
76
+ this.stop()
77
+ } else {
78
+ this.start()
79
+ }
80
+ })
81
+ recordingSection.appendChild(this.recordButton)
82
+
83
+ // Status
84
+ this.recordStatus = document.createElement('div')
85
+ this.recordStatus.className = 'controls__status'
86
+ this.recordStatus.style.display = 'none'
87
+ recordingSection.appendChild(this.recordStatus)
88
+
89
+ container.appendChild(recordingSection)
90
+ }
91
+
92
+ update() {
93
+ if (this.isRecording) {
94
+ const elapsed = (performance.now() - this.recordingStartTime) / 1000
95
+ this.updateStatus(elapsed)
96
+ if (elapsed >= this.recordingDuration) {
97
+ this.stop()
98
+ }
99
+ }
100
+ }
101
+
102
+ private start() {
103
+ if (this.isRecording) return
104
+
105
+ try {
106
+ const canvas = this.renderer.domElement
107
+
108
+ let recordWidth: number, recordHeight: number
109
+ const currentAspect = canvas.width / canvas.height
110
+
111
+ if (this.recordingResolution === 'current') {
112
+ recordWidth = canvas.width
113
+ recordHeight = canvas.height
114
+ } else {
115
+ this.originalCanvasSize = { width: canvas.width, height: canvas.height }
116
+
117
+ switch (this.recordingResolution) {
118
+ case '1080p':
119
+ recordHeight = 1080
120
+ recordWidth = Math.round(recordHeight * currentAspect)
121
+ break
122
+ case '1440p':
123
+ recordHeight = 1440
124
+ recordWidth = Math.round(recordHeight * currentAspect)
125
+ break
126
+ case '4k':
127
+ recordHeight = 2160
128
+ recordWidth = Math.round(recordHeight * currentAspect)
129
+ break
130
+ }
131
+
132
+ this.renderer.setSize(recordWidth, recordHeight, false)
133
+ this.composer.setSize(recordWidth, recordHeight)
134
+ this.bloomPass.setSize(recordWidth, recordHeight)
135
+ }
136
+
137
+ const stream = canvas.captureStream(60)
138
+ const pixelCount = recordWidth * recordHeight
139
+ const bitrate = Math.min(25000000, Math.max(8000000, pixelCount * 4))
140
+
141
+ this.recordedChunks = []
142
+ this.mediaRecorder = new MediaRecorder(stream, {
143
+ mimeType: 'video/webm;codecs=vp9',
144
+ videoBitsPerSecond: bitrate,
145
+ })
146
+
147
+ this.mediaRecorder.ondataavailable = (event) => {
148
+ if (event.data.size > 0) {
149
+ this.recordedChunks.push(event.data)
150
+ }
151
+ }
152
+
153
+ this.mediaRecorder.onstop = () => {
154
+ if (this.originalCanvasSize) {
155
+ this.renderer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height, false)
156
+ this.composer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
157
+ this.bloomPass.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
158
+ this.originalCanvasSize = null
159
+ this.onResize()
160
+ }
161
+
162
+ const blob = new Blob(this.recordedChunks, { type: 'video/webm' })
163
+ const url = URL.createObjectURL(blob)
164
+ const a = document.createElement('a')
165
+ a.href = url
166
+ a.download = `luminar-${recordWidth}x${recordHeight}-${Date.now()}.webm`
167
+ a.click()
168
+ URL.revokeObjectURL(url)
169
+
170
+ if (this.recordStatus) {
171
+ this.recordStatus.textContent = 'Recording complete! Download started.'
172
+ setTimeout(() => {
173
+ if (this.recordStatus) this.recordStatus.style.display = 'none'
174
+ }, 3000)
175
+ }
176
+ }
177
+
178
+ this.mediaRecorder.start()
179
+ this.isRecording = true
180
+ this.recordingStartTime = performance.now()
181
+
182
+ if (this.recordButton) {
183
+ this.recordButton.textContent = '⏹ Stop recording'
184
+ this.recordButton.style.opacity = '1'
185
+ }
186
+ if (this.recordStatus) {
187
+ this.recordStatus.style.display = 'block'
188
+ this.recordStatus.textContent = `Recording at ${recordWidth}x${recordHeight} (${(bitrate / 1000000).toFixed(0)} Mbps): 0.0s / ${this.recordingDuration}s`
189
+ }
190
+ } catch (error) {
191
+ console.error('Failed to start recording:', error)
192
+ if (this.originalCanvasSize) {
193
+ this.renderer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height, false)
194
+ this.composer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
195
+ this.bloomPass.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
196
+ this.originalCanvasSize = null
197
+ this.onResize()
198
+ }
199
+ if (this.recordStatus) {
200
+ this.recordStatus.style.display = 'block'
201
+ this.recordStatus.textContent = 'Recording not supported in this browser.'
202
+ }
203
+ }
204
+ }
205
+
206
+ private stop() {
207
+ if (!this.isRecording || !this.mediaRecorder) return
208
+
209
+ this.isRecording = false
210
+ this.mediaRecorder.stop()
211
+ this.mediaRecorder = null
212
+
213
+ if (this.recordButton) {
214
+ this.recordButton.textContent = '▶ Start recording'
215
+ this.recordButton.style.opacity = '1'
216
+ }
217
+ }
218
+
219
+ private updateStatus(elapsed: number) {
220
+ if (this.recordStatus) {
221
+ const current = elapsed.toFixed(1)
222
+ const canvas = this.renderer.domElement
223
+ const bitrate = Math.min(25000000, Math.max(8000000, canvas.width * canvas.height * 4))
224
+ this.recordStatus.textContent = `Recording at ${canvas.width}x${canvas.height} (${(bitrate / 1000000).toFixed(0)} Mbps): ${current}s / ${this.recordingDuration}s`
225
+ }
226
+ }
227
+ }
package/src/style.css CHANGED
@@ -90,6 +90,52 @@ canvas {
90
90
  color: #9fb7ff;
91
91
  }
92
92
 
93
+ .controls__header {
94
+ display: flex;
95
+ justify-content: space-between;
96
+ align-items: baseline;
97
+ margin-bottom: 10px;
98
+ }
99
+
100
+ .controls__toggle {
101
+ background: linear-gradient(120deg, rgba(120, 180, 255, 0.12), rgba(80, 120, 200, 0.08));
102
+ border: 1px solid rgba(120, 180, 255, 0.3);
103
+ color: #9fb7ff;
104
+ font-size: 14px;
105
+ font-weight: 600;
106
+ cursor: pointer;
107
+ padding: 6px 10px;
108
+ margin: 0 0 0 8px;
109
+ line-height: 1;
110
+ display: inline-block;
111
+ border-radius: 6px;
112
+ transition: all 120ms ease;
113
+ }
114
+
115
+ .controls__toggle:hover {
116
+ color: #dfe8ff;
117
+ border-color: rgba(140, 200, 255, 0.6);
118
+ box-shadow: 0 0 12px rgba(120, 180, 255, 0.2);
119
+ background: linear-gradient(120deg, rgba(120, 180, 255, 0.18), rgba(80, 120, 200, 0.12));
120
+ }
121
+
122
+ .controls__toggle:active {
123
+ transform: translateY(1px);
124
+ }
125
+
126
+ .controls--collapsed {
127
+ width: auto !important;
128
+ padding: 10px 12px !important;
129
+ }
130
+
131
+ .controls--collapsed .controls__header {
132
+ margin-bottom: 0;
133
+ }
134
+
135
+ .controls--collapsed> :not(.controls__header) {
136
+ display: none !important;
137
+ }
138
+
93
139
  .controls__row {
94
140
  display: grid;
95
141
  grid-template-columns: 1fr 1fr auto;
@@ -188,4 +234,33 @@ canvas {
188
234
 
189
235
  .controls__advanced {
190
236
  margin-top: 8px;
237
+ }
238
+
239
+ .drop-overlay {
240
+ position: fixed;
241
+ display: flex;
242
+ align-items: center;
243
+ justify-content: center;
244
+ color: #d7e2ff;
245
+ font-size: 18px;
246
+ letter-spacing: 0.02em;
247
+ z-index: 20;
248
+ pointer-events: none;
249
+ backdrop-filter: blur(4px);
250
+ font-weight: 600;
251
+ inset: 0;
252
+ width: 50%;
253
+ background: rgba(80, 140, 255, 0.2);
254
+ }
255
+
256
+ .drop-overlay--left {
257
+ left: 0;
258
+ right: auto;
259
+ border-right: 3px solid rgba(120, 180, 255, 0.4);
260
+ }
261
+
262
+ .drop-overlay--right {
263
+ left: auto;
264
+ right: 0;
265
+ border-left: 3px solid rgba(120, 180, 255, 0.4);
191
266
  }
@@ -1 +0,0 @@
1
- @import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap";:root{color:#e8f0ff;background-color:#02040a;font-family:Space Grotesk,Segoe UI,system-ui,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 20% 20%,#508cff1f,#0000 35%),radial-gradient(circle at 80% 10%,#b464ff1f,#0000 30%),radial-gradient(circle at 50% 80%,#3cc8b424,#0000 32%),#02040a;min-height:100vh;margin:0;overflow:hidden}#app{position:fixed;inset:0;overflow:hidden}canvas{filter:saturate(1.05);width:100%;height:100%;display:block}.hud{color:#dfe8ff;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;mix-blend-mode:screen;text-shadow:0 0 12px #6eaaff4d;position:absolute;top:18px;left:18px}.hud .title{font-size:16px;font-weight:600}.hud .subtitle{opacity:.7;letter-spacing:.04em;margin-top:4px;font-size:12px;font-weight:400}.hud .status{opacity:.8;letter-spacing:.03em;color:#9fb7ff;margin-top:6px;font-size:11px;font-weight:400}.controls{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#dfe8ff;pointer-events:auto;background:#060a16b3;border:1px solid #78b4ff40;border-radius:12px;width:240px;padding:14px 14px 10px;position:absolute;top:18px;right:18px;box-shadow:0 12px 30px #00000059,0 0 24px #64a0ff1f}.controls__title{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;margin-bottom:10px;font-size:12px}.controls__row{color:#e8f0ff;grid-template-columns:1fr 1fr auto;align-items:center;gap:8px;margin-bottom:8px;font-size:12px;display:grid}.controls__row input[type=range]{accent-color:#7cc4ff;width:100%}.controls__value{font-variant-numeric:tabular-nums;color:#9fb7ff;text-align:right;min-width:44px}.controls__button{color:#dfe8ff;letter-spacing:.04em;cursor:pointer;background:linear-gradient(120deg,#78b4ff29,#5078c81a);border:1px solid #78b4ff59;border-radius:10px;width:100%;margin-top:6px;padding:8px 10px;font-size:12px;transition:border-color .12s,transform .12s,box-shadow .2s}.controls__button:hover{border-color:#8cc8ffb3;box-shadow:0 0 18px #78b4ff40}.controls__button:active{transform:translateY(1px)}.controls__button--record{background:linear-gradient(120deg,#ff507838,#c8507824);border-color:#ff789666}.controls__button--record:hover{border-color:#ff8caacc;box-shadow:0 0 18px #ff78964d}.controls__section{border-top:1px solid #78b4ff26;margin-top:12px;padding-top:12px}.controls__subtitle{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;opacity:.85;margin-bottom:8px;font-size:11px}.controls__status{color:#b3d4ff;text-align:center;background:#64b4ff1a;border:1px solid #78b4ff33;border-radius:6px;margin-top:8px;padding:6px 8px;font-size:11px}.controls__select{color:#dfe8ff;cursor:pointer;background:#141e3280;border:1px solid #78b4ff40;border-radius:6px;width:100%;padding:4px 6px;font-size:11px}.controls__select:hover{border-color:#8cc8ff80}.controls__advanced{margin-top:8px}