diff --git a/frontend-modern/DESIGN_SYSTEM.md b/frontend-modern/DESIGN_SYSTEM.md index 612903b31..05efef1a3 100644 --- a/frontend-modern/DESIGN_SYSTEM.md +++ b/frontend-modern/DESIGN_SYSTEM.md @@ -13,6 +13,7 @@ This document outlines the standard UI primitives, tokens, and components that c - `npm run lint:theme` is a hard gate for theme governance across the whole frontend (`src/**` + `index.html`). - `npm run lint:headers` audits header composition and page-level header usage policy. - Routed surfaces must use shared header primitives (`PageHeader`, `SectionHeader`, `SettingsPanel`, `OperationsPanel`) instead of raw `

` markup. +- Visible form labels must either use `for` with a matching control ID or wrap a native labelable control; use a heading, paragraph, or span for non-control captions. - Only explicit non-visual route wrappers are exempt from header primitive requirements. - Theme ownership policy: - Only `src/utils/theme.ts` and `index.html` may read/write theme keys (`pulseThemePreference`, `darkMode`, `pulse_dark_mode`) or toggle the root `dark` class. diff --git a/frontend-modern/package.json b/frontend-modern/package.json index c657ae16e..53e006f71 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -35,10 +35,11 @@ "test:coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include=src/**/*.ts --coverage.include=src/**/*.tsx --coverage.exclude=src/index.tsx", "test:coverage:ai": "vitest run --coverage --coverage.provider=v8 --coverage.thresholds.100 --coverage.thresholds.perFile --coverage.include=src/components/AI/aiChatUtils.ts", "type-check": "tsc --noEmit", - "lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms", + "lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms && npm run lint:form-labels", "lint:canonical-platforms": "node scripts/canonical-platform-audit.mjs", "lint:eslint": "eslint \"src/**/*.{ts,tsx}\"", "lint:copy": "node scripts/copy-style-audit.mjs", + "lint:form-labels": "node scripts/form-label-audit.mjs", "lint:theme": "node scripts/theme-audit.mjs", "lint:headers": "node scripts/header-audit.mjs", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", diff --git a/frontend-modern/scripts/form-label-audit.mjs b/frontend-modern/scripts/form-label-audit.mjs new file mode 100644 index 000000000..8efab5cd4 --- /dev/null +++ b/frontend-modern/scripts/form-label-audit.mjs @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const sourceRoot = path.resolve('src'); + +const sourceFiles = []; +const collectSourceFiles = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + collectSourceFiles(entryPath); + } else if (entry.isFile() && entry.name.endsWith('.tsx')) { + sourceFiles.push(entryPath); + } + } +}; + +collectSourceFiles(sourceRoot); + +const labelableElements = new Set([ + 'button', + 'input', + 'meter', + 'output', + 'progress', + 'select', + 'textarea', +]); +const diagnostics = []; + +const getTagName = (node, sourceFile) => node.tagName.getText(sourceFile); +const getAttributeValue = (attribute, sourceFile) => + attribute.initializer?.getText(sourceFile).replace(/^['"]|['"]$/g, ''); + +const hasLabelTarget = (element, sourceFile, labelableIds) => { + const attributes = element.openingElement.attributes.properties; + const forAttribute = attributes.find( + (attribute) => + ts.isJsxAttribute(attribute) && + ['for', 'htmlFor'].includes(attribute.name.getText(sourceFile)), + ); + if (forAttribute) { + const target = getAttributeValue(forAttribute, sourceFile); + return target !== undefined && labelableIds.has(target); + } + + let containsLabelableElement = false; + const visit = (node) => { + if ( + (ts.isJsxElement(node) && + labelableElements.has(getTagName(node.openingElement, sourceFile))) || + (ts.isJsxSelfClosingElement(node) && labelableElements.has(getTagName(node, sourceFile))) + ) { + containsLabelableElement = true; + return; + } + ts.forEachChild(node, visit); + }; + element.children.forEach(visit); + return containsLabelableElement; +}; + +for (const sourcePath of sourceFiles.sort()) { + const sourceText = fs.readFileSync(sourcePath, 'utf8'); + const sourceFile = ts.createSourceFile( + sourcePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + + const labelableIds = new Set(); + const collectLabelableIds = (node) => { + const element = ts.isJsxElement(node) + ? node.openingElement + : ts.isJsxSelfClosingElement(node) + ? node + : undefined; + if (element && labelableElements.has(getTagName(element, sourceFile))) { + const idAttribute = element.attributes.properties.find( + (attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'id', + ); + if (idAttribute) { + const id = getAttributeValue(idAttribute, sourceFile); + if (id !== undefined) labelableIds.add(id); + } + } + ts.forEachChild(node, collectLabelableIds); + }; + collectLabelableIds(sourceFile); + + const visit = (node) => { + if ( + ts.isJsxElement(node) && + getTagName(node.openingElement, sourceFile) === 'label' && + !hasLabelTarget(node, sourceFile, labelableIds) + ) { + const position = sourceFile.getLineAndCharacterOfPosition(node.openingElement.getStart()); + diagnostics.push( + `${path.relative(process.cwd(), sourcePath)}:${position.line + 1}:${position.character + 1} ` + + 'label must target an ID on a native labelable control or contain that control', + ); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +} + +if (diagnostics.length > 0) { + console.error('Form label audit failed:\n'); + diagnostics.forEach((diagnostic) => console.error(`- ${diagnostic}`)); + process.exit(1); +} + +console.log(`Form label audit passed (${sourceFiles.length} TSX files checked).`); diff --git a/frontend-modern/src/components/Alerts/BulkEditDialog.tsx b/frontend-modern/src/components/Alerts/BulkEditDialog.tsx index 58144b2af..9833596cf 100644 --- a/frontend-modern/src/components/Alerts/BulkEditDialog.tsx +++ b/frontend-modern/src/components/Alerts/BulkEditDialog.tsx @@ -117,7 +117,7 @@ export function BulkEditDialog(props: BulkEditDialogProps) { return (
- + {column}
{isOff() @@ -148,6 +148,7 @@ export function BulkEditDialog(props: BulkEditDialogProps) { {['cpu', 'memory', 'disk', 'temperature'].includes(metric) ? (
- +

{state.backupOrphanedPresentation.ignoreVmidsDescription}

diff --git a/frontend-modern/src/components/Alerts/WebhookConfigForm.tsx b/frontend-modern/src/components/Alerts/WebhookConfigForm.tsx index 7e6e86ae0..9caeb096d 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfigForm.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfigForm.tsx @@ -80,7 +80,7 @@ export function WebhookConfigForm(props: WebhookConfigFormProps) {
- + Service Type
setConfirmPassword(e.currentTarget.value)} diff --git a/frontend-modern/src/components/Workloads/ThresholdSlider.tsx b/frontend-modern/src/components/Workloads/ThresholdSlider.tsx index 77354872a..6307078dd 100644 --- a/frontend-modern/src/components/Workloads/ThresholdSlider.tsx +++ b/frontend-modern/src/components/Workloads/ThresholdSlider.tsx @@ -26,6 +26,7 @@ export function ThresholdSlider(props: ThresholdSliderProps) { {/* Native range input (invisible but functional) */} {ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP}

- +