Files
yellowjacket/frontend/src/components/audio-player/controls/player-controls.ts
T
logan 49b1194333 fix(a11y): give the semantic colours a ramp, and every fill a foreground
The contrast pass found two things larger than itself, both recorded as
not-fixed. This is them.

The semantic colours were 'fixed across themes', and one fixed colour
cannot clear 4.5:1 against both a near-black and a near-white surface:
--yj-error measured 2.55:1 on dark's elevated, --yj-info 2.31:1, and
success and warning failed on dark and light both. They are split by the
question they answer. A *fill* is 'what colour is a danger button' --
red in every theme, unchanged -- and a *text* colour is 'what colour is
the word failed on this background', which is now per ramp.

Every fill also carries a computed foreground. White on the default
accent is 1.43:1, and the accent is a colour picker, so no fixed answer
survives it: --yj-accent-fg and the four semantic -fg values are derived
(white if white clears, else black), which keeps a red danger button
white and flips a green or amber one to black. Two accent buttons took
their foreground from --yj-bg-base, which inverts with the ramp -- that
is exactly the white-on-yellow 'Apply (A)' the light theme showed.

Accent used as text gets the same treatment through accentTextOn(),
which mixes along the hue until it clears the ramp's surface and stops.
On both dark ramps it returns the accent unchanged, so the dark themes
are visually untouched by that half.

Measured across three ramps and twelve views: 2237 nodes, 0 failing,
against 110 on dark and 50 on light before. Borders, outlines and
shadows were explicitly kept on the fill token -- a border is not text,
and the first pass of the rewrite moved 30 of them by accident.
2026-08-13 01:05:37 -04:00

151 lines
3.9 KiB
TypeScript

import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
import { queueStore } from '@store/queue-store';
import type { RepeatMode } from '@store/queue-store';
import { designTokens } from '../../../styles/tokens.css';
@customElement('player-controls')
export class PlayerControls extends LitElement {
private player = new PlayerController(this);
private unsubscribeQueue?: () => void;
@state() private shuffleMode = false;
@state() private repeatMode: RepeatMode = 'off';
override connectedCallback(): void {
super.connectedCallback();
const s = queueStore.getState();
this.shuffleMode = s.shuffleMode;
this.repeatMode = s.repeatMode;
this.unsubscribeQueue = queueStore.subscribe(() => {
const qs = queueStore.getState();
if (
qs.shuffleMode !== this.shuffleMode ||
qs.repeatMode !== this.repeatMode
) {
this.shuffleMode = qs.shuffleMode;
this.repeatMode = qs.repeatMode;
}
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribeQueue?.();
}
static override styles = [designTokens, css`
#player-control-buttons {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
}
button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: center;
}
button:hover {
color: var(--yj-accent-text, #ffd43b);
}
.active {
color: var(--yj-accent-text, #ffd43b);
}
.repeat-one {
position: relative;
}
.repeat-one::after {
content: '1';
font-size: 8px;
font-weight: bold;
position: absolute;
bottom: 2px;
right: 2px;
}
`];
private handlePlayClick = () => {
queueStore.play();
};
private handlePauseClick = () => {
this.player.pause();
};
private handleNextClick = () => {
queueStore.next();
};
private handlePreviousClick = () => {
queueStore.previous();
};
private handleShuffleClick = () => {
queueStore.toggleShuffle();
};
private handleRepeatClick = () => {
queueStore.cycleRepeat();
};
override render() {
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
const shuffleClass = this.shuffleMode ? 'active' : '';
const repeatMode = this.repeatMode;
const repeatClasses = [
repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '',
].filter(Boolean).join(' ');
return html`
<div id="player-control-buttons">
<button
class=${shuffleClass}
aria-label="Shuffle"
aria-pressed=${this.shuffleMode}
@click=${this.handleShuffleClick}
>
<wa-icon name="shuffle"></wa-icon>
</button>
<button aria-label="Previous track" @click=${this.handlePreviousClick}>
<wa-icon name="backward-step"></wa-icon>
</button>
<button aria-label=${this.player.isPlaying ? 'Pause' : 'Play'} @click="${playOrPauseHandler}">
<wa-icon name=${playOrPauseIcon}></wa-icon>
</button>
<button aria-label="Next track" @click=${this.handleNextClick}>
<wa-icon name="forward-step"></wa-icon>
</button>
<button
class=${repeatClasses}
aria-label=${`Repeat: ${repeatMode}`}
aria-pressed=${repeatMode !== 'off'}
@click=${this.handleRepeatClick}
>
<wa-icon name="repeat"></wa-icon>
</button>
</div>
`;
}
}