fix(ui): enforce accessible theme contrast

This commit is contained in:
sechmachine
2026-08-15 00:30:01 +07:00
parent 6d30b426aa
commit 3343745632
4 changed files with 151 additions and 8 deletions
+75
View File
@@ -0,0 +1,75 @@
# Test Evidence: theme token contrast
- **Test type:** Web
- **Requirement IDs:** `UI-005`, `UI-006`, `UI-010`, `UI-018`, `I1-UI-02`
- **Scenario IDs:** `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast`
- **Implementation commit:** `pending`
## Protected behavior
The committed light and dark CSS tokens provide at least 4.5:1 contrast for normal text and 3:1 for focus indicators and meaningful panel/control boundaries against their adjacent surfaces.
## Test method
The web test reads the generated classpath CSS, extracts the production light and dark custom-property values, converts sRGB colors to relative luminance, and checks WCAG contrast ratios for ink, muted/subtle text, neutral boundaries, and focus tokens.
## Hand-derived expected result
Both themes must keep normal text at or above 4.5:1. Borders and focus tokens must be at or above 3:1 against the panel, sidebar, or canvas on which they are used.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast test
```
**Observed result**
```text
border / canvas contrast 1.2206621853850066 is below 3.0
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
npm run build
./mvnw -Dtest=UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast test
```
**Observed result**
```text
Tailwind CSS v4.3.3: Done in 73ms
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 26.385 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=UiContractWebTest,DashboardTemplateWebTest,ReportingArchitectureTest,LayerStructureTest test
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 27.072 s
```
## External-test boundaries
This deterministic check proves the declared theme-token ratios used by the shared shell. It does not replace browser inspection for antialiasing, authored colors outside the token set, image contrast, zoom, high-contrast modes, or viewport-specific focus clipping.
+7 -7
View File
@@ -7,8 +7,8 @@
--color-sidebar: #f0f1f2; --color-sidebar: #f0f1f2;
--color-panel: #ffffff; --color-panel: #ffffff;
--color-panel-muted: #f7f8f9; --color-panel-muted: #f7f8f9;
--color-border: #dfe1e5; --color-border: #858c96;
--color-border-strong: #c9cdd3; --color-border-strong: #747d89;
--color-muted: #626a75; --color-muted: #626a75;
--color-accent: #3157e7; --color-accent: #3157e7;
--color-success: #087a48; --color-success: #087a48;
@@ -23,10 +23,10 @@
--sidebar: #f0f1f2; --sidebar: #f0f1f2;
--panel: #ffffff; --panel: #ffffff;
--panel-muted: #f7f8f9; --panel-muted: #f7f8f9;
--border: #dfe1e5; --border: #858c96;
--border-strong: #c9cdd3; --border-strong: #747d89;
--muted: #626a75; --muted: #626a75;
--subtle: #818894; --subtle: #626a75;
--accent: #3157e7; --accent: #3157e7;
--focus: #3157e7; --focus: #3157e7;
--success: #087a48; --success: #087a48;
@@ -41,8 +41,8 @@
--sidebar: #111317; --sidebar: #111317;
--panel: #17191e; --panel: #17191e;
--panel-muted: #1d2026; --panel-muted: #1d2026;
--border: #30343d; --border: #626b78;
--border-strong: #454b57; --border-strong: #707987;
--muted: #b2b7c0; --muted: #b2b7c0;
--subtle: #969da8; --subtle: #969da8;
--accent: #8ca4ff; --accent: #8ca4ff;
File diff suppressed because one or more lines are too long
@@ -6,6 +6,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -70,6 +72,28 @@ class UiContractWebTest {
assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')")); assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')"));
} }
@Test
void themeTokensMeetTextFocusAndMeaningfulBoundaryContrast() throws Exception {
String css = new ClassPathResource("static/assets/app.css")
.getContentAsString(StandardCharsets.UTF_8);
String light = section(css, ":root\\{color-scheme:light;([^}]*)}");
String dark = section(css, ":root\\[data-theme=dark]\\{color-scheme:dark;([^}]*)}");
assertContrast(light, "ink", "canvas", 4.5);
assertContrast(light, "muted", "panel", 4.5);
assertContrast(light, "subtle", "sidebar", 4.5);
assertContrast(light, "border", "canvas", 3.0);
assertContrast(light, "border-strong", "panel", 3.0);
assertContrast(light, "focus", "canvas", 3.0);
assertContrast(dark, "ink", "canvas", 4.5);
assertContrast(dark, "muted", "panel", 4.5);
assertContrast(dark, "subtle", "sidebar", 4.5);
assertContrast(dark, "border", "panel", 3.0);
assertContrast(dark, "border-strong", "panel", 3.0);
assertContrast(dark, "focus", "panel", 3.0);
}
@Test @Test
@WithMockUser(username = "admin@example.test", roles = "ADMIN") @WithMockUser(username = "admin@example.test", roles = "ADMIN")
void sharedComponentsExposeAccessibleFormsStatusAndEmptyState() throws Exception { void sharedComponentsExposeAccessibleFormsStatusAndEmptyState() throws Exception {
@@ -100,4 +124,48 @@ class UiContractWebTest {
return "test/components-consumer"; return "test/components-consumer";
} }
} }
private static String section(String css, String expression) {
Matcher matcher = Pattern.compile(expression).matcher(css);
assertTrue(matcher.find(), () -> "Missing CSS token section: " + expression);
return matcher.group(1);
}
private static void assertContrast(String section, String foreground, String background, double minimum) {
double ratio = contrast(color(section, foreground), color(section, background));
assertTrue(ratio >= minimum,
() -> foreground + " / " + background + " contrast " + ratio + " is below " + minimum);
}
private static String color(String section, String name) {
Matcher matcher = Pattern.compile("--" + Pattern.quote(name) + ":(#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?)")
.matcher(section);
assertTrue(matcher.find(), () -> "Missing CSS color token: " + name);
String value = matcher.group(1);
if (value.length() == 4) {
return "#" + value.charAt(1) + value.charAt(1)
+ value.charAt(2) + value.charAt(2)
+ value.charAt(3) + value.charAt(3);
}
return value;
}
private static double contrast(String first, String second) {
double lighter = Math.max(luminance(first), luminance(second));
double darker = Math.min(luminance(first), luminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
private static double luminance(String hex) {
double red = linear(Integer.parseInt(hex.substring(1, 3), 16) / 255.0);
double green = linear(Integer.parseInt(hex.substring(3, 5), 16) / 255.0);
double blue = linear(Integer.parseInt(hex.substring(5, 7), 16) / 255.0);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
private static double linear(double component) {
return component <= 0.04045
? component / 12.92
: Math.pow((component + 0.055) / 1.055, 2.4);
}
} }