fix(ui): enforce accessible theme contrast
This commit is contained in:
@@ -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,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user