Skip to content

Commit ee6efa9

Browse files
Merge pull request #279 from SceneTech/feature/css-accessibility-preferences-255
Add host CSS accessibility preference state
2 parents 28d1d64 + aaab02a commit ee6efa9

26 files changed

Lines changed: 806 additions & 13 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# CSS accessibility preference qualification
2+
3+
This gate covers the product-neutral preference slice implemented for issue #255:
4+
5+
- host publication of forced-colors, reduced-motion, and increased-contrast state;
6+
- live `forced-colors`, `prefers-reduced-motion`, and `prefers-contrast` media-query updates;
7+
- computed system colors plus inherited `color-scheme` and `accent-color` values;
8+
- a public managed host property and versioned native C ABI with unknown-flag rejection;
9+
- bounded refresh and cleanup across 4,096 controls and 100 enable/restore cycles.
10+
11+
## Direct gates
12+
13+
| Gate | Denominator | Result |
14+
| --- | ---: | --- |
15+
| Native WPT profile | 1 document / 10 subtests | 1/1 and 10/10 passed |
16+
| Chrome 153 oracle | 1 document / 10 subtests | 1/1 and 10/10 passed |
17+
| Native preference lifecycle/performance | 4,096 controls / 100 cycles / 200 states | passed in 8,394.38 ms; 8,194..8,198 fixture-node bound; at most 1 retained node |
18+
| Portable CSS tests | 364 tests / 2 target frameworks | 364/364 on net8.0 and 364/364 on net10.0 |
19+
| Managed host build | 1 target | 1/1 passed with 0 warnings and 0 errors |
20+
| Portable native build | 1 target | 1/1 passed |
21+
| V8 dedicated native target | 1 target / 1 CTest | 1/1 built and 1/1 passed |
22+
23+
The native gate has a 15,000 ms ceiling, requires zero host animation-frame demand
24+
after the final settled state, waits for a completed low-memory notification, limits
25+
post-cleanup V8 heap growth to 32 MiB, and rejects unknown ABI flag bits. The two
26+
retained runner reports are bounded to 64 KiB each; their combined observed size is
27+
5,187 bytes.
28+
29+
The monolithic native test executable is excluded from this change-related gate
30+
because it reaches the pre-existing four-engine resource-deduplication assertion.
31+
The dedicated `webscene_css_accessibility_preferences_tests` target isolates the
32+
#255 semantic, performance, memory, and lifecycle checks from that baseline failure.
33+
34+
Machine-readable results and report digests are recorded in
35+
`docs/validation/evidence/css-accessibility-preferences-255-20260917.json`.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"schema": "webscene-css-accessibility-preferences-qualification-v1",
3+
"recordedAt": "2026-09-17",
4+
"baseCommit": "28d1d649e927c665ff05844197af44f223edf952",
5+
"issue": 255,
6+
"gates": {
7+
"nativeWpt": {
8+
"engineIdentity": "abi=3;sha256=524b39d85d81d84a2a05519b1d6ecfaeb64ee582237137bdd99172bd36ff02de",
9+
"profileSha256": "c66cd93274676da6182295676a30780bae138c48e1fa402d3813e4973048fd74",
10+
"documentsPassed": 1,
11+
"documentsTotal": 1,
12+
"subtestsPassed": 10,
13+
"subtestsTotal": 10,
14+
"durationSeconds": 0.091843,
15+
"reportBytes": 2767,
16+
"reportMaximumBytes": 65536,
17+
"reportSha256": "ee64c9ff3fc8b847154c3709af914100cda909843883108a17c2a1a3a7abacb2"
18+
},
19+
"chromeOracle": {
20+
"identity": "Google Chrome 153.0.8010.48",
21+
"documentsPassed": 1,
22+
"documentsTotal": 1,
23+
"subtestsPassed": 10,
24+
"subtestsTotal": 10,
25+
"durationMilliseconds": 78,
26+
"reportBytes": 2420,
27+
"reportMaximumBytes": 65536,
28+
"reportSha256": "762fca67035a191c38ff2102f36eff1284d5788ab06f4d3936a8dcebd164494e"
29+
},
30+
"nativeLifecycle": {
31+
"controls": 4096,
32+
"cycles": 100,
33+
"states": 200,
34+
"elapsedMilliseconds": 8394.38,
35+
"maximumMilliseconds": 15000,
36+
"minimumFixtureNodeDelta": 8194,
37+
"maximumFixtureNodeDelta": 8198,
38+
"maximumRetainedNodeDelta": 1,
39+
"settledAnimationFrameDemand": 0,
40+
"maximumPostCleanupHeapGrowthBytes": 33554432,
41+
"unknownFlagSetsAccepted": 0
42+
},
43+
"portableCss": {
44+
"net8TestsPassed": 364,
45+
"net8TestsTotal": 364,
46+
"net10TestsPassed": 364,
47+
"net10TestsTotal": 364
48+
},
49+
"managedHostBuild": {
50+
"targetsPassed": 1,
51+
"targetsTotal": 1,
52+
"warnings": 0,
53+
"errors": 0
54+
},
55+
"portableNativeBuild": {
56+
"targetsPassed": 1,
57+
"targetsTotal": 1
58+
},
59+
"v8Native": {
60+
"targetsBuilt": 1,
61+
"targetsTotal": 1,
62+
"ctestsPassed": 1,
63+
"ctestsTotal": 1
64+
}
65+
},
66+
"ownership": {
67+
"pullRequest245Head": "c7859becc0f6799d206f0974534c0db3a589f950",
68+
"pullRequest245Files": 52,
69+
"overlappingFiles": 0
70+
},
71+
"knownBaselineFailure": {
72+
"gate": "webscene_native_engine_tests",
73+
"message": "identical resources (including warmup) were loaded more than once across four engines",
74+
"changeRelated": false,
75+
"excludedFromFocusedGate": true
76+
}
77+
}

‎experiments/WebScene.NativeEngine.Probe/CMakeLists.txt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,16 @@ if(WEBSCENE_NATIVE_ENGINE_ENABLE_V8)
794794
set_tests_properties(webscene_css_containment_tests PROPERTIES
795795
TIMEOUT 30
796796
LABELS "native;css;containment;performance;lifecycle")
797+
add_executable(webscene_css_accessibility_preferences_tests
798+
tests/native_css_accessibility_preferences_tests.cpp)
799+
target_compile_features(webscene_css_accessibility_preferences_tests PRIVATE cxx_std_20)
800+
target_link_libraries(webscene_css_accessibility_preferences_tests PRIVATE
801+
webscene_native_engine)
802+
add_test(NAME webscene_css_accessibility_preferences_tests
803+
COMMAND webscene_css_accessibility_preferences_tests)
804+
set_tests_properties(webscene_css_accessibility_preferences_tests PROPERTIES
805+
TIMEOUT 30
806+
LABELS "native;css;accessibility;performance;lifecycle")
797807
add_test(NAME webscene_native_idle_v8_platform COMMAND webscene_native_engine_tests)
798808
add_test(NAME webscene_native_dom_punctuation_keyboard COMMAND webscene_native_engine_tests)
799809
add_test(NAME webscene_native_shortcut_activation COMMAND webscene_native_engine_tests)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#pragma once
2+
3+
#include <algorithm>
4+
#include <cctype>
5+
#include <cstdint>
6+
#include <optional>
7+
#include <string>
8+
#include <string_view>
9+
10+
namespace webscene_native::css {
11+
12+
struct accessibility_preferences final {
13+
bool dark{false};
14+
bool forced_colors{false};
15+
bool reduced_motion{false};
16+
bool contrast_more{false};
17+
};
18+
19+
inline thread_local accessibility_preferences active_accessibility_preferences{};
20+
21+
inline void set_active_accessibility_preferences(
22+
accessibility_preferences preferences) noexcept
23+
{
24+
active_accessibility_preferences = preferences;
25+
}
26+
27+
inline accessibility_preferences get_active_accessibility_preferences() noexcept
28+
{
29+
return active_accessibility_preferences;
30+
}
31+
32+
inline std::optional<uint32_t> system_color_rgba(std::string_view input)
33+
{
34+
auto name = std::string(input);
35+
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char value) {
36+
return static_cast<char>(std::tolower(value));
37+
});
38+
const auto preferences = get_active_accessibility_preferences();
39+
const auto dark = preferences.dark;
40+
const auto forced = preferences.forced_colors;
41+
42+
if (name == "canvas") return dark ? 0x1e1e1effU : 0xffffffffU;
43+
if (name == "canvastext") return dark ? 0xffffffffU : 0x000000ffU;
44+
if (name == "buttonface" || name == "field") {
45+
return forced ? (dark ? 0x000000ffU : 0xffffffffU)
46+
: (dark ? 0x333333ffU : 0xefefefffU);
47+
}
48+
if (name == "buttontext" || name == "fieldtext") {
49+
return dark ? 0xffffffffU : 0x000000ffU;
50+
}
51+
if (name == "graytext") return dark ? 0xaaaaaaffU : 0x6d6d6dffU;
52+
if (name == "highlight" || name == "selecteditem" || name == "accentcolor") {
53+
return dark ? 0x6aa9ffffU : 0x0067c0ffU;
54+
}
55+
if (name == "highlighttext" || name == "selecteditemtext"
56+
|| name == "accentcolortext") return 0xffffffffU;
57+
if (name == "linktext") return dark ? 0x8ab4f8ffU : 0x0000eeffU;
58+
if (name == "visitedtext") return dark ? 0xc58af9ffU : 0x551a8bffU;
59+
if (name == "activetext") return 0xff0000ffU;
60+
if (name == "mark") return 0xffff00ffU;
61+
if (name == "marktext") return 0x000000ffU;
62+
return std::nullopt;
63+
}
64+
65+
} // namespace webscene_native::css

‎experiments/WebScene.NativeEngine.Probe/native/webscene_css_media.h‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,36 @@
11
#pragma once
22
#include "webscene_css_container_queries.h"
33
#include "webscene_css_matching.h"
4+
#include "webscene_accessibility_preferences.h"
45
#include <cstdlib>
56

67
namespace webscene_native::css {
7-
struct media_environment { float width; float height; bool dark=false; bool reduced_motion=false; };
8+
struct media_environment {
9+
float width{};
10+
float height{};
11+
bool dark{false};
12+
bool reduced_motion{false};
13+
bool forced_colors{false};
14+
bool contrast_more{false};
15+
16+
media_environment() = default;
17+
media_environment(float width_value, float height_value)
18+
: media_environment(width_value, height_value, false) {}
19+
media_environment(float width_value, float height_value, bool dark_value)
20+
: width(width_value), height(height_value), dark(dark_value)
21+
{
22+
const auto preferences = get_active_accessibility_preferences();
23+
reduced_motion = preferences.reduced_motion;
24+
forced_colors = preferences.forced_colors;
25+
contrast_more = preferences.contrast_more;
26+
}
27+
media_environment(float width_value, float height_value, bool dark_value,
28+
bool reduced_motion_value, bool forced_colors_value = false,
29+
bool contrast_more_value = false)
30+
: width(width_value), height(height_value), dark(dark_value),
31+
reduced_motion(reduced_motion_value), forced_colors(forced_colors_value),
32+
contrast_more(contrast_more_value) {}
33+
};
834
inline bool media_matches(std::string query, const media_environment& environment)
935
{
1036
if (is_container_query(query)) return true;
@@ -85,6 +111,10 @@ inline bool media_matches(std::string query, const media_environment& environmen
85111
environment.dark
86112
? "dark"
87113
: "light");
114+
} else if (feature == "forced-colors") {
115+
matches = value == (environment.forced_colors ? "active" : "none");
116+
} else if (feature == "prefers-contrast") {
117+
matches = value == (environment.contrast_more ? "more" : "no-preference");
88118
} else {
89119
matches = false;
90120
}
@@ -152,7 +182,8 @@ bool inventory_media(std::string query, Record&& record_feature)
152182
static const std::unordered_set<std::string> supported_features{
153183
"max-width", "min-width", "max-height", "min-height", "orientation",
154184
"hover", "any-hover", "pointer", "any-pointer",
155-
"prefers-reduced-motion", "prefers-color-scheme"};
185+
"prefers-reduced-motion", "prefers-color-scheme",
186+
"forced-colors", "prefers-contrast"};
156187
const auto known = range_syntax || (separator != std::string::npos
157188
&& supported_features.contains(feature));
158189
record_feature(

‎experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ enum inline_style_property : uint64_t {
6767
inline_scrollbar_color = 1ULL << 59U,
6868
inline_svg_stroke_width = 1ULL << 60U,
6969
inline_align_content = 1ULL << 61U,
70+
inline_accessibility_colors = 1ULL << 62U,
7071
inline_containment_features = 1ULL << 63U,
7172
inline_transition = inline_transition_property | inline_transition_duration
7273
| inline_transition_delay | inline_transition_timing
@@ -134,6 +135,9 @@ inline uint64_t property_mask(std::string_view name)
134135
if (name == "visibility") return inline_visibility;
135136
if (name == "pointer-events") return inline_pointer_events;
136137
if (name == "color") return inline_color;
138+
if (name == "color-scheme" || name == "accent-color") {
139+
return inline_accessibility_colors;
140+
}
137141
if (name == "fill") return inline_svg_fill;
138142
if (name == "stroke") return inline_svg_stroke;
139143
if (name == "text-anchor") return inline_svg_text_anchor;

‎experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ inline void apply_all_unset(dom_node& node)
5858
textual.contain_intrinsic_size = previous.textual().contain_intrinsic_size;
5959
reset.content_visibility_hidden = previous.content_visibility_hidden;
6060
}
61+
if (is_inline(inline_accessibility_colors)) {
62+
reset.mutable_textual().color_scheme = previous.textual().color_scheme;
63+
reset.mutable_textual().accent_color = previous.textual().accent_color;
64+
}
6165
if (is_inline(inline_padding)) {
6266
reset.padding_left = previous.padding_left;
6367
reset.padding_top = previous.padding_top;

‎experiments/WebScene.NativeEngine.Probe/native/webscene_css_text_values.h‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,32 @@ bool apply_text_value(dom_node& node,const std::string& name,const std::string&
263263
} else if (value == "inherit" || value == "unset") {
264264
node.style.mutable_textual().svg_text_anchor.clear();
265265
}
266+
} else if (name == "color-scheme" && !is_inline(inline_accessibility_colors)) {
267+
const auto normalized = value == "inherit" || value == "unset"
268+
? std::string{} : value == "initial" ? std::string{"normal"} : value;
269+
if (normalized.empty() || normalized == "normal" || normalized == "light"
270+
|| normalized == "dark" || normalized == "light dark"
271+
|| normalized == "dark light" || normalized == "only light"
272+
|| normalized == "only dark") {
273+
node.style.mutable_textual().color_scheme = normalized;
274+
} else {
275+
decision.classification = "unsupported";
276+
decision.semantic_slice = "normal, light, dark, only, and inheritance";
277+
}
278+
} else if (name == "accent-color" && !is_inline(inline_accessibility_colors)) {
279+
if (value == "inherit" || value == "unset") {
280+
node.style.mutable_textual().accent_color.clear();
281+
} else if (value == "initial" || value == "auto") {
282+
node.style.mutable_textual().accent_color = "auto";
283+
} else {
284+
const auto parsed = native_document::parse_color(value);
285+
if (is_explicit_color_token(value, parsed)) {
286+
node.style.mutable_textual().accent_color = value;
287+
} else {
288+
decision.classification = "unsupported";
289+
decision.semantic_slice = "auto, CSS colors, system colors, and inheritance";
290+
}
291+
}
266292
} else if (name == "cursor" && !is_inline(inline_cursor)) {
267293
node.style.mutable_textual().cursor =
268294
value == "initial" ? "auto" : value;

‎experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include "webscene_native_engine.h"
44
#include "webscene_named_colors.h"
5+
#include "webscene_accessibility_preferences.h"
56
#include "graphics/canvas_backing.h"
67
#include "graphics/image_lease_abi.h"
78

@@ -575,6 +576,9 @@ struct node_style final {
575576
std::string container_name{"none"};
576577
std::string content_visibility{"visible"};
577578
css_length contain_intrinsic_size{};
579+
// Empty means inherit; computed CSSOM supplies the initial root value.
580+
std::string color_scheme;
581+
std::string accent_color;
578582
// Authored cursor token. Cursor is inherited, so an empty value means
579583
// the host projection resolves the nearest declaration or `auto`.
580584
std::string cursor;

‎experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_css_values.inc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ uint32_t native_document::parse_color(const std::string& value)
254254
return static_cast<char>(std::tolower(character));
255255
});
256256

257+
if (const auto system = css::system_color_rgba(normalized)) return *system;
257258
if (const auto named = named_color_rgba(normalized)) return *named;
258259
if ((normalized.starts_with("hsl(") || normalized.starts_with("hsla("))
259260
&& normalized.ends_with(')')) {

0 commit comments

Comments
 (0)