Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
## 2025-05-15 - [Inclusive Controls for Screen Readers]
**Learning:** Hidden range inputs used for rotary knobs and sliders require explicit `aria-label` attributes to be useful for assistive technologies, as the visual label might not be programmatically associated with the input.
**Action:** Ensure all `input[type="range"]` elements, especially those styled to look like knobs, have descriptive `aria-label` or `aria-labelledby`.

## 2025-05-15 - [ARIA Roles Require Focus Management]
**Learning:** Assigning ARIA roles like `switch`, `button`, or `slider` to non-native elements is insufficient for accessibility if not paired with `tabIndex` and keyboard event listeners (Space/Enter for buttons/switches, Arrow keys for sliders). Without these, the UI remains unusable for keyboard-only users despite being "labeled" for screen readers.
**Action:** Always pair interactive ARIA roles with `tabIndex={0}` and appropriate `onKeyDown` handlers to ensure full keyboard operability.
16 changes: 14 additions & 2 deletions src/components/HUD/BassScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ export const BassScreen: React.FC = () => {
{/* Harmony Section */}
<div className="bass-harmony-section">
<div className="harmony-controls">
<label>Root</label>
<label htmlFor="bass-root">Root</label>
<select
id="bass-root"
value={harmony.root}
onChange={(e) => harmony.setRoot(e.target.value)}
className="studio-select"
Expand All @@ -136,8 +137,9 @@ export const BassScreen: React.FC = () => {
</select>
</div>
<div className="harmony-controls">
<label>Scale</label>
<label htmlFor="bass-scale">Scale</label>
<select
id="bass-scale"
value={harmony.scale}
onChange={(e) => harmony.setScale(e.target.value as any)}
className="studio-select"
Expand All @@ -155,6 +157,16 @@ export const BassScreen: React.FC = () => {
key={i}
className={`pattern-step ${step?.active ? 'active' : ''}`}
onClick={() => toggleStepParam(i, 'active')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleStepParam(i, 'active')
}
}}
role="switch"
tabIndex={0}
aria-checked={step?.active}
aria-label={`Step ${i + 1}`}
/>
))}
</div>
Expand Down
26 changes: 24 additions & 2 deletions src/components/HUD/DrumsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export const DrumsScreen: React.FC = () => {
key={d.id}
className={`drum-pad ${isSelected ? 'selected' : ''}`}
onClick={() => setSelectedDrum(d.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedDrum(d.id)
}
}}
role="button"
tabIndex={0}
aria-label={d.label}
aria-selected={isSelected}
>
<div
className="drum-pad-surface"
Expand Down Expand Up @@ -119,16 +129,18 @@ export const DrumsScreen: React.FC = () => {
<div className="sequencer-header">
<span>EDITING: {selectedDrum.toUpperCase()}</span>
<div className="sequencer-params">
<label>STEPS</label>
<label htmlFor="drum-steps">STEPS</label>
<input
id="drum-steps"
type="number"
min="1"
max="32"
value={(store as any)[selectedDrum]?.steps || 16}
onChange={(e) => handleParamChange(selectedDrum, 'steps', parseInt(e.target.value))}
/>
<label>PULSES</label>
<label htmlFor="drum-pulses">PULSES</label>
<input
id="drum-pulses"
type="number"
min="0"
max={(store as any)[selectedDrum]?.steps || 16}
Expand All @@ -146,6 +158,16 @@ export const DrumsScreen: React.FC = () => {
key={i}
className={`step-button ${active ? 'active' : ''}`}
onClick={() => store.toggleStep(selectedDrum as any, i)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
store.toggleStep(selectedDrum as any, i)
}
}}
role="switch"
tabIndex={0}
aria-checked={active}
aria-label={`Step ${i + 1}`}
/>
)
})}
Expand Down
43 changes: 43 additions & 0 deletions src/components/Knob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,41 @@ export function Knob({ label, value, min = 0, max = 1, step = 0.01, defaultValue
e.currentTarget.releasePointerCapture(e.pointerId)
}

const handleKeyDown = (e: React.KeyboardEvent) => {
let newValue = value
const range = max - min
const smallStep = step || range / 100
const largeStep = range / 10

if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
newValue = Math.min(max, value + smallStep)
} else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
newValue = Math.max(min, value - smallStep)
} else if (e.key === 'PageUp') {
newValue = Math.min(max, value + largeStep)
} else if (e.key === 'PageDown') {
newValue = Math.max(min, value - largeStep)
} else if (e.key === 'Home') {
newValue = min
} else if (e.key === 'End') {
newValue = max
} else {
return
}

e.preventDefault()
const steppedValue = Math.round(newValue / smallStep) * smallStep
// Use a more dynamic precision based on the step value
const precision = step < 0.01 ? 3 : 2
const finalValue = Number(steppedValue.toFixed(precision))
if (finalValue !== value) {
onChange(finalValue)
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.selectionChanged()
}
}
}

const handleDoubleClick = () => {
try {
const resetValue = defaultValue !== undefined ? defaultValue : (max + min) / 2
Expand Down Expand Up @@ -100,6 +135,14 @@ export function Knob({ label, value, min = 0, max = 1, step = 0.01, defaultValue
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={step >= 1 ? Math.round(value).toString() : value.toFixed(2)}
style={{
width: size,
height: size,
Expand Down