diff --git a/.changelog/next/fixed-issue-4282.md b/.changelog/next/fixed-issue-4282.md new file mode 100644 index 0000000000..23214d239f --- /dev/null +++ b/.changelog/next/fixed-issue-4282.md @@ -0,0 +1 @@ +- Accessibility checks now cover client form inputs across pages, including explicit and implicit labels. diff --git a/client/src/a11yConventions.test.js b/client/src/a11yConventions.test.js index c2c68e68d2..6395687473 100644 --- a/client/src/a11yConventions.test.js +++ b/client/src/a11yConventions.test.js @@ -239,6 +239,647 @@ 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(`(?:^|\\s)${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 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'; + // 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) => { + 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 (c === '\\') { i++; continue; } + if (c === quote) quote = null; + continue; + } + 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 (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) { + const entry = jsxStack.pop(); + mode = entry?.root ? 'code' : (jsxStack.length ? 'jsx-text' : 'code'); + } else if (selfClosing) { + mode = tagParentMode; + } else { + jsxStack.push({ name: tagInfo.fragment ? null : tagInfo.name, root: tagParentMode === 'code' }); + mode = 'jsx-text'; + } + tagInfo = null; + continue; + } + + if (c === '"' || c === "'" || c === '`') { + quote = c; + continue; + } + 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 (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(''); +} + +function hasMatchingExplicitLabel(src, id) { + const re = /]*${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; + 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 = stripHiddenElementContent(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) { + const labels = []; + const re = /<\/?label\b/g; + let match; + while ((match = re.exec(src)) && match.index < index) { + if (match[0].startsWith('$/.test(tag)) labels.push({ index: match.index, tag }); + re.lastIndex = match.index + tag.length; + } + 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; + 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) { + const formRe = /$/.test(formTag)) continue; + const label = normalizedAttributeValue(attributeValue(formTag, 'label')); + if (label === null || label === '' || /^(?:undefined|null|false)$/i.test(label)) continue; + + let depth = 0; + let firstChild = null; + let closed = false; + 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, cursor); + if (!tag) break; + if (depth === 0) firstChild = firstChild || name; + if (!tag.selfClosing) depth++; + 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 === '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')) return true; + if (hasUsableAccessibleNameAttribute(tag, 'aria-labelledby') && hasUsableAriaLabelledByReference(src, tag)) return true; + if (hasUsableNativeInputName(tag)) 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 tied to stable source anchors +// rather than line numbers, so inserting code above a control does not move +// the exception to a different input; remove each entry as its control receives +// a real name. +const INPUT_ANCHOR_ATTRIBUTES = [ + 'id', 'name', 'type', 'placeholder', 'value', 'ref', 'title', 'role', + 'aria-label', 'aria-labelledby', 'autoFocus', 'min', 'max', 'step', +]; + +function inputSemanticAnchor(tag) { + return INPUT_ANCHOR_ATTRIBUTES.map((name) => { + const value = attributeValue(tag, name); + return value === null ? null : `${name}=${value.replace(/\s+/g, ' ')}`; + }).filter(Boolean).join('|'); +} + +function inputSourceAnchor(file, src, index) { + const tag = openingTagAt(src, 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 || ''|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 || ''|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 || ''", + "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=Thinking... (optional)|value=moveThinking", + "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=What is this agent thinking?|value=thought", + "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=Message to nearby agents...|value=sayMessage", + "src/components/agents/tabs/WorldTab.jsx|type=text|placeholder=To Agent ID (optional — leave blank for broadcast)|value=sayTo", + "src/components/apps/ReferenceReposPanel.jsx|placeholder=Display name (e.g. phosphene)|value=form.name", + "src/components/apps/ReferenceReposPanel.jsx|placeholder=Branch (default: main)|value=form.branch", + "src/components/apps/ReferenceReposPanel.jsx|placeholder=Repo URL (https://github.com/owner/repo.git) or local path|value=form.repoUrl", + "src/components/apps/tabs/CustomTasksSection.jsx|type=text|placeholder=Task name *|value=form.name", + "src/components/apps/tabs/CustomTasksSection.jsx|type=text|placeholder=Description|value=form.description", + "src/components/apps/tabs/CustomTasksSection.jsx|type=text|placeholder=0 7 * * *|value=form.cronExpression || ''|title=Cron expression: minute hour dayOfMonth month dayOfWeek", + "src/components/apps/tabs/CustomTasksSection.jsx|type=time|value=form.scheduledTime || ''|title=Run at a specific time (leave empty for any time)", + "src/components/apps/tabs/GitTab.jsx|type=text|placeholder=Commit message...|value=commitMessage", + "src/components/brain/tabs/DailyLogTab.jsx|type=text|placeholder=Quick append — adds a new paragraph…|value=quickAppend", + "src/components/brain/tabs/FeedsTab.jsx|type=text|placeholder=Paste an RSS or Atom feed URL...|value=inputUrl|ref=inputRef", + "src/components/brain/tabs/InboxTab.jsx|type=text|placeholder=One thought at a time...|value=inputText|ref=inputRef", + "src/components/brain/tabs/LinksTab.jsx|type=text|placeholder=Paste a URL (GitHub repos auto-clone)...|value=inputUrl|ref=inputRef", + "src/components/brain/tabs/LinksTab.jsx|type=text|placeholder=Search links by title, URL, description, or tag...|value=search", + "src/components/brain/tabs/LinksTab.jsx|type=text|placeholder=Tags (comma-separated)|value=editForm.tags", + "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Name|value=form.name || ''", + "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 || ''|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 || ''|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 || ''", + "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Mood (e.g. happy, reflective, tired)|value=form.mood || ''", + "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=Tags (comma separated)|value=form.tagInput ?? (form.tags || []).join(', ')", + "src/components/brain/tabs/MemoryTab.jsx|type=text|placeholder=`Search ${DESTINATIONS[activeType]?.label?.toLowerCase() || 'records'}...`|value=searchQuery", + "src/components/brain/tabs/NotesTab.jsx|placeholder=Search notes...|value=searchQuery|ref=searchRef", + "src/components/brain/tabs/NotesTab.jsx|placeholder=folder/note-name|value=newNotePath", + "src/components/brain/tabs/NotesTab.jsx|placeholder=/path/to/obsidian/vault|value=customPath", + "src/components/calendar/AgendaTab.jsx|type=text|placeholder=Search events...|value=search", + "src/components/calendar/ConfigTab.jsx|type=text|placeholder=Client ID (e.g. 123456789-abc.apps.googleusercontent.com)|value=oauthForm.clientId", + "src/components/calendar/ConfigTab.jsx|type=password|placeholder=Client Secret (e.g. GOCSPX-...)|value=oauthForm.clientSecret", + "src/components/calendar/ReviewTab.jsx|type=date|value=date", + "src/components/calendar/ReviewTab.jsx|type=number|placeholder=min|value=editForm.durationMinutes|min=1|max=1440", + "src/components/calendar/ReviewTab.jsx|type=text|placeholder=Note (optional)|value=editForm.note", + "src/components/cos/TaskAddForm.jsx|type=text|placeholder=Template name...|value=templateNameInput", + "src/components/cos/tabs/AgentCard.jsx|type=text|placeholder=Send additional context to agent...|value=btwInput", + "src/components/cos/tabs/AgentCard.jsx|type=text|placeholder=What made this work well or poorly?|value=feedbackComment", + "src/components/cos/tabs/ConfigRow.jsx|type=number|value=inputValue", + "src/components/cos/tabs/ConfigRow.jsx|type=checkbox", + "src/components/cos/tabs/JobsTab.jsx|type=text|placeholder=0 7 * * *|value=data.cronExpression || ''|title=Cron expression: minute hour dayOfMonth month dayOfWeek", + "src/components/cos/tabs/JobsTab.jsx|type=time|value=data.scheduledTime || ''|title=Run at specific time (leave empty for any time)", + "src/components/cos/tabs/JobsTab.jsx|type=text|placeholder=Job name|value=editData.name", + "src/components/cos/tabs/JobsTab.jsx|type=text|placeholder=Description|value=editData.description", + "src/components/cos/tabs/JobsTab.jsx|type=text|placeholder=Job name *|value=newJob.name", + "src/components/cos/tabs/JobsTab.jsx|type=text|placeholder=Category|value=newJob.category", + "src/components/cos/tabs/JobsTab.jsx|type=text|placeholder=Description|value=newJob.description", + "src/components/cos/tabs/MemoryEditModal.jsx|type=text|placeholder=Add tag...|value=newTag", + "src/components/cos/tabs/TaskItem.jsx|type=text|value=editData.description", + "src/components/cos/tabs/TaskItem.jsx|type=text|placeholder=e.g., Waiting for API access, Needs design review...|value=blockedReason|ref=blockedInputRef", + "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|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", + "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|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|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", + "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|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|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", + "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|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", + "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', () => { // 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 +1073,24 @@ describe('a11y conventions', () => { expect(offenders, `Icon-only