simplify more

This commit is contained in:
Andras Schmelczer 2026-05-20 21:37:30 +01:00
commit 2fe3c69963
40 changed files with 690 additions and 873 deletions

View file

@ -47,59 +47,6 @@ const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
const MIRROR_SEGMENT_STEP = 1;
const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
const ELEMENT_TAGS = {
div: 'div',
pre: 'pre',
} as const;
const ARIA_ATTRIBUTES = {
label: 'aria-label',
live: 'aria-live',
pressed: 'aria-pressed',
role: 'role',
valueNow: 'aria-valuenow',
valueText: 'aria-valuetext',
} as const;
const ARIA_LIVE_VALUES = {
assertive: 'assertive',
polite: 'polite',
} as const;
const ARIA_ROLES = {
alert: 'alert',
status: 'status',
} as const;
const CSS_CLASSES = {
active: 'active',
errorsContainer: 'errors-container',
isLoading: 'is-loading',
muted: 'muted',
preDrawing: 'pre-drawing',
} as const;
const CSS_VARIABLES = {
eraserControlScale: '--eraser-control-scale',
eraserProgress: '--eraser-progress',
gardenBackground: '--garden-background',
loadingProgress: '--loading-progress',
mirrorAngle: '--mirror-angle',
mirrorProgress: '--mirror-progress',
volumeProgress: '--volume-progress',
} as const;
const DOM_EVENTS = {
click: 'click',
focus: 'focus',
input: 'input',
keydown: 'keydown',
pointerDown: 'pointerdown',
pointerUp: 'pointerup',
touchEnd: 'touchend',
touchStart: 'touchstart',
} as const;
const APP_SELECTORS = {
aside: 'aside',
canvas: 'canvas',
@ -132,24 +79,6 @@ const APP_SELECTORS = {
volumeSlider: '.volume-slider',
} as const;
const AUDIO_LABELS = {
mutedPrefix: 'Muted',
mute: 'Mute audio',
unmute: 'Unmute audio',
volumeSuffix: 'volume',
} as const;
const LOADING_MESSAGES = {
fontsError: 'Could not load fonts.',
pianoSamplesError: 'Could not preload piano samples.',
ready: 'Ready',
} as const;
const VIBE_CHANGE_SOURCES = {
nextButton: 'next-button',
previousButton: 'previous-button',
} as const;
const clampEraserSize = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
@ -197,18 +126,18 @@ type RuntimeUiError = Parameters<
>[0];
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
const message = document.createElement(ELEMENT_TAGS.pre);
const message = document.createElement('pre');
message.className = error.severity;
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
message.setAttribute(
ARIA_ATTRIBUTES.role,
error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status
'role',
error.severity === Severity.ERROR ? 'alert' : 'status'
);
message.setAttribute(
ARIA_ATTRIBUTES.live,
'aria-live',
error.severity === Severity.ERROR
? ARIA_LIVE_VALUES.assertive
: ARIA_LIVE_VALUES.polite
? 'assertive'
: 'polite'
);
container.append(message);
@ -229,14 +158,14 @@ const renderStartupException = (exception: unknown) => {
const container =
existingContainer instanceof HTMLElement
? existingContainer
: document.createElement(ELEMENT_TAGS.div);
: document.createElement('div');
if (!(existingContainer instanceof HTMLElement)) {
container.className = CSS_CLASSES.errorsContainer;
container.className = 'errors-container';
document.body.append(container);
}
container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive);
container.setAttribute('aria-live', 'assertive');
renderRuntimeMessage(container, getRuntimeUiError(exception));
};
@ -298,10 +227,10 @@ const setLoadingStage = (label: string, ratio: number) => {
const percent = Math.round(clamp01(ratio) * 100);
elements.loadingStatus.textContent = label;
elements.loadingProgress.style.setProperty(
CSS_VARIABLES.loadingProgress,
'--loading-progress',
`${percent}%`
);
elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent));
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
};
let audioVolume = readInitialAudioVolume();
@ -323,32 +252,32 @@ const renderAudioUi = (game: GameLoop | null) => {
const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
const volumePercent = getAudioVolumePercent(audioVolume);
elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted));
elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
elements.soundButton.setAttribute(
ARIA_ATTRIBUTES.label,
isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute
'aria-label',
isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
);
elements.soundButton.title = isEffectivelyMuted
? AUDIO_LABELS.unmute
: AUDIO_LABELS.mute;
? 'Unmute audio'
: 'Mute audio';
elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
elements.volumeSlider.setAttribute(
ARIA_ATTRIBUTES.valueText,
'aria-valuetext',
isEffectivelyMuted
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%`
? `${'Muted'}, ${volumePercent}%`
: `${volumePercent}%`
);
elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
elements.volumeControl.title = isEffectivelyMuted
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`
: `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`;
? `${'Muted'}, ${volumePercent}% ${'volume'}`
: `${volumePercent}% ${'volume'}`;
elements.volumeControl.style.setProperty(
CSS_VARIABLES.volumeProgress,
'--volume-progress',
`${volumePercent}%`
);
@ -360,14 +289,14 @@ const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
swatch.classList.toggle(
CSS_CLASSES.active,
'active',
settings.selectedColorIndex === index && !isEraserActive
);
});
elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive);
elements.eraserSizeControl.classList.toggle('active', isEraserActive);
game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty(
CSS_VARIABLES.gardenBackground,
'--garden-background',
rgbColorToCss(activeVibe.backgroundColor)
);
};
@ -382,18 +311,18 @@ const renderEraserSizeUi = (game: GameLoop | null) => {
elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
elements.eraserSizeSlider.value = size.toString();
elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.valueText, `${size}px`);
elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
const ratio = getEraserSizeRatio(size);
const scale =
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
elements.eraserSizeControl.style.setProperty(
CSS_VARIABLES.eraserProgress,
'--eraser-progress',
`${ratio * 100}%`
);
elements.eraserSizeControl.style.setProperty(
CSS_VARIABLES.eraserControlScale,
'--eraser-control-scale',
scale.toFixed(3)
);
game?.updateEraserPreview();
@ -412,15 +341,15 @@ const renderMirrorSegmentUi = () => {
const label = formatMirrorSegmentCount(count);
const ratio = getMirrorSegmentRatio(count);
elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label);
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
elements.mirrorSegmentControl.title = label;
elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1);
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
elements.mirrorSegmentControl.style.setProperty(
CSS_VARIABLES.mirrorProgress,
'--mirror-progress',
`${ratio * 100}%`
);
elements.mirrorSegmentControl.style.setProperty(
CSS_VARIABLES.mirrorAngle,
'--mirror-angle',
`${(360 / count).toFixed(3)}deg`
);
};
@ -437,13 +366,13 @@ const main = async () => {
elements = queryAppElements();
elements.errorContainer.setAttribute(
ARIA_ATTRIBUTES.live,
ARIA_LIVE_VALUES.assertive
'aria-live',
'assertive'
);
ErrorHandler.addOnErrorListener((error) => {
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
document.body.classList.remove(CSS_CLASSES.isLoading);
document.body.classList.remove('is-loading');
game?.destroy();
shouldStop = true;
}
@ -488,31 +417,31 @@ const main = async () => {
game?.startAudio(true);
};
window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, {
window.addEventListener('touchstart', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, {
window.addEventListener('pointerdown', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, {
window.addEventListener('touchend', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, {
window.addEventListener('pointerup', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, {
window.addEventListener('click', startAudioFromUserGesture, {
capture: true,
});
window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, {
window.addEventListener('keydown', startAudioFromUserGesture, {
capture: true,
});
elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy());
elements.soundButton.addEventListener(DOM_EVENTS.click, () => {
elements.restartButton.addEventListener('click', () => game?.destroy());
elements.soundButton.addEventListener('click', () => {
const shouldUnmute = isAudioMuted || audioVolume <= 0;
if (shouldUnmute && audioVolume <= 0) {
audioVolume = DEFAULT_AUDIO_VOLUME;
@ -524,7 +453,7 @@ const main = async () => {
game?.startAudio(true);
}
});
elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => {
elements.volumeSlider.addEventListener('input', () => {
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
isAudioMuted = audioVolume <= 0;
persistAudioUiState();
@ -550,16 +479,16 @@ const main = async () => {
game?.playVibeChangeAudio(true);
};
elements.previousVibe.addEventListener(DOM_EVENTS.click, () =>
selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton)
elements.previousVibe.addEventListener('click', () =>
selectRelativeVibe(-1, 'previous-button')
);
elements.nextVibe.addEventListener(DOM_EVENTS.click, () =>
selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton)
elements.nextVibe.addEventListener('click', () =>
selectRelativeVibe(1, 'next-button')
);
elements.swatches.forEach((swatch, index) => {
swatch.addEventListener(DOM_EVENTS.click, () => {
swatch.addEventListener('click', () => {
settings.selectedColorIndex = index;
isEraserActive = false;
renderPaletteUi(game);
@ -572,11 +501,11 @@ const main = async () => {
renderPaletteUi(game);
};
elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser);
elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser);
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser);
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
elements.eraserSizeControl.addEventListener('click', activateEraser);
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => {
elements.eraserSizeSlider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
isEraserActive = true;
renderEraserSizeUi(game);
@ -584,7 +513,7 @@ const main = async () => {
configPane?.refresh();
});
elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => {
elements.mirrorSegmentSlider.addEventListener('input', () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount(
Number(elements.mirrorSegmentSlider.value)
);
@ -594,7 +523,7 @@ const main = async () => {
configPane?.refresh();
});
elements.export4k.addEventListener(DOM_EVENTS.click, async () => {
elements.export4k.addEventListener('click', async () => {
if (!game || elements.export4k.disabled) {
return;
}
@ -620,7 +549,7 @@ const main = async () => {
// the AudioContext on iOS, and gates the intro.
const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: LOADING_MESSAGES.fontsError,
fallbackMessage: 'Could not load fonts.',
severity: Severity.WARNING,
});
});
@ -633,12 +562,12 @@ const main = async () => {
}).then(
() => {
isPreloadComplete = true;
setLoadingStage(LOADING_MESSAGES.ready, 1);
setLoadingStage('Ready', 1);
},
(error: unknown) => {
isPreloadComplete = true;
ErrorHandler.addException(error, {
fallbackMessage: LOADING_MESSAGES.pianoSamplesError,
fallbackMessage: 'Could not preload piano samples.',
severity: Severity.WARNING,
});
}
@ -651,7 +580,6 @@ const main = async () => {
game?.onVibeChanged();
syncRuntimeUi();
},
onOpenChange: () => undefined,
onRuntimeChange: syncRuntimeUi,
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
@ -680,14 +608,14 @@ const main = async () => {
elements.startButton.disabled = false;
await new Promise<void>((resolve) => {
const onClick = () => {
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
elements.startButton.removeEventListener('click', onClick);
hasStarted = true;
game?.startAudio(true);
trackStart();
elements.splash.hidden = true;
resolve();
};
elements.startButton.addEventListener(DOM_EVENTS.click, onClick);
elements.startButton.addEventListener('click', onClick);
});
if (!isPreloadComplete) {
@ -698,16 +626,16 @@ const main = async () => {
}
// Keep the dev stats overlay hidden until the user actually starts drawing.
document.body.classList.add(CSS_CLASSES.preDrawing);
document.body.classList.add('pre-drawing');
elements.canvas.addEventListener(
DOM_EVENTS.pointerDown,
() => document.body.classList.remove(CSS_CLASSES.preDrawing),
'pointerdown',
() => document.body.classList.remove('pre-drawing'),
{ once: true }
);
requestAnimationFrame(() =>
requestAnimationFrame(() =>
document.body.classList.remove(CSS_CLASSES.isLoading)
document.body.classList.remove('is-loading')
)
);
}
@ -715,7 +643,7 @@ const main = async () => {
await game.start();
}
} catch (e) {
document.body.classList.remove(CSS_CLASSES.isLoading);
document.body.classList.remove('is-loading');
if (hasRuntimeErrorListener) {
ErrorHandler.addException(e);
} else {