From b98a5f3d81bf4f1733cc1b371aad62c3e3867d65 Mon Sep 17 00:00:00 2001 From: kyfex-uwu Date: Sat, 5 Sep 2026 13:04:09 -0600 Subject: [PATCH 1/2] added a custom theme editor --- demo/index.html | 145 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/demo/index.html b/demo/index.html index c73cd2d..5450f4a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -308,6 +308,31 @@ accent-color: var(--accent); } + /* Editor */ + + .editor-grid{ + display: grid; + grid-template-columns: min-content 1fr min-content; + gap: 0.75rem; + + & label { + text-wrap: nowrap; + } + + & .wide { + grid-column: span 2; + } + + & input:not([type=range]) { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + padding: 0 1rem; + width: 100px; + } + } + /* Footer */ footer { text-align: center; @@ -362,6 +387,12 @@

Themes

+ + + + + @@ -574,7 +605,10 @@

Setup

btn.addEventListener('click', () => { document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active')) btn.classList.add('active') - tiks.setTheme(btn.dataset.theme) + if (btn.dataset.theme !== "custom") { + tiks.setTheme(btn.dataset.theme) + document.getElementById("theme-editor").hidden = true + } else initCustomTheme() tiks.click() triggerWave(0.5, 8) }) @@ -598,6 +632,115 @@

Setup

triggerWave(0.8, 6) setTimeout(() => btn.textContent = 'Copy', 1500) }) + + // Custom theme + const themeTranslations = { + baseFreq: 'Base Frequency', + noiseColor: 'Noise Color', + oscType: 'Oscillator Type', + filterFreq: 'Filter Frequency', + filterQ: 'Filter Resonance', + attack: 'Attack Time', + decay: 'Decay Time', + brightness: 'Brightness', + + white: 'White', + pink: 'Pink', + sine: 'Sine', + triangle: 'Triangle', + square: 'Square', + sawtooth: 'Sawtooth' + } + const propertyRanges = { + baseFreq: [150, 500, 1], + filterFreq: [1500, 6000, 1], + filterQ: [1, 5, 0.01], + attack: [0.00001, 0.005, 0.00001], + decay: [0.3, 1.5, 0.01], + brightness: [1500, 5000, 1] + } + function initCustomTheme() { + const customThemeData = { + baseFreq: Math.floor(Math.random() * 200 + 400), + noiseColor: (['white', 'pink'])[Math.floor(Math.random() * 2)], + oscType: (['sine', 'triangle', 'square', 'sawtooth'])[Math.floor(Math.random() * 4)], + filterFreq: Math.floor(Math.random() * 5000 + 2000), + filterQ: Math.floor(Math.random() * 400 + 200) * 0.01, + attack: Math.floor(Math.random() * 400) * 0.00001, + decay: Math.floor(Math.random() * 100 + 50) * 0.01, + brightness: Math.floor(Math.random() * 4000 + 2000) + } + const themeInputs = { + baseFreq: [], + filterFreq: [], + filterQ: [], + attack: [], + decay: [], + brightness: [], + } + function updateTheme(property, value) { + customThemeData[property] = parseFloat(value) || value; + for (const listener of themeInputs[property] ?? []) listener.value = customThemeData[property] + tiks.setTheme(customThemeData) + } + + function createEditorSlider(property) { + const label = document.createElement('label') + label.innerText = themeTranslations[property] + + const input = document.createElement('input') + input.type = 'range' + input.min = propertyRanges[property][0] + input.max = propertyRanges[property][1] + input.step = propertyRanges[property][2] + input.value = customThemeData[property] + input.addEventListener('input', () => { + updateTheme(property, input.value) + }) + + const display = document.createElement('input') + display.value = customThemeData[property].toString().replaceAll(/0{7,}\d/g,"") + display.addEventListener('input', () => { + updateTheme(property, display.value) + }) + + themeInputs[property].push(input, display) + + return [label, input, display] + } + function createButtonList(property, values){ + const label = document.createElement('label') + label.innerText = themeTranslations[property] + + const container = document.createElement("span") + container.classList = 'theme-switcher wide' + + for (const value of values) { + const button = document.createElement("button") + button.classList = 'theme-btn' + (value === customThemeData[property] ? ' active' : '') + button.innerText = themeTranslations[value] + button.addEventListener('click', () => { + for (const child of container.childNodes) child.classList = 'theme-btn' + button.classList = 'theme-btn active' + updateTheme(property, value) + }) + + container.append(button) + } + + return [label, container] + } + + const editorGrid = document.querySelector('.editor-grid') + editorGrid.innerText = '' + + editorGrid.append(...createButtonList('noiseColor', ['white', 'pink'])) + editorGrid.append(...createButtonList('oscType', ['sine', 'triangle', 'square', 'sawtooth'])) + for (const property in themeInputs) editorGrid.append(...createEditorSlider(property)) + + document.getElementById('theme-editor').hidden = false + tiks.setTheme(customThemeData) + } From e4e1d62d4c2c07b6eef4ccf2681e3b8961ab9bc4 Mon Sep 17 00:00:00 2001 From: Saiful Islam Date: Sun, 6 Sep 2026 12:14:56 +0600 Subject: [PATCH 2/2] fix(demo): keep the theme editor's controls in sync with the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The random starting values were written as their own set of literals, separate from propertyRanges, and the two drifted apart: five of the six sliders could open outside their own min/max. The browser clamps the thumb but not the theme, so the slider showed one value while the sound used another — 10 of 12 fresh opens had at least one desynced control. Seed from propertyRanges instead, snapped to the slider's step, so the two can't disagree. Also stop feeding non-numbers to Web Audio. `parseFloat(value) || value` put "" or "abc" straight into the theme when a number box was cleared or given text, and the next sound threw out of the generator: baseFreq "" -> RangeError: exponentialRampToValueAtTime target (0) attack "abc" -> TypeError: linearRampToValueAtTime non-finite The boxes are now type=number and updateTheme ignores anything non-finite, normalizing on blur. Edits no longer write back to the control being edited, so typing a multi-digit value isn't clobbered. Smaller fixes alongside: give the theme a `name` (TiksTheme requires it, and the demo is the example people copy); associate every label with its control; scope the preset handler to `.theme-btn[data-theme]` so it stops clearing the editor's own buttons. --- demo/index.html | 145 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 102 insertions(+), 43 deletions(-) diff --git a/demo/index.html b/demo/index.html index 5450f4a..8d6e8c1 100644 --- a/demo/index.html +++ b/demo/index.html @@ -315,8 +315,8 @@ grid-template-columns: min-content 1fr min-content; gap: 0.75rem; - & label { - text-wrap: nowrap; + & .field-label { + text-wrap: nowrap; } & .wide { @@ -601,13 +601,13 @@

Setup

}) // Theme switcher - document.querySelectorAll('.theme-btn').forEach(btn => { + document.querySelectorAll('.theme-btn[data-theme]').forEach(btn => { btn.addEventListener('click', () => { - document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active')) + document.querySelectorAll('.theme-btn[data-theme]').forEach(b => b.classList.remove('active')) btn.classList.add('active') - if (btn.dataset.theme !== "custom") { - tiks.setTheme(btn.dataset.theme) - document.getElementById("theme-editor").hidden = true + if (btn.dataset.theme !== 'custom') { + tiks.setTheme(btn.dataset.theme) + document.getElementById('theme-editor').hidden = true } else initCustomTheme() tiks.click() triggerWave(0.5, 8) @@ -659,16 +659,33 @@

Setup

decay: [0.3, 1.5, 0.01], brightness: [1500, 5000, 1] } + + // Snap to the slider's own step and clamp to its bounds, so the number box + // and the slider thumb can never disagree. toFixed kills the float noise + // that min + n * step introduces (0.00399 -> 0.0039900000000000005). + function snapToRange(value, [min, max, step]) { + const snapped = min + Math.round((value - min) / step) * step + const decimals = (String(step).split('.')[1] ?? '').length + return Number(Math.min(max, Math.max(min, snapped)).toFixed(decimals)) + } + + const randomInRange = property => { + const [min, max] = propertyRanges[property] + return snapToRange(min + Math.random() * (max - min), propertyRanges[property]) + } + const randomOf = values => values[Math.floor(Math.random() * values.length)] + function initCustomTheme() { const customThemeData = { - baseFreq: Math.floor(Math.random() * 200 + 400), - noiseColor: (['white', 'pink'])[Math.floor(Math.random() * 2)], - oscType: (['sine', 'triangle', 'square', 'sawtooth'])[Math.floor(Math.random() * 4)], - filterFreq: Math.floor(Math.random() * 5000 + 2000), - filterQ: Math.floor(Math.random() * 400 + 200) * 0.01, - attack: Math.floor(Math.random() * 400) * 0.00001, - decay: Math.floor(Math.random() * 100 + 50) * 0.01, - brightness: Math.floor(Math.random() * 4000 + 2000) + name: 'custom', + baseFreq: randomInRange('baseFreq'), + noiseColor: randomOf(['white', 'pink']), + oscType: randomOf(['sine', 'triangle', 'square', 'sawtooth']), + filterFreq: randomInRange('filterFreq'), + filterQ: randomInRange('filterQ'), + attack: randomInRange('attack'), + decay: randomInRange('decay'), + brightness: randomInRange('brightness') } const themeInputs = { baseFreq: [], @@ -678,57 +695,99 @@

Setup

decay: [], brightness: [], } - function updateTheme(property, value) { - customThemeData[property] = parseFloat(value) || value; - for (const listener of themeInputs[property] ?? []) listener.value = customThemeData[property] + // `source` is the control the edit came from — never write back to it, or + // typing in the number box fights the caret. + function updateTheme(property, value, source) { + const range = propertyRanges[property] + if (range) { + const parsed = parseFloat(value) + // Empty or non-numeric input: leave the theme alone. Letting "" or NaN + // through reaches Web Audio and throws on the next sound. + if (!Number.isFinite(parsed)) return + customThemeData[property] = snapToRange(parsed, range) + } else { + customThemeData[property] = value + } + for (const input of themeInputs[property] ?? []) { + if (input !== source) input.value = customThemeData[property] + } tiks.setTheme(customThemeData) } function createEditorSlider(property) { + const [min, max, step] = propertyRanges[property] + const label = document.createElement('label') + label.className = 'field-label' + label.htmlFor = `theme-${property}` label.innerText = themeTranslations[property] const input = document.createElement('input') input.type = 'range' - input.min = propertyRanges[property][0] - input.max = propertyRanges[property][1] - input.step = propertyRanges[property][2] + input.id = `theme-${property}` + input.min = min + input.max = max + input.step = step input.value = customThemeData[property] input.addEventListener('input', () => { - updateTheme(property, input.value) + updateTheme(property, input.value, input) }) const display = document.createElement('input') - display.value = customThemeData[property].toString().replaceAll(/0{7,}\d/g,"") + display.type = 'number' + display.min = min + display.max = max + display.step = step + display.setAttribute('aria-label', themeTranslations[property]) + display.value = customThemeData[property] display.addEventListener('input', () => { - updateTheme(property, display.value) + updateTheme(property, display.value, display) + }) + // Typing is left alone as it happens; on commit, show what actually + // landed — including snapping back if the box was cleared or out of range. + display.addEventListener('change', () => { + updateTheme(property, display.value, null) + display.value = customThemeData[property] }) themeInputs[property].push(input, display) return [label, input, display] } - function createButtonList(property, values){ - const label = document.createElement('label') - label.innerText = themeTranslations[property] - - const container = document.createElement("span") - container.classList = 'theme-switcher wide' - - for (const value of values) { - const button = document.createElement("button") - button.classList = 'theme-btn' + (value === customThemeData[property] ? ' active' : '') - button.innerText = themeTranslations[value] - button.addEventListener('click', () => { - for (const child of container.childNodes) child.classList = 'theme-btn' - button.classList = 'theme-btn active' - updateTheme(property, value) - }) - - container.append(button) + function createButtonList(property, values) { + const label = document.createElement('span') + label.className = 'field-label' + label.id = `theme-${property}-label` + label.innerText = themeTranslations[property] + + const container = document.createElement('span') + container.className = 'theme-switcher wide' + container.setAttribute('role', 'group') + container.setAttribute('aria-labelledby', label.id) + + for (const value of values) { + const button = document.createElement('button') + button.className = 'theme-btn' + button.setAttribute('aria-pressed', 'false') + button.innerText = themeTranslations[value] + const select = () => { + for (const sibling of container.children) { + sibling.className = 'theme-btn' + sibling.setAttribute('aria-pressed', 'false') + } + button.className = 'theme-btn active' + button.setAttribute('aria-pressed', 'true') } + button.addEventListener('click', () => { + select() + updateTheme(property, value) + }) + if (value === customThemeData[property]) select() + + container.append(button) + } - return [label, container] + return [label, container] } const editorGrid = document.querySelector('.editor-grid')