From 0928d225a4f059d0a9ee8aa52ce18ab482b3a20a Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Wed, 5 Aug 2026 12:43:37 -0700 Subject: [PATCH 1/3] feat(lint): make the CSS lint step report what stylelint finds - parseJsonOutput read only stdout, but stylelint writes its JSON report to stderr, so every result was discarded and the CSS check reported success regardless of what it found - stylelint also received every path as an argument, so a whole-tree run exceeded the Windows ~8191 char command-line limit and failed outright - an unparseable report now blocks the commit rather than passing silently, which is the failure mode this step exists to catch - moves the a11y plugin to its recommended tier: strict adds nine rules that produced 132 findings here and no real defects, while both genuine WCAG failures came from recommended rules - ESLint still writes to stdout, so the vue and cshtml checks are unaffected --- scripts/lib/lint-staged-common.js | 6 +- scripts/lint-staged-css.js | 94 ++++++++++++++++++++----------- stylelint.config.mjs | 9 ++- 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/scripts/lib/lint-staged-common.js b/scripts/lib/lint-staged-common.js index 6f2d45d55..d677754c2 100644 --- a/scripts/lib/lint-staged-common.js +++ b/scripts/lib/lint-staged-common.js @@ -228,8 +228,10 @@ function parseArguments() { function parseJsonOutput(stdout, stderr, toolName, textFallbackParser) { const logger = createLogger(toolName) - // Parse JSON output (should be in stdout with --format json) - const jsonOutput = stdout.trim() + // ESLint writes its JSON report to stdout, but Stylelint writes to stderr, so + // fall back to stderr when stdout is empty. Without this the report is dropped + // and the tool silently reports no issues. + const jsonOutput = stdout.trim() || stderr.trim() try { if (jsonOutput) { diff --git a/scripts/lint-staged-css.js b/scripts/lint-staged-css.js index 0b22f2a1d..aa9644ab7 100644 --- a/scripts/lint-staged-css.js +++ b/scripts/lint-staged-css.js @@ -90,48 +90,74 @@ function parseTextOutput(output) { return issues } -try { - // Run Stylelint using shared command runner - const stylelintArgs = [...(fixFlag ? ["--fix"] : []), "--formatter", "json", "--allow-empty-input", ...files] +/** + * Block the commit, dumping whatever Stylelint emitted so the failure is diagnosable + * @param {string} reason - What went wrong + * @param {string} blockedMessage - Short summary for the COMMIT BLOCKED line + * @param {string} stdout - Stylelint stdout + * @param {string} stderr - Stylelint stderr, deprecation warnings already filtered + * @returns {never} + */ +function blockCommit(reason, blockedMessage, stdout, stderr) { + logger.error(reason) + if (stdout) { + logger.error(stdout) + } + if (stderr) { + logger.error(stderr) + } + logger.error(`🛑 COMMIT BLOCKED - ${blockedMessage}`) + process.exit(1) +} +// Stylelint receives each path as an argument and Windows caps a command line at +// ~8191 chars, so a whole-tree run (200+ files) has to be split into batches. +const MAX_BATCH_SIZE = 50 + +try { logger.info(`Running Stylelint accessibility and style checks on ${files.length} CSS/Vue files...`) - const stylelintResult = runCommand("stylelint", stylelintArgs, "Stylelint", projectRoot) - // Check for fatal errors - if (stylelintResult.status !== 0 && stylelintResult.status !== 2) { - logger.error("Stylelint command failed:") - if (stylelintResult.stdout) { - logger.error(stylelintResult.stdout) - } - if (stylelintResult.stderr) { - logger.error(stylelintResult.stderr) + const issues = [] + + for (let index = 0; index < files.length; index += MAX_BATCH_SIZE) { + const batch = files.slice(index, index + MAX_BATCH_SIZE) + const stylelintArgs = [...(fixFlag ? ["--fix"] : []), "--formatter", "json", "--allow-empty-input", ...batch] + + const stylelintResult = runCommand("stylelint", stylelintArgs, "Stylelint", projectRoot) + + // Filter out deprecation warnings before parsing: stylelint writes its JSON + // report to stderr, so a leading DeprecationWarning line would make the whole + // report unparseable. + const cleanStderr = stylelintResult.stderr + ? stylelintResult.stderr + .split("\n") + .filter((line) => !line.includes("DeprecationWarning")) + .join("\n") + .trim() + : "" + + // Check for fatal errors + if (stylelintResult.status !== 0 && stylelintResult.status !== 2) { + blockCommit("Stylelint command failed:", "Stylelint execution failed", stylelintResult.stdout, cleanStderr) } - logger.error("🛑 COMMIT BLOCKED - Stylelint execution failed") - process.exit(1) - } - // Status 2 means "violations found" - only warn if no violations were parsed - if (stylelintResult.status === 2) { - const jsonToCheck = stylelintResult.stdout.trim() || stylelintResult.stderr.trim() - const hasValidJson = jsonToCheck && jsonToCheck.startsWith("[") - if (!hasValidJson) { - logger.warning("STYLELINT CONFIGURATION WARNING: Status 2 with no parseable violations") - logger.warning("📋 Consider reviewing stylelint.config.mjs if unexpected behavior occurs") + // Parse and accumulate this batch's Stylelint output + const batchIssues = parseStylelintOutput(stylelintResult.stdout, cleanStderr) + issues.push(...batchIssues) + + // Status 2 means "violations found", so an empty batch means the report was lost in + // parsing. Fail closed: passing silently here is the blind-stylelint bug this script + // exists to prevent. + if (stylelintResult.status === 2 && batchIssues.length === 0) { + blockCommit( + "Stylelint reported violations but none could be parsed:", + "Stylelint output could not be read", + stylelintResult.stdout, + cleanStderr, + ) } } - // Filter out deprecation warnings - const cleanStderr = stylelintResult.stderr - ? stylelintResult.stderr - .split("\n") - .filter((line) => !line.includes("DeprecationWarning")) - .join("\n") - .trim() - : "" - - // Parse and categorize Stylelint output - const issues = parseStylelintOutput(stylelintResult.stdout, cleanStderr) - // For CSS, we need special handling of accessibility categories const criticalAccessibilityIssues = [] const accessibilityWarnings = [] diff --git a/stylelint.config.mjs b/stylelint.config.mjs index 77f23d300..4e17107e4 100644 --- a/stylelint.config.mjs +++ b/stylelint.config.mjs @@ -1,6 +1,13 @@ // oxlint-disable-next-line import/no-default-export, import/no-anonymous-default-export -- Stylelint config requires default export export default { - extends: ["stylelint-config-standard", "@double-great/stylelint-a11y/strict"], + // The a11y plugin's own "recommended" tier is what we enforce. Its "strict" tier + // turns on nine further rules which between them produced 132 findings here and + // no real defects: dark-theme demands (we ship a single light theme whose + // contrast pairings are verified against WCAG AA, see DESIGN.md), baseline-grid + // line heights, and display:none inside print and responsive blocks. Every + // genuine WCAG failure found so far came from a recommended rule. Revisit strict, + // media-prefers-color-scheme in particular, if we implement dark mode. + extends: ["stylelint-config-standard", "@double-great/stylelint-a11y/recommended"], customSyntax: "postcss-html", ignoreFiles: [ "**/bin/**", // .NET build output directories From dd4e5d23b9c9070dd9a0d4b22c615a4778ceaec7 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Wed, 5 Aug 2026 12:44:45 -0700 Subject: [PATCH 2/3] style(vueapp): modernize VueApp CSS to the stylelint standard - modern color notation, media range syntax and blank-line spacing, via stylelint --fix - word-break: break-word is a deprecated alias, so switched to overflow-wrap; one line was dropped instead, its block already setting overflow-wrap: anywhere - renamed the fadeToBackground keyframe to kebab-case, updating its one reference - suppressed declaration-property-value-no-unknown on Vue's v-bind() in CSS, which stylelint cannot resolve - corrected stylelint's reduced-motion autofix: it emitted the media query before the rule it overrides, so at equal specificity the animation always won and the override never applied - drops the dark-mode block from base.css, which flipped --color-* to dark values under prefers-color-scheme and implied dark-mode support the app does not have; it reaches only the scaffold main.ts entry, and removing it orphaned seven --vt-c-*-dark declarations --- .../src/CMS/pages/ManageLinkCollections.vue | 3 +-- VueApp/src/CTS/components/LevelSelect.vue | 14 +++++++----- .../CTS/pages/CompetenciesBundleReport.vue | 8 +++---- .../components/ScheduleView.vue | 2 +- .../ClinicalScheduler/components/WeekCell.vue | 4 ++-- .../components/WeekHistoryContent.vue | 1 + VueApp/src/Effort/pages/AuditList.vue | 2 +- VueApp/src/assets/base.css | 22 ------------------- VueApp/src/components/SortableList.vue | 11 ++++++++-- 9 files changed, 28 insertions(+), 39 deletions(-) diff --git a/VueApp/src/CMS/pages/ManageLinkCollections.vue b/VueApp/src/CMS/pages/ManageLinkCollections.vue index 92dc0ab80..6d3350438 100644 --- a/VueApp/src/CMS/pages/ManageLinkCollections.vue +++ b/VueApp/src/CMS/pages/ManageLinkCollections.vue @@ -826,7 +826,6 @@ loadCollections() .link-url { white-space: normal; - word-break: break-word; overflow-wrap: anywhere; } @@ -854,7 +853,7 @@ loadCollections() } .link-field { - word-break: break-word; + overflow-wrap: break-word; min-width: 0; } diff --git a/VueApp/src/CTS/components/LevelSelect.vue b/VueApp/src/CTS/components/LevelSelect.vue index 0995bace0..f2940b872 100644 --- a/VueApp/src/CTS/components/LevelSelect.vue +++ b/VueApp/src/CTS/components/LevelSelect.vue @@ -160,23 +160,27 @@ div.levelSelection button.selectedLevel { } div.levelSelection button.selectedLevel.selectedLevel--1 { - background-color: rgba(62, 127, 238, 0.3); + background-color: rgb(62 127 238 / 30%); color: #212529; } + div.levelSelection button.selectedLevel.selectedLevel--2 { - background-color: rgba(62, 127, 238, 0.7); + background-color: rgb(62 127 238 / 70%); color: #212529; } + div.levelSelection button.selectedLevel.selectedLevel--3 { - background-color: rgba(62, 127, 238, 1); + background-color: rgb(62 127 238 / 100%); color: #000; } + div.levelSelection button.selectedLevel.selectedLevel--4 { - background-color: rgba(0, 44, 175, 0.8); + background-color: rgb(0 44 175 / 80%); color: #fff; } + div.levelSelection button.selectedLevel.selectedLevel--5 { - background-color: rgba(11, 3, 139, 1); + background-color: rgb(11 3 139 / 100%); color: #fff; } diff --git a/VueApp/src/CTS/pages/CompetenciesBundleReport.vue b/VueApp/src/CTS/pages/CompetenciesBundleReport.vue index 9148202b3..bc11f618c 100644 --- a/VueApp/src/CTS/pages/CompetenciesBundleReport.vue +++ b/VueApp/src/CTS/pages/CompetenciesBundleReport.vue @@ -381,7 +381,7 @@ onMounted(() => { .col-competency-name { display: block; white-space: normal; - word-break: break-word; + overflow-wrap: break-word; line-height: 1.4; max-width: 100%; } @@ -389,13 +389,13 @@ onMounted(() => { /* Apply wrapping to competency name column cells */ .competency-name-column { white-space: normal; - word-break: break-word; + overflow-wrap: break-word; } /* Ensure name column respects its width and allows wrapping */ :deep(.q-table td:nth-child(2)) { white-space: normal; - word-break: break-word; + overflow-wrap: break-word; vertical-align: top; max-width: 0; /* This forces the cell to respect table-layout: fixed */ } @@ -404,7 +404,7 @@ onMounted(() => { :deep(.q-table td:nth-child(3)), :deep(.q-table td:nth-child(4)) { white-space: normal; - word-break: break-word; + overflow-wrap: break-word; } /* Bundle chips wrapper */ diff --git a/VueApp/src/ClinicalScheduler/components/ScheduleView.vue b/VueApp/src/ClinicalScheduler/components/ScheduleView.vue index 65fcd462b..eab7602d8 100644 --- a/VueApp/src/ClinicalScheduler/components/ScheduleView.vue +++ b/VueApp/src/ClinicalScheduler/components/ScheduleView.vue @@ -391,7 +391,7 @@ defineExpose({ } /* Mobile single column: q-gutter-md adds only a left margin, which left-pins the card */ -@media (max-width: 599.98px) { +@media (width <= 599.98px) { .schedule-week-grid, .schedule-week-grid > * { margin-left: 0; diff --git a/VueApp/src/ClinicalScheduler/components/WeekCell.vue b/VueApp/src/ClinicalScheduler/components/WeekCell.vue index 5f8ca424c..68197a6b0 100644 --- a/VueApp/src/ClinicalScheduler/components/WeekCell.vue +++ b/VueApp/src/ClinicalScheduler/components/WeekCell.vue @@ -437,10 +437,10 @@ const cardClasses = computed(() => { background-color: var(--ucdavis-gold-20); border-radius: 4px; padding: 2px 4px; - animation: fadeToBackground var(--highlight-duration) ease-out forwards; /* Duration from ANIMATIONS.HIGHLIGHT_DURATION_MS */ + animation: fade-to-background var(--highlight-duration) ease-out forwards; /* Duration from ANIMATIONS.HIGHLIGHT_DURATION_MS */ } -@keyframes fadeToBackground { +@keyframes fade-to-background { 0% { background-color: var(--ucdavis-gold-30); } diff --git a/VueApp/src/ClinicalScheduler/components/WeekHistoryContent.vue b/VueApp/src/ClinicalScheduler/components/WeekHistoryContent.vue index a7ce03ea7..aae5df594 100644 --- a/VueApp/src/ClinicalScheduler/components/WeekHistoryContent.vue +++ b/VueApp/src/ClinicalScheduler/components/WeekHistoryContent.vue @@ -304,6 +304,7 @@ watch( max-height: 55vh; /* Height comes from the measured content (script); animate size changes between weeks */ + /* stylelint-disable-next-line declaration-property-value-no-unknown -- Vue SFC v-bind() is resolved at compile time */ height: v-bind("bodyHeight"); transition: height 0.24s cubic-bezier(0.22, 1, 0.36, 1); will-change: height; diff --git a/VueApp/src/Effort/pages/AuditList.vue b/VueApp/src/Effort/pages/AuditList.vue index c437e62dc..cc23e5680 100644 --- a/VueApp/src/Effort/pages/AuditList.vue +++ b/VueApp/src/Effort/pages/AuditList.vue @@ -884,6 +884,6 @@ onMounted(() => initPage()) diff --git a/VueApp/src/assets/base.css b/VueApp/src/assets/base.css index f10adb8b3..dd6761c49 100644 --- a/VueApp/src/assets/base.css +++ b/VueApp/src/assets/base.css @@ -4,21 +4,13 @@ --vt-c-white-soft: #f8f8f8; --vt-c-white-mute: #f2f2f2; - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; - --vt-c-indigo: #2c3e50; --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); --vt-c-text-light-1: var(--vt-c-indigo); --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); } /* semantic color variables for this project */ @@ -36,20 +28,6 @@ --section-gap: 160px; } -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } -} - *, *::before, *::after { diff --git a/VueApp/src/components/SortableList.vue b/VueApp/src/components/SortableList.vue index cb7c0dde9..21afde31f 100644 --- a/VueApp/src/components/SortableList.vue +++ b/VueApp/src/components/SortableList.vue @@ -297,8 +297,8 @@ function onMoveDown(index: number) { } /* "Just moved" cue: a brand-blue tint and ring that fade out. It is a colour/shadow - fade (no movement), so it also plays under reduced motion — where the slide is - skipped — keeping a clear signal that something changed. */ + fade with no movement, but reduced motion still turns it off (see the media query + below); the row's new position remains the signal that something changed. */ @keyframes sortable-row-flash { 0% { background-color: var(--ucdavis-blue-10); @@ -315,6 +315,13 @@ function onMoveDown(index: number) { animation: sortable-row-flash 1s ease-out; } +/* Must follow the rule above: equal specificity, so the later declaration wins. */ +@media screen and (prefers-reduced-motion: reduce) { + .sortable-row--moved { + animation: none; + } +} + /* On phones the row becomes a stacked card: handle + controls share a top bar, the body drops to its own full-width line below. */ @media (width <= 599px) { From 5c8fadde6d4e1f635e2046e71ad59369a8d6bef6 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Wed, 5 Aug 2026 12:45:53 -0700 Subject: [PATCH 3/3] feat(a11y): give Effort dashboard rows a visible keyboard focus ring - both :focus states set outline: none and offered only a background tint or a 5% brightness shift in its place, leaving keyboard users without a visible focus indicator (WCAG 2.4.7) - adds the system focus ring on :focus-visible, matching the pattern in styles/base.css including the transparent outline that carries the indicator under Windows forced-colors --- VueApp/src/Effort/pages/StaffDashboard.vue | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/VueApp/src/Effort/pages/StaffDashboard.vue b/VueApp/src/Effort/pages/StaffDashboard.vue index d85e0d2d2..05e708831 100644 --- a/VueApp/src/Effort/pages/StaffDashboard.vue +++ b/VueApp/src/Effort/pages/StaffDashboard.vue @@ -1258,7 +1258,17 @@ watch( .dept-row--clickable:hover, .dept-row--clickable:focus { background-color: #e0e0e0; - outline: none; +} + +/* A background tint alone is too weak a focus indicator for keyboard users + (WCAG 2.4.7), so carry the system focus ring. The transparent outline is + invisible normally but becomes the indicator under Windows forced-colors, + where box-shadow is dropped. */ +.dept-row--clickable:focus-visible { + outline: 2px solid transparent; + box-shadow: + 0 0 0 0.1rem white, + 0 0 0 0.25rem var(--focus-ring-color); } .dept-row--no-dept { @@ -1318,7 +1328,15 @@ watch( .clickable-badge:hover, .clickable-badge:focus { filter: brightness(0.95); - outline: none; +} + +/* A 5% brightness shift is not a visible focus indicator (WCAG 2.4.7), so carry + the system focus ring. See the note on .dept-row--clickable above. */ +.clickable-badge:focus-visible { + outline: 2px solid transparent; + box-shadow: + 0 0 0 0.1rem white, + 0 0 0 0.25rem var(--focus-ring-color); } #no-instructors-alerts {