Forms and media
Every input needs a programmatically associated label, and every non-text element needs a text alternative — WCAG 1.1.1 and 3.3.2, covered properly by WebAIM's forms guide if you want the general version.
This app is unusual on both counts. It has eight labelled inputs in the entire components/
tree, and its primary content is a live camera image that no alt text can describe. Both of those
change the advice.
Labels: MUI does it, hand-rolled markup mostly does too
If you use MUI's TextField, pass label and the association is done for you —
components/molecules/MapButtons/LayersMenu/LayersMenu.tsx does this with
label="Search Layers", and gets a <label for> plus a generated id at no cost.
For hand-rolled inputs, components/molecules/Notifications/AlertMessage/AlertMessage.tsx is the
reference:
<label htmlFor="alert-lat" className={styles.coordInputLabel}>Latitude</label>
<input id="alert-lat" type="text" value={latInput}
placeholder="e.g. 37.7749"
onChange={e => setLatInput(e.target.value)}
onBlur={() => applyCoords(latInput, lngInput)} />
Note the placeholder is an example value, not the label — that is the right division. A placeholder disappears the moment someone types, so it can never be the only name a field has.
The gap: errors nobody hears
AlertMessage labels its fields correctly and then drops the other half of the contract:
{
coordError && <p className={styles.coordError}>{coordError}</p>;
}
The message is a bare paragraph. It is not referenced by aria-describedby, the inputs carry no
aria-invalid, and there is no role="alert", so when a coordinate fails to parse a sighted user
sees red text and a screen-reader user hears nothing at all — the field just silently refuses
to take the value. This is the single most common form defect anywhere, and it is here.
The fix is three attributes:
<input id="alert-lat" aria-invalid={Boolean(coordError)}
aria-describedby={coordError ? 'alert-coord-error' : undefined} … />
…
{coordError && <p id="alert-coord-error" role="alert" className={styles.coordError}>{coordError}</p>}
aria-describedby takes a space-separated list of ids, so a field can point at a hint and an error
at once. The field-wiring sandbox
shows the correct and broken versions side by side.
Alt text for an image whose content you cannot know
A live camera frame changes every ten seconds. There is no honest description of its content, so
the useful alt text names the source, and that is what atoms/IImage requires — alt is a
non-optional prop on IImageProps, which is the cheapest possible enforcement, and callers pass
the camera name (alt={cameraDetails.cn}).
components/molecules/Timelapse/PlaybackImage/PlaybackImage.tsx shows the two cases in eleven
lines:
<img src={displayedUrl} alt="Playback frame" … />
{pendingImage && (
<img key={pendingImage.imageUrl} src={pendingImage.imageUrl} alt="" aria-hidden="true"
style={{ display: 'none' }} onLoad={handlePendingImageLoad} />
)}
The visible frame gets a name. The hidden preloader — which exists only to warm the cache — gets
alt="" and aria-hidden, so it is absent from the accessibility tree entirely. That is the
correct treatment for any image that is not content.
Filenames are not alt text
jsx-a11y/alt-text is set to error in eslint.config.js, and it passes every one of these:
alt="settings-video-icon" // ExpandedImageButtons.tsx, three separate icons
alt="cam-movement-btn" // ExpandedImageButtons.tsx
alt="compass-icon" // ExpandedImageButtons.tsx
alt="play-icon" / alt="loop-icon" // PlaybackWrapper.tsx
alt={`layout-symbol-${item.label}`} // CameraOptionsMenuContent.tsx
The rule checks that the attribute exists, never that it says anything. All of these are icons
inside a labelled control, which means the icon is decorative and the correct value is alt="" —
otherwise a screen reader announces "settings-video-icon, Camera" and the operator hears the
filename twice. Worse, ExpandedImageButtons uses the identical alt="settings-video-icon" for
the camera, 360, and map icons, so the three toggles are indistinguishable by name.
Rule of thumb: if the icon sits inside a <button> that already has text or an aria-label,
the icon's alt is "". Only a standalone image that is the content gets a description.
Canvases expose nothing
There is no <video> or <audio> anywhere in this app, so jsx-a11y/media-has-caption never
fires and captions are not a concern here. What is a concern: two <canvas> elements —
Timelapse/TimelapseCanvasPlayer and organisms/PeakFinderPanel — and a canvas has no built-in
accessible content at all. Neither currently carries a role, an aria-label, or fallback
children.
If you extend either, give the canvas an aria-label describing what it renders, and make sure
every action available by dragging on it is also available from a labelled control nearby. The
timelapse player already has real play/loop buttons; the peak finder's on-image interactions do
not have a keyboard equivalent.
Next: Color and typography.