Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changelog/next/fixed-issue-4156.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
- Screen readers now announce the inline form controls on the Character Sheet, GitHub, Sharing, Browser, Mood Board and Video Timeline Editor pages, which previously had no accessible name
- Screen readers now announce 80 more form controls that previously had no accessible name, across the Goals views, the Brain tabs (Daily Log, Feeds, Inbox, Links, Memory, Notes), the MeatSpace tabs (Age, Alcohol, Genome, Lifestyle, Nicotine) and the Agent World/Tools tabs — visible labels where the surface had room, `aria-label` in compact toolbars and table rows
- Named 33 more unlabeled form controls across the Chief of Staff, MeatSpace POST and Voice settings surfaces, and taught the accessible-name guard two wrapper shapes it was blind to — burning the pre-existing allowlist down from 142 to 71 entries
314 changes: 198 additions & 116 deletions client/src/a11yConventions.test.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions client/src/components/cos/TaskAddForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,7 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
onChange={e => setTemplateNameInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveAsTemplate()}
placeholder="Template name..."
aria-label="Template name"
className="flex-1 px-3 py-1.5 bg-port-bg border border-port-border rounded-lg text-white text-sm min-h-[44px]"
autoFocus
/>
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/cos/tabs/AgentCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
onChange={(e) => setBtwInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendBtw()}
placeholder="Send additional context to agent..."
aria-label="Additional context for the agent"
className="flex-1 px-2 py-1 text-sm bg-port-bg border border-port-border rounded text-white placeholder-gray-600 focus:outline-hidden focus:border-yellow-500/50 min-h-[32px]"
maxLength={5000}
disabled={sendingBtw}
Expand Down Expand Up @@ -898,6 +899,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
value={feedbackComment}
onChange={(e) => setFeedbackComment(e.target.value)}
placeholder="What made this work well or poorly?"
aria-label="Feedback comment"
className="flex-1 px-2 py-1 text-sm bg-port-bg border border-port-border rounded text-white placeholder-gray-500 focus:outline-hidden focus:border-port-accent min-h-[32px]"
maxLength={200}
/>
Expand Down
10 changes: 9 additions & 1 deletion client/src/components/cos/tabs/ConfigRow.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { useId } from 'react';

export default function ConfigRow({ label, value, editing, type, inputValue, onChange, suffix, tooltip }) {
// The row's text already reads as the control's label, so pair the two rather
// than duplicating it into an aria-label — that also makes the text a click
// target for the checkbox, which is otherwise a 16px hit area.
const controlId = useId();
return (
<div className="flex items-center justify-between p-4" title={tooltip}>
<span className="text-gray-400 cursor-help">{label}</span>
<label htmlFor={controlId} className="text-gray-400 cursor-help">{label}</label>
{editing ? (
<div className="flex items-center gap-2">
{type === 'checkbox' ? (
<input
id={controlId}
type="checkbox"
checked={inputValue}
onChange={e => onChange(e.target.checked)}
Expand All @@ -14,6 +21,7 @@ export default function ConfigRow({ label, value, editing, type, inputValue, onC
) : (
<>
<input
id={controlId}
type="number"
value={inputValue}
onChange={e => onChange(parseInt(e.target.value, 10) || 0)}
Expand Down
94 changes: 52 additions & 42 deletions client/src/components/cos/tabs/JobsTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { effectiveModelFor, effortAwareModelOptions } from '../../../utils/provi
import ProviderModelSelector from '../../ProviderModelSelector';
import EffortSelect from '../EffortSelect';
import InlineConfirmRow from '../../ui/InlineConfirmRow';
import FormField from '../../ui/FormField';
import { useConfirmDelete } from '../../../hooks/useConfirmDelete';

const INTERVAL_OPTIONS = [
Expand Down Expand Up @@ -229,6 +230,7 @@ function ScheduleFields({ data, onChange }) {
className="flex-1 px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm font-mono"
placeholder="0 7 * * *"
title="Cron expression: minute hour dayOfMonth month dayOfWeek"
aria-label="Cron expression: minute hour dayOfMonth month dayOfWeek"
/>
<select
value=""
Expand Down Expand Up @@ -263,6 +265,7 @@ function ScheduleFields({ data, onChange }) {
onChange={e => onChange('scheduledTime', e.target.value || null)}
className="px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
title="Run at specific time (leave empty for any time)"
aria-label="Run at a specific time (leave empty for any time)"
/>
</div>
)}
Expand Down Expand Up @@ -455,20 +458,22 @@ function JobCard({ job, apps, providers, onToggle, onTrigger, onDelete, onUpdate
<div className="border-t border-port-border p-4 space-y-3">
{editing ? (
<>
<input
type="text"
value={editData.name}
onChange={e => setEditData(d => ({ ...d, name: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
placeholder="Job name"
/>
<input
type="text"
value={editData.description}
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
placeholder="Description"
/>
<FormField label="Job name" labelClassName="block text-xs text-gray-400 mb-1">
<input
type="text"
value={editData.name}
onChange={e => setEditData(d => ({ ...d, name: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
/>
</FormField>
<FormField label="Description" labelClassName="block text-xs text-gray-400 mb-1">
<input
type="text"
value={editData.description}
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
/>
</FormField>
<div className="flex gap-3">
<select
value={editData.type}
Expand Down Expand Up @@ -799,37 +804,42 @@ export default function JobsTab() {
<div className="bg-port-card border border-port-accent/50 rounded-lg p-4">
<div className="space-y-3">
<div className="flex gap-3">
<FormField label="Job name *" className="flex-1" labelClassName="block text-xs text-gray-400 mb-1">
<input
type="text"
value={newJob.name}
onChange={e => setNewJob(j => ({ ...j, name: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
/>
</FormField>
<FormField label="Type" labelClassName="block text-xs text-gray-400 mb-1">
<select
value={newJob.type}
onChange={e => setNewJob(j => ({ ...j, type: e.target.value }))}
className="px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
>
{JOB_TYPE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
<FormField label="Category" labelClassName="block text-xs text-gray-400 mb-1">
<input
type="text"
value={newJob.category}
onChange={e => setNewJob(j => ({ ...j, category: e.target.value }))}
className="w-40 px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
/>
</FormField>
</div>
<FormField label="Description" labelClassName="block text-xs text-gray-400 mb-1">
<input
type="text"
placeholder="Job name *"
value={newJob.name}
onChange={e => setNewJob(j => ({ ...j, name: e.target.value }))}
className="flex-1 px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
/>
<select
value={newJob.type}
onChange={e => setNewJob(j => ({ ...j, type: e.target.value }))}
className="px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
>
{JOB_TYPE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<input
type="text"
placeholder="Category"
value={newJob.category}
onChange={e => setNewJob(j => ({ ...j, category: e.target.value }))}
className="w-40 px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
value={newJob.description}
onChange={e => setNewJob(j => ({ ...j, description: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
/>
</div>
<input
type="text"
placeholder="Description"
value={newJob.description}
onChange={e => setNewJob(j => ({ ...j, description: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
/>
</FormField>
<div className="flex gap-3">
<select
value={newJob.priority}
Expand Down
1 change: 1 addition & 0 deletions client/src/components/cos/tabs/MemoryEditModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export default function MemoryEditModal({ memory, apps, onSave, onClose }) {
onChange={e => setNewTag(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add tag..."
aria-label="Add tag"
className="flex-1 px-3 py-3 min-h-[44px] bg-port-bg border border-port-border rounded-lg text-white text-sm focus:border-port-accent focus:outline-hidden"
/>
<button
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/cos/tabs/TaskItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
<input
type="text"
value={editData.description}
aria-label="Task description"
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm"
/>
Expand Down Expand Up @@ -645,6 +646,7 @@ export default function TaskItem({ task, isSystem, onRefresh, providers, duratio
if (e.key === 'Enter') handleConfirmBlocked();
}}
placeholder="e.g., Waiting for API access, Needs design review..."
aria-label="Reason this task is blocked"
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm mb-4"
/>
<div className="flex justify-end gap-2">
Expand Down
1 change: 1 addition & 0 deletions client/src/components/cos/tabs/workflow/ScheduleEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa
<input
type="time"
value={parseSimpleCron(form.recheckCron)?.time ?? ''}
aria-label="Perpetual recheck time"
onChange={event => set('recheckCron', buildWeeklyCron(parseSimpleCron(form.recheckCron)?.days ?? [], event.target.value))}
className="w-full rounded border border-port-border bg-port-bg px-3 py-2 text-sm text-white"
/>
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/meatspace/EpigeneticTracker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export default function EpigeneticTracker({ markerCategories = [] }) {
value={customForm.name}
onChange={(e) => setCustomForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="Intervention name"
aria-label="Intervention name"
className="col-span-2 px-3 py-2 bg-port-bg border border-port-border rounded text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent"
/>
<select
Expand All @@ -176,13 +177,15 @@ export default function EpigeneticTracker({ markerCategories = [] }) {
value={customForm.dosage}
onChange={(e) => setCustomForm(prev => ({ ...prev, dosage: e.target.value }))}
placeholder="Target dosage (e.g. 5g/day)"
aria-label="Target dosage"
className="px-3 py-2 bg-port-bg border border-port-border rounded text-sm text-white placeholder-gray-600 focus:outline-hidden"
/>
<input
type="text"
value={customForm.trackingUnit}
onChange={(e) => setCustomForm(prev => ({ ...prev, trackingUnit: e.target.value }))}
placeholder="Unit (g, mg, min, etc.)"
aria-label="Tracking unit"
className="px-3 py-2 bg-port-bg border border-port-border rounded text-sm text-white placeholder-gray-600 focus:outline-hidden"
/>
</div>
Expand Down Expand Up @@ -268,6 +271,7 @@ export default function EpigeneticTracker({ markerCategories = [] }) {
onChange={(e) => setLogAmounts(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && handleLog(key)}
placeholder={`Amount (${intervention.trackingUnit})`}
aria-label={`Amount to log for ${intervention.name}`}
className="flex-1 max-w-[160px] px-3 py-1.5 bg-port-bg border border-port-border rounded text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent font-mono"
min="0"
step="any"
Expand Down
4 changes: 3 additions & 1 deletion client/src/components/meatspace/post/ElementsSong.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ function ElementsSongMain({ item, mastery, onSelectMode, onBack, onAttestMastery
<div className="flex items-center gap-2">
<div className="relative flex-1 sm:flex-none">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-600" />
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search..."
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search..." aria-label="Search elements"
className="w-full sm:w-32 bg-port-bg border border-port-border rounded pl-7 pr-2 py-1.5 sm:py-1 text-xs text-white placeholder-gray-600 focus:border-port-accent focus:outline-none" />
</div>
<div className="flex bg-port-bg rounded border border-port-border shrink-0">
Expand Down Expand Up @@ -1006,6 +1006,7 @@ function ElementFlashMode({ item, mastery, onBack, onComplete }) {
onKeyDown={e => { if (e.key === 'Enter') check(); }}
className="mt-6 w-48 bg-port-bg border border-port-border rounded-lg px-4 py-2.5 text-white text-center text-lg placeholder-gray-600 focus:border-port-accent focus:outline-none"
placeholder="..."
aria-label="Your answer"
autoComplete="off"
/>
)}
Expand Down Expand Up @@ -1156,6 +1157,7 @@ function FillBlankMode({ item, onBack, onComplete }) {
onChange={e => setAnswer(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') check(); }}
placeholder={`${blankedWords.length} element${blankedWords.length > 1 ? 's' : ''}...`}
aria-label="Missing elements"
className="w-full bg-port-bg border border-port-border rounded px-4 py-2.5 text-white placeholder-gray-600 focus:border-port-accent focus:outline-none"
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions client/src/components/meatspace/post/MemoryPractice.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ function MemoryPracticeRunner({ item, mode, onSelectMode, onExitMode, onBack, on
onChange={e => setAnswer(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCheckFillBlank(blankWords); }}
placeholder={`${blankWords.length} word${blankWords.length > 1 ? 's' : ''} missing...`}
aria-label="Missing words"
className="w-full bg-port-bg border border-port-border rounded px-4 py-2.5 text-white placeholder-gray-600 focus:border-port-accent focus:outline-none"
/>
</div>
Expand Down
10 changes: 8 additions & 2 deletions client/src/components/meatspace/post/MorseTrainer.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useId } from 'react';
import { ArrowLeft, Radio, Headphones, Hand, EyeOff, CheckCircle, XCircle, Play, RefreshCw, Volume2, GitBranch, List as ListIcon, Ruler, Eraser } from 'lucide-react';
import useDrawerTab from '../../../hooks/useDrawerTab';
import useAudioSessionClaim from '../../../hooks/useAudioSessionClaim.js';
Expand Down Expand Up @@ -642,13 +642,18 @@ function SettingsPanel({ prefs, updatePrefs, onResetProgress, trainingStats }) {
}

function SliderRow({ label, value, min, max, step = 1, onChange, suffix = '', hint }) {
// The row already shows the label; pair it to the slider rather than
// duplicating it into an aria-label. Wrapping instead would pull the live
// value readout beside it into the accessible name.
const sliderId = useId();
return (
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-gray-400 uppercase tracking-wide">{label}</label>
<label htmlFor={sliderId} className="text-xs text-gray-400 uppercase tracking-wide">{label}</label>
<span className="text-sm text-white font-mono">{value}{suffix && ` ${suffix}`}</span>
</div>
<input
id={sliderId}
type="range"
min={min}
max={max}
Expand Down Expand Up @@ -1141,6 +1146,7 @@ function CopyDrill({ prefs, updatePrefs, ensureCtx, claimSession, releaseSession
autoFocus
className="w-full px-4 py-3 bg-port-bg border border-port-border focus:border-port-accent rounded-lg text-white text-center font-mono text-lg uppercase tracking-widest outline-none"
placeholder="????"
aria-label="Decoded characters"
/>
)}
{feedback && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ function DigitSpanRunner({ drill, drillIndex, drillCount, onComplete, isTraining
value={input}
onChange={e => setInput(e.target.value.replace(/\D/g, ''))}
placeholder="Digits"
aria-label="Your answer"
autoFocus
className="flex-1 bg-port-bg border border-port-border rounded-lg px-4 py-3 text-2xl font-mono text-white text-center tracking-widest placeholder-gray-600 focus:border-rose-400 focus:outline-none"
/>
Expand Down
1 change: 1 addition & 0 deletions client/src/components/meatspace/post/PostDrillRunner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export default function PostDrillRunner({ session }) {
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="Answer"
aria-label="Your answer"
autoFocus
className="flex-1 bg-port-bg border border-port-border rounded-lg px-4 py-3 text-xl font-mono text-white text-center placeholder-gray-600 focus:border-port-accent focus:outline-none"
/>
Expand Down
Loading