From 0bf5a176c9765472f33bd55ba4bb41a9458f70f1 Mon Sep 17 00:00:00 2001 From: Saiful Islam Date: Sun, 6 Sep 2026 12:25:57 +0600 Subject: [PATCH] feat(demo): persist the custom theme and let people copy it as code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the theme editor from #4. The editor's state lived inside initCustomTheme, so every visit rebuilt the grid from a fresh random theme — switch to a preset, come back, and whatever you had dialed in was gone. The theme and its input registry now sit at module scope, randomized once per page load, and the DOM is built on first open only. Reopening shows the theme you left. The editor also had no way to get a theme back out, which is the main reason to have one in a library demo. It now renders a live defineTheme() snippet under the controls, with a Copy button matching the install block. The snippet is generated as [text, class] segments, so the highlighted markup and the copied text come from one source instead of two that can drift. Widened the number boxes to 115px — 0.00402 is the longest value the sliders can produce and it clipped behind the spinner at 100px. --- demo/index.html | 285 ++++++++++++++++++++++++++++++------------------ 1 file changed, 176 insertions(+), 109 deletions(-) diff --git a/demo/index.html b/demo/index.html index 8d6e8c1..840bcad 100644 --- a/demo/index.html +++ b/demo/index.html @@ -328,8 +328,27 @@ border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - padding: 0 1rem; - width: 100px; + padding: 0 0.75rem; + /* Wide enough for the longest value the sliders can produce (0.00402) + plus the number spinner. */ + width: 115px; + } + } + + .theme-export { + position: relative; + margin-top: 1.5rem; + + & .code-block { + margin: 0; + white-space: pre; + } + + & .copy-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: var(--bg); } } @@ -394,6 +413,10 @@

Themes

@@ -675,131 +698,175 @@

Setup

} const randomOf = values => values[Math.floor(Math.random() * values.length)] - function initCustomTheme() { - const customThemeData = { - 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') + // Randomized once per page load, then owned by the editor — switching to a + // preset and back reopens the same theme rather than rolling a new one. + const customThemeData = { + 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: [], + filterFreq: [], + filterQ: [], + attack: [], + decay: [], + brightness: [], + } + + // The snippet is built as [text, class] segments so the copied text and the + // highlighted markup are generated from one source and can't drift apart. + function themeSnippetSegments() { + const str = value => [`'${value}'`, 'str'] + const segments = [ + ['import ', 'kw'], ['{ tiks, defineTheme }'], [' from ', 'kw'], str('@rexa-developer/tiks'), ['\n\n'], + ['const ', 'kw'], ['myTheme = '], ['defineTheme', 'fn'], ['({\n'] + ] + for (const [property, value] of Object.entries(customThemeData)) { + segments.push([` ${property}: `], typeof value === 'string' ? str(value) : [String(value)], [',\n']) } - const themeInputs = { - baseFreq: [], - filterFreq: [], - filterQ: [], - attack: [], - decay: [], - brightness: [], + segments.push(['})\n\n'], ['tiks', 'fn'], ['.'], ['setTheme', 'fn'], ['(myTheme)']) + return segments + } + + const themeSnippetText = () => themeSnippetSegments().map(([text]) => text).join('') + + function renderThemeSnippet() { + document.getElementById('themeCode').replaceChildren(...themeSnippetSegments().map(([text, className]) => { + if (!className) return document.createTextNode(text) + const span = document.createElement('span') + span.className = className + span.textContent = text + return span + })) + } + + // `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 } - // `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) + for (const input of themeInputs[property] ?? []) { + if (input !== source) input.value = customThemeData[property] } + renderThemeSnippet() + 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.id = `theme-${property}` - input.min = min - input.max = max - input.step = step - input.value = customThemeData[property] - input.addEventListener('input', () => { - updateTheme(property, input.value, input) - }) + 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 display = document.createElement('input') - display.type = 'number' - display.min = min - display.max = max - display.step = step - display.setAttribute('aria-label', themeTranslations[property]) + const input = document.createElement('input') + input.type = 'range' + input.id = `theme-${property}` + input.min = min + input.max = max + input.step = step + input.value = customThemeData[property] + input.addEventListener('input', () => { + updateTheme(property, input.value, input) + }) + + const display = document.createElement('input') + 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, 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] - display.addEventListener('input', () => { - 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) + themeInputs[property].push(input, display) - return [label, input, display] - } - 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() + return [label, input, display] + } + + 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) - container.append(button) + 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() - return [label, container] + container.append(button) } - const editorGrid = document.querySelector('.editor-grid') - editorGrid.innerText = '' + return [label, container] + } - editorGrid.append(...createButtonList('noiseColor', ['white', 'pink'])) - editorGrid.append(...createButtonList('oscType', ['sine', 'triangle', 'square', 'sawtooth'])) - for (const property in themeInputs) editorGrid.append(...createEditorSlider(property)) + let editorBuilt = false + function initCustomTheme() { + if (!editorBuilt) { + const editorGrid = document.querySelector('.editor-grid') + editorGrid.append(...createButtonList('noiseColor', ['white', 'pink'])) + editorGrid.append(...createButtonList('oscType', ['sine', 'triangle', 'square', 'sawtooth'])) + for (const property in themeInputs) editorGrid.append(...createEditorSlider(property)) + renderThemeSnippet() + editorBuilt = true + } document.getElementById('theme-editor').hidden = false tiks.setTheme(customThemeData) } + + document.getElementById('copyThemeBtn').addEventListener('click', () => { + navigator.clipboard.writeText(themeSnippetText()) + const btn = document.getElementById('copyThemeBtn') + btn.textContent = 'Copied!' + tiks.success() + triggerWave(0.8, 6) + setTimeout(() => btn.textContent = 'Copy', 1500) + })