From 24adfee04a4bd7d30b2179ee916be7e5842b36d9 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 08:40:50 -0700 Subject: [PATCH 1/6] fix([issue-4282]): generalize input accessible-name checks --- client/src/a11yConventions.test.js | 391 ++++++++++++++++++++++++++++- 1 file changed, 381 insertions(+), 10 deletions(-) diff --git a/client/src/a11yConventions.test.js b/client/src/a11yConventions.test.js index c2c68e68d2..4f07ba3d50 100644 --- a/client/src/a11yConventions.test.js +++ b/client/src/a11yConventions.test.js @@ -239,6 +239,374 @@ function hasFortyFourMinTouchTarget(cls) { return hOk && wOk; } +// Return a static JSX attribute value, including the common expression forms +// used for paired input ids (`id={fieldId}` / `id={`field-${id}`}`). Dynamic +// expressions are compared as source text, which is sufficient for matching +// an input and its label when they share the same expression in one file. +function attributeValue(tag, name) { + const match = new RegExp(`\\b${name}\\s*=`).exec(tag); + if (!match) return null; + + let index = match.index + match[0].length; + while (/\s/.test(tag[index])) index++; + + if (tag[index] === '"' || tag[index] === "'" || tag[index] === '`') { + const quote = tag[index]; + for (let end = index + 1; end < tag.length; end++) { + if (tag[end] === '\\') { end++; continue; } + if (tag[end] === quote) return tag.slice(index + 1, end); + } + return null; + } + + if (tag[index] === '{') { + const end = matchingBraceEnd(tag, index); + if (end === -1) return null; + return tag.slice(index + 1, end).trim(); + } + + const rest = tag.slice(index); + const end = rest.search(/[\s/>]/); + return rest.slice(0, end === -1 ? rest.length : end); +} + +function normalizedAttributeValue(value) { + if (value === null) return null; + const trimmed = value.trim(); + if (trimmed.length >= 2 && ['"', "'", '`'].includes(trimmed[0]) && trimmed.at(-1) === trimmed[0]) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +// Keep source indexes stable while removing comments that may contain JSX +// examples. This lets the repo-wide scan inspect actual elements without +// reporting documentation snippets such as `` in a component header. +function maskComments(src) { + const chars = [...src]; + let quote = null; + for (let i = 0; i < chars.length; i++) { + if (quote) { + if (chars[i] === '\\') { i++; continue; } + if (chars[i] === quote) quote = null; + continue; + } + if (chars[i] === '"' || chars[i] === "'" || chars[i] === '`') { + quote = chars[i]; + continue; + } + if (chars[i] === '/' && chars[i + 1] === '/') { + for (i += 1; i < chars.length && chars[i] !== '\n'; i++) chars[i] = ' '; + continue; + } + if (chars[i] === '/' && chars[i + 1] === '*') { + chars[i] = ' '; + chars[i + 1] = ' '; + for (i += 2; i < chars.length - 1; i++) { + if (chars[i] === '*' && chars[i + 1] === '/') { + chars[i] = ' '; + chars[i + 1] = ' '; + i++; + break; + } + if (chars[i] !== '\n') chars[i] = ' '; + } + } + } + return chars.join(''); +} + +function hasMatchingExplicitLabel(src, id) { + const re = /$/.test(tag)) depth++; + re.lastIndex = match.index + tag.length; + } + return depth > 0; +} + +function isNestedInLabeledFormField(src, index) { + const stack = []; + const re = /<\/?FormField\b/g; + let match; + while ((match = re.exec(src)) && match.index < index) { + if (match[0].startsWith('$/.test(tag)) stack.push(attributeValue(tag, 'label') !== null); + re.lastIndex = match.index + tag.length; + } + return stack.some(Boolean); +} + +function hasAccessibleInputName(src, tag, index) { + if (/\baria-label(?:ledby)?\s*=/.test(tag)) return true; + if (normalizedAttributeValue(attributeValue(tag, 'type'))?.toLowerCase() === 'hidden') return true; + if (isNestedInLabel(src, index) || isNestedInLabeledFormField(src, index)) return true; + + const id = normalizedAttributeValue(attributeValue(tag, 'id')); + return id !== null && id !== '' && hasMatchingExplicitLabel(src, id); +} + +// These are pre-existing controls exposed when the rule was generalized. The +// migration is tracked in #4297. Keep exceptions location-specific so a new +// input in an existing file still fails the guard; remove each entry as its +// control receives a real name. +const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([ + 'src/components/CronInput.jsx:65', + 'src/components/EntityCombobox.jsx:125', + 'src/components/TagPicker.jsx:103', + 'src/components/agents/tabs/ToolsTab.jsx:581', + 'src/components/agents/tabs/WorldTab.jsx:406', + 'src/components/agents/tabs/WorldTab.jsx:407', + 'src/components/agents/tabs/WorldTab.jsx:408', + 'src/components/agents/tabs/WorldTab.jsx:413', + 'src/components/agents/tabs/WorldTab.jsx:418', + 'src/components/agents/tabs/WorldTab.jsx:419', + 'src/components/agents/tabs/WorldTab.jsx:420', + 'src/components/agents/tabs/WorldTab.jsx:437', + 'src/components/agents/tabs/WorldTab.jsx:438', + 'src/components/agents/tabs/WorldTab.jsx:831', + 'src/components/agents/tabs/WorldTab.jsx:862', + 'src/components/agents/tabs/WorldTab.jsx:955', + 'src/components/agents/tabs/WorldTab.jsx:963', + 'src/components/apps/ReferenceReposPanel.jsx:399', + 'src/components/apps/ReferenceReposPanel.jsx:406', + 'src/components/apps/ReferenceReposPanel.jsx:413', + 'src/components/apps/tabs/CustomTasksSection.jsx:115', + 'src/components/apps/tabs/CustomTasksSection.jsx:122', + 'src/components/apps/tabs/CustomTasksSection.jsx:157', + 'src/components/apps/tabs/CustomTasksSection.jsx:186', + 'src/components/apps/tabs/GitTab.jsx:517', + 'src/components/brain/tabs/DailyLogTab.jsx:929', + 'src/components/brain/tabs/FeedsTab.jsx:143', + 'src/components/brain/tabs/InboxTab.jsx:320', + 'src/components/brain/tabs/LinksTab.jsx:367', + 'src/components/brain/tabs/LinksTab.jsx:466', + 'src/components/brain/tabs/LinksTab.jsx:548', + 'src/components/brain/tabs/MemoryTab.jsx:318', + 'src/components/brain/tabs/MemoryTab.jsx:332', + 'src/components/brain/tabs/MemoryTab.jsx:345', + 'src/components/brain/tabs/MemoryTab.jsx:363', + 'src/components/brain/tabs/MemoryTab.jsx:383', + 'src/components/brain/tabs/MemoryTab.jsx:398', + 'src/components/brain/tabs/MemoryTab.jsx:418', + 'src/components/brain/tabs/MemoryTab.jsx:434', + 'src/components/brain/tabs/MemoryTab.jsx:441', + 'src/components/brain/tabs/MemoryTab.jsx:454', + 'src/components/brain/tabs/MemoryTab.jsx:468', + 'src/components/brain/tabs/MemoryTab.jsx:475', + 'src/components/brain/tabs/MemoryTab.jsx:770', + 'src/components/brain/tabs/NotesTab.jsx:305', + 'src/components/brain/tabs/NotesTab.jsx:327', + 'src/components/brain/tabs/NotesTab.jsx:745', + 'src/components/calendar/AgendaTab.jsx:90', + 'src/components/calendar/ConfigTab.jsx:472', + 'src/components/calendar/ConfigTab.jsx:479', + 'src/components/calendar/ReviewTab.jsx:116', + 'src/components/calendar/ReviewTab.jsx:289', + 'src/components/calendar/ReviewTab.jsx:300', + 'src/components/cos/TaskAddForm.jsx:788', + 'src/components/cos/tabs/AgentCard.jsx:734', + 'src/components/cos/tabs/AgentCard.jsx:896', + 'src/components/cos/tabs/ConfigRow.jsx:16', + 'src/components/cos/tabs/ConfigRow.jsx:8', + 'src/components/cos/tabs/JobsTab.jsx:225', + 'src/components/cos/tabs/JobsTab.jsx:260', + 'src/components/cos/tabs/JobsTab.jsx:458', + 'src/components/cos/tabs/JobsTab.jsx:465', + 'src/components/cos/tabs/JobsTab.jsx:802', + 'src/components/cos/tabs/JobsTab.jsx:818', + 'src/components/cos/tabs/JobsTab.jsx:826', + 'src/components/cos/tabs/MemoryEditModal.jsx:228', + 'src/components/cos/tabs/TaskItem.jsx:354', + 'src/components/cos/tabs/TaskItem.jsx:639', + 'src/components/cos/tabs/workflow/ScheduleEditor.jsx:208', + 'src/components/dashboard/LayoutEditor.jsx:304', + 'src/components/dashboard/LayoutEditor.jsx:364', + 'src/components/digital-twin/tabs/DocumentsTab.jsx:208', + 'src/components/digital-twin/tabs/DocumentsTab.jsx:272', + 'src/components/digital-twin/tabs/GoalsTab.jsx:165', + 'src/components/digital-twin/tabs/GoalsTab.jsx:273', + 'src/components/digital-twin/tabs/GoalsTab.jsx:438', + 'src/components/digital-twin/tabs/GoalsTab.jsx:446', + 'src/components/digital-twin/tabs/TimeCapsuleTab.jsx:158', + 'src/components/digital-twin/tabs/TimeCapsuleTab.jsx:293', + 'src/components/goals/GoalEditForm.jsx:139', + 'src/components/goals/GoalEditForm.jsx:174', + 'src/components/goals/GoalEditForm.jsx:38', + 'src/components/goals/GoalLinkedCalendars.jsx:58', + 'src/components/goals/GoalMilestones.jsx:79', + 'src/components/goals/GoalPlanSection.jsx:185', + 'src/components/goals/GoalPlanSection.jsx:195', + 'src/components/goals/GoalPlanSection.jsx:74', + 'src/components/goals/GoalPlanSection.jsx:84', + 'src/components/goals/GoalPlanSection.jsx:97', + 'src/components/goals/GoalProgressLog.jsx:35', + 'src/components/goals/GoalProgressLog.jsx:50', + 'src/components/goals/GoalTodoList.jsx:71', + 'src/components/goals/GoalTodoList.jsx:98', + 'src/components/goals/GoalsListView.jsx:339', + 'src/components/goals/GoalsListView.jsx:349', + 'src/components/goals/GoalsListView.jsx:417', + 'src/components/goals/GoalsTreeView.jsx:484', + 'src/components/goals/GoalsTreeView.jsx:562', + 'src/components/imageGen/HfTokenBanner.jsx:102', + 'src/components/imageGen/LoraPicker.jsx:127', + 'src/components/insights/GoalScorecardTab.jsx:98', + 'src/components/loraTraining/ImportGalleryDialog.jsx:90', + 'src/components/meatspace/EpigeneticTracker.jsx:149', + 'src/components/meatspace/EpigeneticTracker.jsx:174', + 'src/components/meatspace/EpigeneticTracker.jsx:181', + 'src/components/meatspace/EpigeneticTracker.jsx:265', + 'src/components/meatspace/post/ElementsSong.jsx:1001', + 'src/components/meatspace/post/ElementsSong.jsx:1152', + 'src/components/meatspace/post/ElementsSong.jsx:345', + 'src/components/meatspace/post/MemoryPractice.jsx:646', + 'src/components/meatspace/post/MorseTrainer.jsx:1136', + 'src/components/meatspace/post/MorseTrainer.jsx:651', + 'src/components/meatspace/post/PostCognitiveDrillRunner.jsx:647', + 'src/components/meatspace/post/PostDrillRunner.jsx:211', + 'src/components/meatspace/post/PostLlmDrillRunner.jsx:630', + 'src/components/meatspace/post/PostLlmDrillRunner.jsx:672', + 'src/components/meatspace/post/PostLlmDrillRunner.jsx:800', + 'src/components/meatspace/post/WordplayDrillUI.jsx:127', + 'src/components/meatspace/post/WordplayDrillUI.jsx:182', + 'src/components/meatspace/tabs/AgeTab.jsx:47', + 'src/components/meatspace/tabs/AlcoholTab.jsx:350', + 'src/components/meatspace/tabs/AlcoholTab.jsx:357', + 'src/components/meatspace/tabs/AlcoholTab.jsx:373', + 'src/components/meatspace/tabs/AlcoholTab.jsx:418', + 'src/components/meatspace/tabs/AlcoholTab.jsx:425', + 'src/components/meatspace/tabs/AlcoholTab.jsx:441', + 'src/components/meatspace/tabs/AlcoholTab.jsx:486', + 'src/components/meatspace/tabs/AlcoholTab.jsx:574', + 'src/components/meatspace/tabs/AlcoholTab.jsx:597', + 'src/components/meatspace/tabs/AlcoholTab.jsx:606', + 'src/components/meatspace/tabs/AlcoholTab.jsx:624', + 'src/components/meatspace/tabs/AlcoholTab.jsx:635', + 'src/components/meatspace/tabs/GenomeTab.jsx:859', + 'src/components/meatspace/tabs/LifestyleTab.jsx:163', + 'src/components/meatspace/tabs/LifestyleTab.jsx:184', + 'src/components/meatspace/tabs/LifestyleTab.jsx:243', + 'src/components/meatspace/tabs/NicotineTab.jsx:254', + 'src/components/meatspace/tabs/NicotineTab.jsx:261', + 'src/components/meatspace/tabs/NicotineTab.jsx:295', + 'src/components/meatspace/tabs/NicotineTab.jsx:302', + 'src/components/meatspace/tabs/NicotineTab.jsx:435', + 'src/components/meatspace/tabs/NicotineTab.jsx:456', + 'src/components/meatspace/tabs/NicotineTab.jsx:464', + 'src/components/meatspace/tabs/NicotineTab.jsx:473', + 'src/components/media/CollectionPickerShell.jsx:216', + 'src/components/media/CollectionPickerShell.jsx:239', + 'src/components/messages/InboxTab.jsx:381', + 'src/components/music/AlbumsManager.jsx:328', + 'src/components/music/AlbumsManager.jsx:346', + 'src/components/music/AlbumsManager.jsx:349', + 'src/components/music/ArtistsManager.jsx:273', + 'src/components/music/ArtistsManager.jsx:283', + 'src/components/music/ArtistsManager.jsx:395', + 'src/components/music/MusicGenPanel.jsx:324', + 'src/components/music/TracksManager.jsx:397', + 'src/components/pipeline/CanonCard.jsx:257', + 'src/components/pipeline/arcCanvas/AddSeasonRow.jsx:44', + 'src/components/pipeline/arcCanvas/DeriveFromManuscriptPreview.jsx:103', + 'src/components/pipeline/arcCanvas/SeasonActions.jsx:93', + 'src/components/pipeline/arcCanvas/SeasonEditor.jsx:46', + 'src/components/pipeline/arcCanvas/SeasonEditor.jsx:54', + 'src/components/pipeline/arcCanvas/SeasonEditor.jsx:65', + 'src/components/pipeline/arcCanvas/SeasonEditor.jsx:83', + 'src/components/pipeline/arcCanvas/SeasonEditor.jsx:91', + 'src/components/pipeline/arcCanvas/TickingClockEditor.jsx:38', + 'src/components/pipeline/stages/IdeaStage.jsx:94', + 'src/components/pipeline/stages/StoryboardsStage.jsx:510', + 'src/components/pipeline/stages/StoryboardsStage.jsx:745', + 'src/components/settings/VoiceTab.jsx:358', + 'src/components/settings/VoiceTab.jsx:443', + 'src/components/settings/VoiceTab.jsx:482', + 'src/components/settings/VoiceTab.jsx:595', + 'src/components/settings/VoiceTab.jsx:603', + 'src/components/settings/VoiceTab.jsx:611', + 'src/components/settings/VoiceTab.jsx:619', + 'src/components/settings/VoiceTab.jsx:757', + 'src/components/settings/VoiceTab.jsx:766', + 'src/components/settings/VoiceTab.jsx:871', + 'src/components/settings/VoiceTab.jsx:885', + 'src/components/sharing/DuplicateGroup.jsx:67', + 'src/components/shell/TerminalHotKeys.jsx:56', + 'src/components/universe/CharacterDetailEditor.jsx:174', + 'src/components/universeBuilder/CompositeSheetsEditor.jsx:134', + 'src/components/universeBuilder/CompositeSheetsEditor.jsx:193', + 'src/components/universeBuilder/InfluenceChipsInput.jsx:132', + 'src/components/universeBuilder/UniverseBibleTab.jsx:338', + 'src/components/universeBuilder/UniverseBuilderPage.jsx:497', + 'src/components/universeBuilder/UniverseCategoryEditor.jsx:254', + 'src/components/universeBuilder/UniverseCategoryEditor.jsx:316', + 'src/components/universeBuilder/UniverseCategoryEditor.jsx:434', + 'src/components/universeBuilder/UniverseTrunkPanels.jsx:92', + 'src/components/voice/VoiceWidget.jsx:752', + 'src/components/wiki/tabs/SearchTab.jsx:31', + 'src/components/writers-room/LibraryPane.jsx:168', + 'src/components/writers-room/LibraryPane.jsx:185', + 'src/pages/AIProviders.jsx:1390', + 'src/pages/AIProviders.jsx:1437', + 'src/pages/AIProviders.jsx:1444', + 'src/pages/Authors.jsx:300', + 'src/pages/Authors.jsx:413', + 'src/pages/Browser.jsx:446', + 'src/pages/CharacterSheet.jsx:522', + 'src/pages/CharacterSheet.jsx:738', + 'src/pages/CharacterSheet.jsx:745', + 'src/pages/CharacterSheet.jsx:770', + 'src/pages/CharacterSheet.jsx:777', + 'src/pages/CharacterSheet.jsx:802', + 'src/pages/CharacterSheet.jsx:809', + 'src/pages/CharacterSheet.jsx:818', + 'src/pages/GitHub.jsx:229', + 'src/pages/GitHub.jsx:388', + 'src/pages/GitHub.jsx:395', + 'src/pages/MediaCollectionDetail.jsx:346', + 'src/pages/MoodBoardDetail.jsx:606', + 'src/pages/PipelineSeries.jsx:339', + 'src/pages/PipelineSeries.jsx:354', + 'src/pages/PipelineSeries.jsx:364', + 'src/pages/Sharing.jsx:317', + 'src/pages/Sharing.jsx:325', + 'src/pages/StackerNews.jsx:474', + 'src/pages/StackerNews.jsx:566', + 'src/pages/StackerNews.jsx:567', + 'src/pages/StackerNews.jsx:587', + 'src/pages/StackerNews.jsx:588', + 'src/pages/StackerNews.jsx:597', + 'src/pages/StackerNews.jsx:640', + 'src/pages/VideoTimeline.jsx:73', + 'src/pages/VideoTimelineEditor.jsx:530', + 'src/pages/VideoTimelineEditor.jsx:627', +]); + describe('a11y conventions', () => { // Modal.jsx IS the shared implementation; Drawer and Layout use the same // backdrop treatment for a slide-in panel / mobile nav scrim, both of which @@ -432,21 +800,24 @@ describe('a11y conventions', () => { expect(offenders, `Icon-only )} {/* Mobile weight control row */}
Weight:", + "src/components/digital-twin/tabs/GoalsTab.jsx|type=date|value=birthDateInput", + "src/components/digital-twin/tabs/GoalsTab.jsx|type=text|placeholder=Goal title...|value=newGoal.title", + "src/components/digital-twin/tabs/GoalsTab.jsx|type=text|placeholder=Add milestone...|value=newMilestone.title", + "src/components/digital-twin/tabs/GoalsTab.jsx|type=date|value=newMilestone.targetDate", + "src/components/digital-twin/tabs/TimeCapsuleTab.jsx|type=text|placeholder=Snapshot label (e.g., Spring 2026, Pre-career-change)|value=label", + "src/components/digital-twin/tabs/TimeCapsuleTab.jsx|type=checkbox", + "src/components/goals/GoalEditForm.jsx|type=number|value=form.timeBlockConfig?.sessionDurationMinutes || 60|min=15|max=480", + "src/components/goals/GoalEditForm.jsx|type=text|placeholder=Add tag...|value=tagInput", + "src/components/goals/GoalEditForm.jsx|type=text|value=form.title", + "src/components/goals/GoalLinkedCalendars.jsx|type=text|placeholder=Match pattern (optional)|value=calendarMatchPattern", + "src/components/goals/GoalMilestones.jsx|type=text|placeholder=Add milestone...|value=newMilestone.title", + "src/components/goals/GoalPlanSection.jsx|type=text|value=ms.title", + "src/components/goals/GoalPlanSection.jsx|type=text|placeholder=Description...|value=ms.description || ''", + "src/components/goals/GoalPlanSection.jsx|type=text|value=phase.title", + "src/components/goals/GoalPlanSection.jsx|type=text|placeholder=Description...|value=phase.description || ''", + "src/components/goals/GoalPlanSection.jsx|type=date|value=phase.targetDate", + "src/components/goals/GoalProgressLog.jsx|type=date|value=progressForm.date", + "src/components/goals/GoalProgressLog.jsx|type=number|placeholder=Minutes (optional)|value=progressForm.durationMinutes|min=1|max=1440", + "src/components/goals/GoalTodoList.jsx|type=text|placeholder=Add todo...|value=newTodoTitle", + "src/components/goals/GoalTodoList.jsx|type=number|placeholder=Est. min|value=newTodoEstimate|min=1", + "src/components/goals/GoalsListView.jsx|type=text|placeholder=Search goals...|value=searchQuery", + "src/components/goals/GoalsListView.jsx|type=text|placeholder=Add goal...|value=quickAdd", + "src/components/goals/GoalsListView.jsx|type=text|placeholder=Goal title...|value=newGoal.title", + "src/components/goals/GoalsTreeView.jsx|type=text|placeholder=Search...|value=searchQuery", + "src/components/goals/GoalsTreeView.jsx|type=text|placeholder=Goal title...|value=newGoal.title", + "src/components/imageGen/HfTokenBanner.jsx|type=password|placeholder=hf_…|value=token", + "src/components/imageGen/LoraPicker.jsx|type=number|value=sel.scale|min=0|max=2|step=0.1", + "src/components/insights/GoalScorecardTab.jsx|type=text|placeholder=extra keywords, comma-separated|value=drafts[rule.id] ?? ''", + "src/components/loraTraining/ImportGalleryDialog.jsx|type=text|placeholder=Search prompt, model, seed, LoRA…|value=query", + "src/components/meatspace/EpigeneticTracker.jsx|type=text|placeholder=Intervention name|value=customForm.name", + "src/components/meatspace/EpigeneticTracker.jsx|type=text|placeholder=Target dosage (e.g. 5g/day)|value=customForm.dosage", + "src/components/meatspace/EpigeneticTracker.jsx|type=text|placeholder=Unit (g, mg, min, etc.)|value=customForm.trackingUnit", + "src/components/meatspace/EpigeneticTracker.jsx|type=number|placeholder=`Amount (${intervention.trackingUnit})`|value=logAmounts[key] || ''|min=0|step=any", + "src/components/meatspace/post/ElementsSong.jsx|type=text|placeholder=...|value=answer|ref=inputRef", + "src/components/meatspace/post/ElementsSong.jsx|type=text|placeholder=`${blankedWords.length} element${blankedWords.length > 1 ? 's' : ''}...`|value=answer|ref=inputRef", + "src/components/meatspace/post/ElementsSong.jsx|type=text|placeholder=Search...|value=searchQuery", + "src/components/meatspace/post/MemoryPractice.jsx|type=text|placeholder=`${blankWords.length} word${blankWords.length > 1 ? 's' : ''} missing...`|value=answer|ref=inputRef", + "src/components/meatspace/post/MorseTrainer.jsx|placeholder=????|value=input|ref=inputRef", + "src/components/meatspace/post/MorseTrainer.jsx|type=range|value=value|min=min|max=max|step=step", + "src/components/meatspace/post/PostCognitiveDrillRunner.jsx|type=text|placeholder=Digits|value=input|ref=inputRef", + "src/components/meatspace/post/PostDrillRunner.jsx|type=isTextDrill ? 'text' : 'number'|placeholder=Answer|value=inputValue|ref=inputRef", + "src/components/meatspace/post/PostLlmDrillRunner.jsx|type=text|placeholder=Your answer...|value=i === items.length ? inputValue : ''|ref=i === items.length ? inputRef : undefined|autoFocus=i === items.length", + "src/components/meatspace/post/PostLlmDrillRunner.jsx|type=text|placeholder=Type an item and press Enter...|value=inputValue|ref=inputRef", + "src/components/meatspace/post/PostLlmDrillRunner.jsx|type=text|placeholder=Type a creative use and press Enter...|value=inputValue|ref=inputRef", + "src/components/meatspace/post/WordplayDrillUI.jsx|type=text|placeholder=Type the full compound or just the other half...|value=inputValue|ref=inputRef", + "src/components/meatspace/post/WordplayDrillUI.jsx|type=text|placeholder=The bridge word is...|value=inputValue|ref=inputRef", + "src/components/meatspace/tabs/AgeTab.jsx|type=date|value=input", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=text|placeholder=Name|value=buttonForm.name", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=buttonVolumeUnit === 'oz' ? 'Oz' : 'mL'|value=buttonForm.oz|min=0.1|step=0.1|before= setButtonForm(f => ({ ...f, name: e.target.value }))} placeholder=\"Name\" className=\"flex-1 px-2 py-1.5 bg-port-bg border border-port-border rounded-lg text-xs text-white\" />", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=ABV%|value=buttonForm.abv|min=0|max=100|step=0.1|before=type=\"button\" onClick={() => setButtonVolumeUnit(u => u === 'oz' ? 'ml' : 'oz')} className=\"px-1.5 py-1 text-[10px] font-medium rounded bg-port-border/50 text-gray-400 hover:text-port-accent hover:bg-port-accent/10 transition-colors\" > {buttonVolumeUnit} ", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=text|placeholder=New button name|value=buttonForm.name", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=buttonVolumeUnit === 'oz' ? 'Oz' : 'mL'|value=buttonForm.oz|min=0.1|step=0.1|before= setButtonForm(f => ({ ...f, name: e.target.value }))} placeholder=\"New button name\" className=\"flex-1 px-2 py-1.5 bg-port-bg border border-port-border rounded-lg text-xs text-white placeholder-gray-600\" />", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=ABV%|value=buttonForm.abv|min=0|max=100|step=0.1|before=", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=volumeUnit === 'oz' ? '12' : '355'|value=oz|min=0.1|step=0.1", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=date|value=editForm.date", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=text|value=editForm.name", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|value=editForm.oz|min=0.1|step=0.1", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|value=editForm.abv|min=0|max=100|step=0.1", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|value=editForm.count|min=1|max=100", + "src/components/meatspace/tabs/GenomeTab.jsx|type=text|placeholder=rs1801133|value=searchRsid", + "src/components/meatspace/tabs/LifestyleTab.jsx|type=range|value=lifestyle?.exerciseMinutesPerWeek ?? 150|min=0|max=600|step=15", + "src/components/meatspace/tabs/LifestyleTab.jsx|type=range|value=lifestyle?.sleepHoursPerNight ?? 7.5|min=3|max=12|step=0.5", + "src/components/meatspace/tabs/LifestyleTab.jsx|type=number|placeholder=e.g. 22.5|value=lifestyle?.bmi ?? ''|min=10|max=80|step=0.1", + "src/components/meatspace/tabs/NicotineTab.jsx|type=text|placeholder=Name|value=buttonForm.name", + "src/components/meatspace/tabs/NicotineTab.jsx|type=number|placeholder=mg|value=buttonForm.mgPerUnit|step=0.1|before= setButtonForm({ ...buttonForm, name: e.target.value })} className=\"flex-1 bg-port-bg border border-port-border rounded px-2 py-1.5 text-xs text-white\" placeholder=\"Name\" />", + "src/components/meatspace/tabs/NicotineTab.jsx|type=text|placeholder=New product name|value=buttonForm.name", + "src/components/meatspace/tabs/NicotineTab.jsx|type=number|placeholder=mg|value=buttonForm.mgPerUnit|step=0.1|before= setButtonForm({ ...buttonForm, name: e.target.value })} className=\"flex-1 bg-port-bg border border-port-border rounded px-2 py-1.5 text-xs text-white placeholder-gray-600\" placeholder=\"New product name\" />", + "src/components/meatspace/tabs/NicotineTab.jsx|type=date|value=editForm.date", + "src/components/meatspace/tabs/NicotineTab.jsx|type=text|value=editForm.product", + "src/components/meatspace/tabs/NicotineTab.jsx|type=number|value=editForm.mgPerUnit|step=0.1", + "src/components/meatspace/tabs/NicotineTab.jsx|type=number|value=editForm.count|min=1", + "src/components/media/CollectionPickerShell.jsx|type=text|placeholder=searchPlaceholder|value=query", + "src/components/media/CollectionPickerShell.jsx|type=text|placeholder=newCollectionPlaceholder|value=newName", + "src/components/messages/InboxTab.jsx|type=text|placeholder=Search messages...|value=search", + "src/components/music/AlbumsManager.jsx|placeholder=Album title|value=form.title", + "src/components/music/AlbumsManager.jsx|placeholder=dream pop|value=form.genre", + "src/components/music/AlbumsManager.jsx|type=number|placeholder=2026|value=form.releaseYear|min=ALBUM_RELEASE_YEAR_MIN|max=ALBUM_RELEASE_YEAR_MAX", + "src/components/music/ArtistsManager.jsx|placeholder=Nova Vale|value=form.name", + "src/components/music/ArtistsManager.jsx|placeholder=indie folk, dream pop|value=form.genre", + "src/components/music/ArtistsManager.jsx|placeholder=/images/… or https://…|value=form.portraitImageUrl", + "src/components/music/MusicGenPanel.jsx|placeholder=org/model-repo|value=installRepo", + "src/components/music/TracksManager.jsx|placeholder=Track title|value=form.title", + "src/components/pipeline/CanonCard.jsx|type=text|placeholder=Outfit name (e.g. Wedding)|value=draftFor('name')", + "src/components/pipeline/arcCanvas/AddSeasonRow.jsx|placeholder=Volume / Season title…|value=title", + "src/components/pipeline/arcCanvas/DeriveFromManuscriptPreview.jsx|placeholder=Volume title|value=volume.title", + "src/components/pipeline/arcCanvas/SeasonActions.jsx|placeholder=Issue / Episode title…|value=newTitle", + "src/components/pipeline/arcCanvas/SeasonEditor.jsx|placeholder=Title|value=draft.title || ''", + "src/components/pipeline/arcCanvas/SeasonEditor.jsx|type=number|placeholder=#|value=draft.number || 0|min=0|max=99", + "src/components/pipeline/arcCanvas/SeasonEditor.jsx|placeholder=One-sentence logline|value=draft.logline || ''", + "src/components/pipeline/arcCanvas/SeasonEditor.jsx|placeholder=Ending hook|value=draft.endingHook || ''", + "src/components/pipeline/arcCanvas/SeasonEditor.jsx|type=number|placeholder=Issue / episode target|value=draft.episodeCountTarget || 0|title=Issue / episode count target for this volume / season|min=0", + "src/components/pipeline/arcCanvas/TickingClockEditor.jsx|id=ticking-clock-label|type=text|placeholder=What the reader counts down to (e.g. “The storm makes landfall”)|value=c.label || ''", + "src/components/pipeline/stages/IdeaStage.jsx|type=text|placeholder=Your answer (optional — leave blank for LLM's choice)|value=answers[i] || ''", + "src/components/pipeline/stages/StoryboardsStage.jsx|placeholder=INT. FOUNDRY — NIGHT|value=scene.slugline || ''", + "src/components/pipeline/stages/StoryboardsStage.jsx|type=number|value=shot.durationSeconds ?? 4|title=Duration in seconds|min=1|max=30", + "src/components/settings/VoiceTab.jsx|type=text|value=cfg.hotkey", + "src/components/settings/VoiceTab.jsx|type=number|value=cfg.tts.rate ?? 1.0|min=0.5|max=2|step=0.1", + "src/components/settings/VoiceTab.jsx|type=text|value=cfg.stt.endpoint", + "src/components/settings/VoiceTab.jsx|type=text|value=cfg.llm.personality?.name ?? ''", + "src/components/settings/VoiceTab.jsx|type=text|value=cfg.llm.personality?.role ?? ''", + "src/components/settings/VoiceTab.jsx|type=text|value=cfg.llm.personality?.speechStyle ?? ''", + "src/components/settings/VoiceTab.jsx|type=text|value=(cfg.llm.personality?.traits || []).join(', ')", + "src/components/settings/VoiceTab.jsx|type=time|value=cfg.llm.proactive?.quietHours?.start || '22:00'", + "src/components/settings/VoiceTab.jsx|type=time|value=cfg.llm.proactive?.quietHours?.end || '07:00'", + "src/components/settings/VoiceTab.jsx|type=number|value=fastPathCfg.browser?.temperature ?? 0.7|min=0|max=2|step=0.1", + "src/components/settings/VoiceTab.jsx|type=number|value=fastPathCfg.browser?.topK ?? 3|min=1|max=128|step=1", + "src/components/sharing/DuplicateGroup.jsx|value=name", + "src/components/shell/TerminalHotKeys.jsx|type=text|placeholder=Tap & paste here|ref=pasteInputRef", + "src/components/universe/CharacterDetailEditor.jsx|type=text", + "src/components/universeBuilder/CompositeSheetsEditor.jsx|placeholder=newKind === 'world_pitch_poster' ? 'World summary concept pitch poster' : 'Gas-Giant Drifters costume sheet'|value=newLabel", + "src/components/universeBuilder/CompositeSheetsEditor.jsx|value=editLabel", + "src/components/universeBuilder/InfluenceChipsInput.jsx|type=text|placeholder=placeholder|value=input", + "src/components/universeBuilder/UniverseBibleTab.jsx|id=world-logline|type=text|placeholder=One-sentence hook — A foundry city goes silent, and the only survivor is a child.|value=draft.logline || ''", + "src/components/universeBuilder/UniverseBuilderPage.jsx|type=text|placeholder=colonies, factions, species|value=newCategoryName", + "src/components/universeBuilder/UniverseCategoryEditor.jsx|type=number|placeholder=Custom|value=genCustom|min=GENERATE_CUSTOM_MIN|max=GENERATE_CUSTOM_MAX", + "src/components/universeBuilder/UniverseCategoryEditor.jsx|placeholder=Label (e.g. Crystalline canyon basin)|value=newLabel", + "src/components/universeBuilder/UniverseCategoryEditor.jsx|value=editLabel", + "src/components/universeBuilder/UniverseTrunkPanels.jsx|type=text|placeholder=trunk.kind === 'characters' ? 'heroes, villains, factions' : trunk.kind === 'places' ? 'colonies, ruins' : 'weapons, vehicles'|value=newBucketName", + "src/components/voice/VoiceWidget.jsx|type=text|placeholder=Type a message…|value=draft", + "src/components/wiki/tabs/SearchTab.jsx|placeholder=Search wiki pages and raw sources...|value=query|ref=inputRef", + "src/components/writers-room/LibraryPane.jsx|placeholder=Folder name|value=folderName", + "src/components/writers-room/LibraryPane.jsx|placeholder=Title|value=workTitle", + "src/pages/AIProviders.jsx|type=isSecret ? 'password' : 'text'|value=value", + "src/pages/AIProviders.jsx|type=text|placeholder=KEY|value=newEnvKey", + "src/pages/AIProviders.jsx|type=newEnvSecret ? 'password' : 'text'|placeholder=value|value=newEnvValue", + "src/pages/Authors.jsx|placeholder=Jane Doe|value=form.name", + "src/pages/Authors.jsx|placeholder=/images/… or https://…|value=form.headshotImageUrl", + "src/pages/Browser.jsx|type=text|placeholder=https://example.com|value=navUrl", + "src/pages/CharacterSheet.jsx|value=classVal", + "src/pages/CharacterSheet.jsx|placeholder=1d8|value=dmgDice", + "src/pages/CharacterSheet.jsx|placeholder=Description (optional)|value=dmgDesc", + "src/pages/CharacterSheet.jsx|type=number|placeholder=XP amount|value=xpAmount", + "src/pages/CharacterSheet.jsx|placeholder=Description (optional)|value=xpDesc", + "src/pages/CharacterSheet.jsx|placeholder=What happened?|value=evtDesc", + "src/pages/CharacterSheet.jsx|type=number|placeholder=XP (optional)|value=evtXp", + "src/pages/CharacterSheet.jsx|placeholder=Dice (e.g. 2d6)|value=evtDice", + "src/pages/GitHub.jsx|type=text|placeholder=Search repos...|value=search", + "src/pages/GitHub.jsx|type=text|placeholder=SECRET_NAME|value=newSecretName", + "src/pages/GitHub.jsx|type=password|placeholder=Secret value|value=newSecretValue", + "src/pages/MediaCollectionDetail.jsx|type=text|value=nameDraft", + "src/pages/MoodBoardDetail.jsx|type=text|placeholder=Add a caption…", + "src/pages/PipelineSeries.jsx|value=series.name || ''", + "src/pages/PipelineSeries.jsx|placeholder=One-sentence pitch|value=series.logline || ''", + "src/pages/PipelineSeries.jsx|type=number|value=series.issueCountTarget || 0|min=0|max=999", + "src/pages/Sharing.jsx|type=text|placeholder=Display name (e.g. atomantic)|value=sharingDisplayName", + "src/pages/Sharing.jsx|type=text|placeholder=Optional bio / contact note (visible to recipients)|value=sharingBio", + "src/components/meatspace/post/PostDrillConfig.jsx|type=number|value=drillConfig[field.key] ?? ''|min=field.min|max=field.max", + "src/pages/AIProviders.jsx|type=text|placeholder=claude-sonnet-4-20250514|value=formData.defaultModel", + "src/pages/AIProviders.jsx|type=text|placeholder=haiku|value=formData.lightModel", + "src/pages/AIProviders.jsx|type=text|placeholder=sonnet|value=formData.mediumModel", + "src/pages/AIProviders.jsx|type=text|placeholder=opus|value=formData.heavyModel", + "src/pages/AIProviders.jsx|type=text|placeholder=Use fallback provider's default|value=formData.fallbackModel", + "src/pages/StackerNews.jsx|id=`${prefix}-label`|value=form.label|before=s of configuration. closeOnEsc={false} closeOnBackdrop={false} closeLabel=\"Close account settings\" > {formError &&
{formError}
}
{activeTab === 'identity' && <> ", + "src/pages/StackerNews.jsx|id=`${prefix}-label`|value=form.label|before=ed border border-port-border bg-port-card p-4\" onSubmit={onSubmit}>

{title}

update('slug', event.target.value)} />", + "src/pages/StackerNews.jsx|id=`${prefix}-tone`|value=form.tone", + "src/pages/StackerNews.jsx|id=`${prefix}-allowed`|value=form.allowedThemes", + "src/pages/StackerNews.jsx|id=`${prefix}-disallowed`|value=form.disallowedThemes", + "src/pages/StackerNews.jsx|id=`${prefix}-escalation`|value=form.escalationCues", + "src/pages/StackerNews.jsx|id=action-title|value=draft.title", + "src/pages/StackerNews.jsx|id=`${prefix}-${id}`|value=form[key]", + "src/pages/StackerNews.jsx|id=`${prefix}-${id}`|type=number|value=form[key]|min=min|max=max", + "src/pages/StackerNews.jsx|id=`${prefix}-label`|value=form.label", + "src/pages/StackerNews.jsx|id=`${prefix}-username`|value=form.username", + "src/pages/StackerNews.jsx|id=`${prefix}-api-key`|type=password|value=form.apiKey", + "src/pages/StackerNews.jsx|id=`${prefix}-slug`|value=form.slug", + "src/pages/VideoTimeline.jsx|type=text|placeholder=New project name…|value=name", + "src/pages/VideoTimelineEditor.jsx|type=text|value=nameDraft", + "src/pages/VideoTimelineEditor.jsx|type=range|value=Math.min(t, total)|min=0|max=Math.max(0.01, total)|step=0.01", + "src/pages/DataDog.jsx|name=site|type=text|placeholder=e.g., api.custom-datadog.com|value=formData.site", ]); describe('a11y conventions', () => { @@ -812,7 +875,7 @@ describe('a11y conventions', () => { let m; while ((m = re.exec(scanSrc))) { const tag = openingTagAt(scanSrc, m.index, ' Date: Sat, 15 Aug 2026 09:39:46 -0700 Subject: [PATCH 4/6] fix([issue-4282]): harden accessible-name analysis --- client/src/a11yConventions.test.js | 154 ++++++++++++++++++++++------- 1 file changed, 118 insertions(+), 36 deletions(-) diff --git a/client/src/a11yConventions.test.js b/client/src/a11yConventions.test.js index d1c787f1fd..46c5671bcd 100644 --- a/client/src/a11yConventions.test.js +++ b/client/src/a11yConventions.test.js @@ -244,7 +244,7 @@ function hasFortyFourMinTouchTarget(cls) { // expressions are compared as source text, which is sufficient for matching // an input and its label when they share the same expression in one file. function attributeValue(tag, name) { - const match = new RegExp(`\\b${name}\\s*=`).exec(tag); + const match = new RegExp(`(?:^|\\s)${name}\\s*=`).exec(tag); if (!match) return null; let index = match.index + match[0].length; @@ -323,11 +323,30 @@ function hasMatchingExplicitLabel(src, id) { const tag = openingTagAt(src, match.index, '$/.test(tag)) return false; + const name = tag.match(/^<([A-Za-z][\w.-]*)\b/)?.[1]; + if (!name) return false; + const closeIndex = src.indexOf(``, index + tag.length); + if (closeIndex === -1) return false; + const body = maskComments(src.slice(index + tag.length, closeIndex)) + .replace(/<\/?[A-Za-z][^>]*>/g, ' ') + .trim(); + if (!body) return false; + const staticText = body.replace(/\{[^{}]*\}/g, ' ').trim(); + if (staticText) return true; + return [...body.matchAll(/\{([^{}]*)\}/g)].some(([, expression]) => ( + !/^(?:''|""|``|null|undefined|false)\s*$/.test(expression.trim()) + )); +} + function isNestedInLabel(src, index) { let depth = 0; const re = /<\/?label\b/g; @@ -352,7 +371,6 @@ function hasUsableAccessibleNameAttribute(tag, name) { } function isNestedInLabeledFormField(src, index) { - const re = /<\/?[A-Za-z][\w.-]*\b|<>|<\/>/g; const formRe = /' || token === ''; - const name = token === '<>' ? 'fragment' : fragment ? null : token.slice(closing ? 2 : 1).split(/\s|>/, 1)[0]; + let cursor = formMatch.index + formTag.length; + while (cursor < index) { + if (/\s/.test(src[cursor])) { + cursor++; + continue; + } + if (src[cursor] === '{') { + const end = matchingBraceEnd(src, cursor); + if (end === -1 || end >= index) break; + if (src.slice(cursor + 1, end).trim()) firstChild = firstChild || 'expression'; + cursor = end + 1; + continue; + } + if (src[cursor] !== '<') { + const nextTag = src.indexOf('<', cursor); + const nextExpression = src.indexOf('{', cursor); + const next = Math.min( + nextTag === -1 ? index : nextTag, + nextExpression === -1 ? index : nextExpression, + index, + ); + if (depth === 0 && src.slice(cursor, next).trim()) firstChild = firstChild || 'text'; + cursor = next; + continue; + } + + const closing = src.startsWith('', cursor)) { + if (depth === 0) firstChild = firstChild || 'fragment'; + depth++; + cursor += 2; + continue; + } + if (src.startsWith('', cursor)) { + depth = Math.max(0, depth - 1); + cursor += 3; + continue; + } + cursor++; + continue; + } if (closing) { + const end = src.indexOf('>', cursor); + if (end === -1) break; if (depth > 0) depth--; else if (name === 'FormField') { closed = true; break; } + cursor = end + 1; continue; } - const tag = tagBoundaryAt(src, childMatch.index); - if (!tag) continue; - if (depth === 0 && firstChild === null) firstChild = name; + const tag = tagBoundaryAt(src, cursor); + if (!tag) break; + if (depth === 0) firstChild = firstChild || name; if (!tag.selfClosing) depth++; - re.lastIndex = childMatch.index + (tag.end - childMatch.index); + cursor = tag.end; } + if (!closed && depth === 0 && firstChild === null && cursor === index) firstChild = 'input'; // FormField clones only its first React child. The current input is named // by the wrapper only when it is that first, direct child; a later control // (DataDog's optional custom-site input) must remain actionable here. - if (!closed && depth === 0 && firstChild === null) return true; + if (!closed && depth === 0 && firstChild === 'input') return true; } return false; } +function hasUsableAriaLabelledByReference(src, tag) { + const raw = attributeValue(tag, 'aria-labelledby'); + const value = normalizedAttributeValue(raw); + if (!value || !/^[A-Za-z][\w:.-]*(?:\s+[A-Za-z][\w:.-]*)*$/.test(value)) return false; + return value.split(/\s+/).every((id) => { + const re = /<[A-Za-z][\w.-]*\b/g; + let match; + while ((match = re.exec(src))) { + const referencedTag = openingTagAt(src, match.index, 1); + if (!referencedTag) continue; + if (normalizedAttributeValue(attributeValue(referencedTag, 'id')) !== id) continue; + if (/\baria-hidden\s*=\s*['"`]true['"`]/.test(referencedTag)) return false; + if (hasUsableElementText(src, match.index, referencedTag)) return true; + } + return false; + }); +} + function hasAccessibleInputName(src, tag, index) { - if (hasUsableAccessibleNameAttribute(tag, 'aria-label') || hasUsableAccessibleNameAttribute(tag, 'aria-labelledby')) return true; + if (hasUsableAccessibleNameAttribute(tag, 'aria-label')) return true; + if (hasUsableAccessibleNameAttribute(tag, 'aria-labelledby') && hasUsableAriaLabelledByReference(src, tag)) return true; if (normalizedAttributeValue(attributeValue(tag, 'type'))?.toLowerCase() === 'hidden') return true; if (isNestedInLabel(src, index) || isNestedInLabeledFormField(src, index)) return true; @@ -418,11 +495,16 @@ function inputSemanticAnchor(tag) { function inputSourceAnchor(file, src, index) { const tag = openingTagAt(src, index, ' inputSemanticAnchor(openingTagAt(src, match.index, '= maxTags ? `Max ${maxTags} tags` : placeholder|value=input", "src/components/agents/tabs/ToolsTab.jsx|type=text|placeholder=Post title...|value=postTitle", - "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=X|value=newActionParams.x || ''|before=s', connecting: 'bg-port-warning animate-pulse', reconnecting: 'bg-port-warning animate-pulse', disconnected: 'bg-gray-600' }[connectionStatus] || 'bg-gray-600'; // Dynamic param fields for add-to-queue form const renderQueueParamFields = () => { switch (newActionType) { case 'mw_explore': return (
", - "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=Y|value=newActionParams.y || ''|before=eParamFields = () => { switch (newActionType) { case 'mw_explore': return (
setNewActionParams(p => ({ ...p, x: e.target.value }))} className=\"px-2 py-1.5 bg-port-bg border border-port-border rounded text-white text-sm\" />", + "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=X|value=newActionParams.x || ''|occurrence=1", + "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=Y|value=newActionParams.y || ''|occurrence=1", "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=Thinking (optional)|value=newActionParams.thinking || ''", "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=Thought text|value=newActionParams.thought || ''", - "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=X|value=newActionParams.x || ''|before=return ( setNewActionParams(p => ({ ...p, thought: e.target.value }))} className=\"w-full px-2 py-1.5 bg-port-bg border border-port-border rounded text-white text-sm\" /> ); case 'mw_build': return (
", - "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=Y|value=newActionParams.y || ''|before=port-border rounded text-white text-sm\" /> ); case 'mw_build': return (
setNewActionParams(p => ({ ...p, x: e.target.value }))} className=\"px-2 py-1.5 bg-port-bg border border-port-border rounded text-white text-sm\" />", + "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=X|value=newActionParams.x || ''|occurrence=2", + "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=Y|value=newActionParams.y || ''|occurrence=2", "src/components/agents/tabs/WorldTab.jsx|type=number|placeholder=Z|value=newActionParams.z || ''", "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=Message|value=newActionParams.message || ''", "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=To Agent ID (optional)|value=newActionParams.sayTo || ''", @@ -461,9 +543,9 @@ const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([ "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Follow-ups (comma separated)|value=(form.followUps || []).join(', ')", "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Project name|value=form.name || ''", "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Next action (concrete, actionable step)|value=form.nextAction || ''", - "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Title|value=form.title || ''|before=placeholder=\"Notes\" value={form.notes || ''} onChange={(e) => setForm({ ...form, notes: e.target.value })} className=\"w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white\" rows={2} />
); case 'ideas': return (
", + "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Title|value=form.title || ''|occurrence=1", "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=One-liner (core insight)|value=form.oneLiner || ''", - "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Title|value=form.title || ''|before=placeholder=\"Notes\" value={form.notes || ''} onChange={(e) => setForm({ ...form, notes: e.target.value })} className=\"w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white\" rows={2} />
); case 'admin': return (
", + "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Title|value=form.title || ''|occurrence=2", "src/components/brain/tabs/MemoryTab.jsx|type=date|placeholder=Due date|value=form.dueDate ? form.dueDate.split('T')[0] : ''", "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Next action|value=form.nextAction || ''", "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Title (e.g. 'DnD session tonight')|value=form.title || ''", @@ -497,8 +579,8 @@ const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([ "src/components/cos/tabs/workflow/ScheduleEditor.jsx|type=time|value=parseSimpleCron(form.recheckCron)?.time ?? ''", "src/components/dashboard/LayoutEditor.jsx|id=layout-editor-window-end|type=time|value=activateWindow.end", "src/components/dashboard/LayoutEditor.jsx|type=text|placeholder=Name for new layout|value=dupName", - "src/components/digital-twin/tabs/DocumentsTab.jsx|type=range|value=selectedDoc.weight || 5|min=1|max=10|before=Name=\"flex items-center gap-1 sm:gap-2 shrink-0\"> {/* Weight Control - hidden on mobile */}
", - "src/components/digital-twin/tabs/DocumentsTab.jsx|type=range|value=selectedDoc.weight || 5|min=1|max=10|before= )}
{/* Mobile weight control row */}
Weight:", + "src/components/digital-twin/tabs/DocumentsTab.jsx|type=range|value=selectedDoc.weight || 5|min=1|max=10|occurrence=1", + "src/components/digital-twin/tabs/DocumentsTab.jsx|type=range|value=selectedDoc.weight || 5|min=1|max=10|occurrence=2", "src/components/digital-twin/tabs/GoalsTab.jsx|type=date|value=birthDateInput", "src/components/digital-twin/tabs/GoalsTab.jsx|type=text|placeholder=Goal title...|value=newGoal.title", "src/components/digital-twin/tabs/GoalsTab.jsx|type=text|placeholder=Add milestone...|value=newMilestone.title", @@ -547,11 +629,11 @@ const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([ "src/components/meatspace/post/WordplayDrillUI.jsx|type=text|placeholder=The bridge word is...|value=inputValue|ref=inputRef", "src/components/meatspace/tabs/AgeTab.jsx|type=date|value=input", "src/components/meatspace/tabs/AlcoholTab.jsx|type=text|placeholder=Name|value=buttonForm.name", - "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=buttonVolumeUnit === 'oz' ? 'Oz' : 'mL'|value=buttonForm.oz|min=0.1|step=0.1|before= setButtonForm(f => ({ ...f, name: e.target.value }))} placeholder=\"Name\" className=\"flex-1 px-2 py-1.5 bg-port-bg border border-port-border rounded-lg text-xs text-white\" />", - "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=ABV%|value=buttonForm.abv|min=0|max=100|step=0.1|before=type=\"button\" onClick={() => setButtonVolumeUnit(u => u === 'oz' ? 'ml' : 'oz')} className=\"px-1.5 py-1 text-[10px] font-medium rounded bg-port-border/50 text-gray-400 hover:text-port-accent hover:bg-port-accent/10 transition-colors\" > {buttonVolumeUnit} ", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=buttonVolumeUnit === 'oz' ? 'Oz' : 'mL'|value=buttonForm.oz|min=0.1|step=0.1|occurrence=1", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=ABV%|value=buttonForm.abv|min=0|max=100|step=0.1|occurrence=1", "src/components/meatspace/tabs/AlcoholTab.jsx|type=text|placeholder=New button name|value=buttonForm.name", - "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=buttonVolumeUnit === 'oz' ? 'Oz' : 'mL'|value=buttonForm.oz|min=0.1|step=0.1|before= setButtonForm(f => ({ ...f, name: e.target.value }))} placeholder=\"New button name\" className=\"flex-1 px-2 py-1.5 bg-port-bg border border-port-border rounded-lg text-xs text-white placeholder-gray-600\" />", - "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=ABV%|value=buttonForm.abv|min=0|max=100|step=0.1|before=", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=buttonVolumeUnit === 'oz' ? 'Oz' : 'mL'|value=buttonForm.oz|min=0.1|step=0.1|occurrence=2", + "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=ABV%|value=buttonForm.abv|min=0|max=100|step=0.1|occurrence=2", "src/components/meatspace/tabs/AlcoholTab.jsx|type=number|placeholder=volumeUnit === 'oz' ? '12' : '355'|value=oz|min=0.1|step=0.1", "src/components/meatspace/tabs/AlcoholTab.jsx|type=date|value=editForm.date", "src/components/meatspace/tabs/AlcoholTab.jsx|type=text|value=editForm.name", @@ -563,9 +645,9 @@ const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([ "src/components/meatspace/tabs/LifestyleTab.jsx|type=range|value=lifestyle?.sleepHoursPerNight ?? 7.5|min=3|max=12|step=0.5", "src/components/meatspace/tabs/LifestyleTab.jsx|type=number|placeholder=e.g. 22.5|value=lifestyle?.bmi ?? ''|min=10|max=80|step=0.1", "src/components/meatspace/tabs/NicotineTab.jsx|type=text|placeholder=Name|value=buttonForm.name", - "src/components/meatspace/tabs/NicotineTab.jsx|type=number|placeholder=mg|value=buttonForm.mgPerUnit|step=0.1|before= setButtonForm({ ...buttonForm, name: e.target.value })} className=\"flex-1 bg-port-bg border border-port-border rounded px-2 py-1.5 text-xs text-white\" placeholder=\"Name\" />", + "src/components/meatspace/tabs/NicotineTab.jsx|type=number|placeholder=mg|value=buttonForm.mgPerUnit|step=0.1|occurrence=1", "src/components/meatspace/tabs/NicotineTab.jsx|type=text|placeholder=New product name|value=buttonForm.name", - "src/components/meatspace/tabs/NicotineTab.jsx|type=number|placeholder=mg|value=buttonForm.mgPerUnit|step=0.1|before= setButtonForm({ ...buttonForm, name: e.target.value })} className=\"flex-1 bg-port-bg border border-port-border rounded px-2 py-1.5 text-xs text-white placeholder-gray-600\" placeholder=\"New product name\" />", + "src/components/meatspace/tabs/NicotineTab.jsx|type=number|placeholder=mg|value=buttonForm.mgPerUnit|step=0.1|occurrence=2", "src/components/meatspace/tabs/NicotineTab.jsx|type=date|value=editForm.date", "src/components/meatspace/tabs/NicotineTab.jsx|type=text|value=editForm.product", "src/components/meatspace/tabs/NicotineTab.jsx|type=number|value=editForm.mgPerUnit|step=0.1", @@ -651,8 +733,8 @@ const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([ "src/pages/AIProviders.jsx|type=text|placeholder=sonnet|value=formData.mediumModel", "src/pages/AIProviders.jsx|type=text|placeholder=opus|value=formData.heavyModel", "src/pages/AIProviders.jsx|type=text|placeholder=Use fallback provider's default|value=formData.fallbackModel", - "src/pages/StackerNews.jsx|id=`${prefix}-label`|value=form.label|before=s of configuration. closeOnEsc={false} closeOnBackdrop={false} closeLabel=\"Close account settings\" > {formError &&
{formError}
} {activeTab === 'identity' && <> ", - "src/pages/StackerNews.jsx|id=`${prefix}-label`|value=form.label|before=ed border border-port-border bg-port-card p-4\" onSubmit={onSubmit}>

{title}

update('slug', event.target.value)} />", + "src/pages/StackerNews.jsx|id=`${prefix}-label`|value=form.label|occurrence=1", + "src/pages/StackerNews.jsx|id=`${prefix}-label`|value=form.label|occurrence=2", "src/pages/StackerNews.jsx|id=`${prefix}-tone`|value=form.tone", "src/pages/StackerNews.jsx|id=`${prefix}-allowed`|value=form.allowedThemes", "src/pages/StackerNews.jsx|id=`${prefix}-disallowed`|value=form.disallowedThemes", @@ -875,7 +957,7 @@ describe('a11y conventions', () => { let m; while ((m = re.exec(scanSrc))) { const tag = openingTagAt(scanSrc, m.index, ' Date: Sat, 15 Aug 2026 09:55:41 -0700 Subject: [PATCH 5/6] fix([issue-4282]): close final accessibility guard gaps --- client/src/a11yConventions.test.js | 176 ++++++++++++++++++++++++----- 1 file changed, 150 insertions(+), 26 deletions(-) diff --git a/client/src/a11yConventions.test.js b/client/src/a11yConventions.test.js index 46c5671bcd..889e925ba8 100644 --- a/client/src/a11yConventions.test.js +++ b/client/src/a11yConventions.test.js @@ -280,37 +280,143 @@ function normalizedAttributeValue(value) { } // Keep source indexes stable while removing comments that may contain JSX -// examples. This lets the repo-wide scan inspect actual elements without -// reporting documentation snippets such as `` in a component header. +// examples. This is a small lexer rather than a quote-only scan: apostrophes, +// slashes, and URLs are ordinary JSX text and must not put the rest of a file +// into a fake JavaScript string/comment state. function maskComments(src) { const chars = [...src]; + let mode = 'code'; let quote = null; + let braceDepth = 0; + let tagBraceDepth = 0; + let tagInfo = null; + let tagParentMode = 'code'; + const jsxStack = []; + + const jsxTagInfoAt = (index) => { + const closing = src.startsWith('', index) || src.startsWith('', index); + const name = src.slice(index + (closing ? 2 : 1)).match(/^([A-Za-z][\w.-]*)/)?.[1] || null; + return { closing, fragment, name }; + }; + + const looksLikeJsxTagStart = (index) => { + const next = src[index + 1]; + if (!(next === '/' || next === '>' || /[A-Za-z]/.test(next || ''))) return false; + let previous = index - 1; + while (previous >= 0 && /\s/.test(src[previous])) previous--; + if (previous < 0 || '=([{,:;!?&|>'.includes(src[previous])) return true; + return /(?:return|yield|=>)\s*$/.test(src.slice(Math.max(0, index - 12), index)); + }; + for (let i = 0; i < chars.length; i++) { + const c = chars[i]; + + if (mode === 'line-comment') { + if (c === '\n') mode = 'code'; + else chars[i] = ' '; + continue; + } + if (mode === 'block-comment') { + if (c === '*' && chars[i + 1] === '/') { + chars[i] = ' '; + chars[++i] = ' '; + mode = 'code'; + } else if (c !== '\n') { + chars[i] = ' '; + } + continue; + } if (quote) { - if (chars[i] === '\\') { i++; continue; } - if (chars[i] === quote) quote = null; + if (c === '\\') { i++; continue; } + if (c === quote) quote = null; continue; } - if (chars[i] === '"' || chars[i] === "'" || chars[i] === '`') { - quote = chars[i]; + if (mode === 'jsx-text') { + if (c === '{') { + mode = 'code'; + braceDepth = 1; + } else if (c === '<' && (src[i + 1] === '/' || /[A-Za-z>]/.test(src[i + 1] || ''))) { + tagInfo = jsxTagInfoAt(i); + tagParentMode = 'jsx-text'; + tagBraceDepth = 0; + mode = 'jsx-tag'; + } continue; } - if (chars[i] === '/' && chars[i + 1] === '/') { - for (i += 1; i < chars.length && chars[i] !== '\n'; i++) chars[i] = ' '; + if (mode === 'jsx-tag') { + if (c === '/' && chars[i + 1] === '/') { + chars[i] = ' '; + chars[++i] = ' '; + mode = 'line-comment'; + continue; + } + if (c === '/' && chars[i + 1] === '*') { + chars[i] = ' '; + chars[++i] = ' '; + mode = 'block-comment'; + continue; + } + if (c === '"' || c === "'" || c === '`') { + quote = c; + continue; + } + if (c === '{') { + tagBraceDepth++; + continue; + } + if (c === '}' && tagBraceDepth > 0) { + tagBraceDepth--; + continue; + } + if (c !== '>' || tagBraceDepth !== 0) continue; + + let previous = i - 1; + while (previous >= 0 && /\s/.test(src[previous])) previous--; + const selfClosing = src[previous] === '/'; + if (tagInfo.closing) { + jsxStack.pop(); + mode = jsxStack.length ? 'jsx-text' : 'code'; + } else if (selfClosing) { + mode = tagParentMode; + } else { + jsxStack.push(tagInfo.fragment ? null : tagInfo.name); + mode = 'jsx-text'; + } + tagInfo = null; continue; } - if (chars[i] === '/' && chars[i + 1] === '*') { + + if (c === '"' || c === "'" || c === '`') { + quote = c; + continue; + } + if (c === '/' && chars[i + 1] === '/') { chars[i] = ' '; - chars[i + 1] = ' '; - for (i += 2; i < chars.length - 1; i++) { - if (chars[i] === '*' && chars[i + 1] === '/') { - chars[i] = ' '; - chars[i + 1] = ' '; - i++; - break; - } - if (chars[i] !== '\n') chars[i] = ' '; - } + chars[++i] = ' '; + mode = 'line-comment'; + continue; + } + if (c === '/' && chars[i + 1] === '*') { + chars[i] = ' '; + chars[++i] = ' '; + mode = 'block-comment'; + continue; + } + if (braceDepth > 0 && c === '{') { + braceDepth++; + continue; + } + if (braceDepth > 0 && c === '}') { + braceDepth--; + if (braceDepth === 0) mode = 'jsx-text'; + continue; + } + if (c === '<' && looksLikeJsxTagStart(i)) { + tagInfo = jsxTagInfoAt(i); + tagParentMode = 'code'; + tagBraceDepth = 0; + mode = 'jsx-tag'; } } return chars.join(''); @@ -329,6 +435,13 @@ function hasMatchingExplicitLabel(src, id) { return false; } +function stripHiddenElementContent(body) { + const hiddenAttribute = String.raw`(?:aria-hidden\s*=\s*(?:["']true["']|\{\s*true\s*\})|\bhidden(?:\s*=\s*(?:["']true["']|\{\s*true\s*\}))?)`; + return body + .replace(new RegExp(`<([A-Za-z][\\w.-]*)\\b[^>]*${hiddenAttribute}[^>]*/\\s*>`, 'gi'), ' ') + .replace(new RegExp(`<([A-Za-z][\\w.-]*)\\b[^>]*${hiddenAttribute}[^>]*>[\\s\\S]*?<\\/\\1\\s*>`, 'gi'), ' '); +} + function hasUsableElementText(src, index, tag) { if (hasUsableAccessibleNameAttribute(tag, 'aria-label')) return true; if (/\/\s*>$/.test(tag)) return false; @@ -336,7 +449,7 @@ function hasUsableElementText(src, index, tag) { if (!name) return false; const closeIndex = src.indexOf(``, index + tag.length); if (closeIndex === -1) return false; - const body = maskComments(src.slice(index + tag.length, closeIndex)) + const body = stripHiddenElementContent(maskComments(src.slice(index + tag.length, closeIndex))) .replace(/<\/?[A-Za-z][^>]*>/g, ' ') .trim(); if (!body) return false; @@ -348,26 +461,37 @@ function hasUsableElementText(src, index, tag) { } function isNestedInLabel(src, index) { - let depth = 0; + const labels = []; const re = /<\/?label\b/g; let match; while ((match = re.exec(src)) && match.index < index) { if (match[0].startsWith('$/.test(tag)) depth++; + if (!/\/\s*>$/.test(tag)) labels.push({ index: match.index, tag }); re.lastIndex = match.index + tag.length; } - return depth > 0; + return labels.some(({ index: labelIndex, tag }) => hasUsableElementText(src, labelIndex, tag)); } function hasUsableAccessibleNameAttribute(tag, name) { const value = normalizedAttributeValue(attributeValue(tag, name)); if (value === null || value === '') return false; - return !/^(?:undefined|null)$/i.test(value); + const trimmed = value.trim(); + return trimmed !== '' && !/^(?:undefined|null)$/i.test(trimmed); +} + +function hasUsableNativeInputName(tag) { + const type = normalizedAttributeValue(attributeValue(tag, 'type'))?.toLowerCase() || 'text'; + if (type === 'hidden') return true; + if (['submit', 'button', 'reset'].includes(type)) { + const value = attributeValue(tag, 'value'); + return value === null || hasUsableAccessibleNameAttribute(tag, 'value'); + } + return type === 'image' && hasUsableAccessibleNameAttribute(tag, 'alt'); } function isNestedInLabeledFormField(src, index) { @@ -469,7 +593,7 @@ function hasUsableAriaLabelledByReference(src, tag) { function hasAccessibleInputName(src, tag, index) { if (hasUsableAccessibleNameAttribute(tag, 'aria-label')) return true; if (hasUsableAccessibleNameAttribute(tag, 'aria-labelledby') && hasUsableAriaLabelledByReference(src, tag)) return true; - if (normalizedAttributeValue(attributeValue(tag, 'type'))?.toLowerCase() === 'hidden') return true; + if (hasUsableNativeInputName(tag)) return true; if (isNestedInLabel(src, index) || isNestedInLabeledFormField(src, index)) return true; const id = normalizedAttributeValue(attributeValue(tag, 'id')); From 1aeac1af8f9a4464fe6ee91131b3a69c0a1a605e Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 10:09:21 -0700 Subject: [PATCH 6/6] fix([issue-4282]): preserve nested JSX scan context --- client/src/a11yConventions.test.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/src/a11yConventions.test.js b/client/src/a11yConventions.test.js index 889e925ba8..6395687473 100644 --- a/client/src/a11yConventions.test.js +++ b/client/src/a11yConventions.test.js @@ -291,6 +291,10 @@ function maskComments(src) { let tagBraceDepth = 0; let tagInfo = null; let tagParentMode = 'code'; + // Each entry marks whether the element started in JavaScript expression + // context. A nested JSX expression can sit inside an outer element; when its + // root closes, return to JavaScript rather than mistaking the outer element's + // remaining stack entry for JSX text. const jsxStack = []; const jsxTagInfoAt = (index) => { @@ -375,12 +379,12 @@ function maskComments(src) { while (previous >= 0 && /\s/.test(src[previous])) previous--; const selfClosing = src[previous] === '/'; if (tagInfo.closing) { - jsxStack.pop(); - mode = jsxStack.length ? 'jsx-text' : 'code'; + const entry = jsxStack.pop(); + mode = entry?.root ? 'code' : (jsxStack.length ? 'jsx-text' : 'code'); } else if (selfClosing) { mode = tagParentMode; } else { - jsxStack.push(tagInfo.fragment ? null : tagInfo.name); + jsxStack.push({ name: tagInfo.fragment ? null : tagInfo.name, root: tagParentMode === 'code' }); mode = 'jsx-text'; } tagInfo = null; @@ -501,7 +505,7 @@ function isNestedInLabeledFormField(src, index) { const formTag = openingTagAt(src, formMatch.index, '$/.test(formTag)) continue; const label = normalizedAttributeValue(attributeValue(formTag, 'label')); - if (label === null || label === '' || /^(?:undefined|null)$/i.test(label)) continue; + if (label === null || label === '' || /^(?:undefined|null|false)$/i.test(label)) continue; let depth = 0; let firstChild = null;