Step 511: add shared interaction state model and contrast checks

This commit is contained in:
Bill
2026-02-17 08:49:37 -07:00
parent c7e1690299
commit 84d67d8da4
4 changed files with 353 additions and 0 deletions

View File

@@ -3478,4 +3478,13 @@ target_link_libraries(step510_test PRIVATE
tree_sitter_javascript tree_sitter_typescript
tree_sitter_java tree_sitter_rust tree_sitter_go)
add_executable(step511_test tests/step511_test.cpp)
target_include_directories(step511_test PRIVATE src)
target_link_libraries(step511_test PRIVATE
nlohmann_json::nlohmann_json
unofficial::tree-sitter::tree-sitter
tree_sitter_python tree_sitter_cpp tree_sitter_elisp
tree_sitter_javascript tree_sitter_typescript
tree_sitter_java tree_sitter_rust tree_sitter_go)
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)

View File

@@ -0,0 +1,166 @@
#pragma once
// Step 511: Interaction State Model
// Shared token-driven interaction state logic for hover/focus/active/pressed/
// disabled with deterministic style selection and contrast validation.
#include <string>
#include <map>
#include <vector>
#include <algorithm>
#include <cmath>
enum class InteractionState {
Idle,
Hover,
Focus,
Active,
Pressed,
Disabled
};
struct RGBA {
float r = 0.0f;
float g = 0.0f;
float b = 0.0f;
float a = 1.0f;
};
struct InteractionTokens {
RGBA surface;
RGBA surfaceHover;
RGBA surfaceFocus;
RGBA surfaceActive;
RGBA surfacePressed;
RGBA surfaceDisabled;
RGBA text;
RGBA textDisabled;
RGBA border;
RGBA focusRing;
};
struct InteractionSnapshot {
bool hovered = false;
bool focused = false;
bool active = false;
bool pressed = false;
bool disabled = false;
};
struct StyleResolution {
InteractionState state = InteractionState::Idle;
RGBA surface;
RGBA text;
RGBA border;
bool showFocusRing = false;
float contrastRatio = 1.0f;
bool contrastPassAA = false;
};
class InteractionStateModel {
public:
static InteractionTokens defaultDarkTokens() {
return {
{0.10f, 0.10f, 0.10f, 1.0f},
{0.15f, 0.15f, 0.15f, 1.0f},
{0.12f, 0.15f, 0.20f, 1.0f},
{0.16f, 0.16f, 0.18f, 1.0f},
{0.20f, 0.20f, 0.22f, 1.0f},
{0.12f, 0.12f, 0.12f, 0.6f},
{0.90f, 0.90f, 0.90f, 1.0f},
{0.55f, 0.55f, 0.55f, 1.0f},
{0.30f, 0.30f, 0.30f, 1.0f},
{0.18f, 0.56f, 0.95f, 1.0f}
};
}
static InteractionState resolveState(const InteractionSnapshot& s) {
if (s.disabled) return InteractionState::Disabled;
if (s.pressed) return InteractionState::Pressed;
if (s.active) return InteractionState::Active;
if (s.focused) return InteractionState::Focus;
if (s.hovered) return InteractionState::Hover;
return InteractionState::Idle;
}
static StyleResolution resolveStyle(const InteractionSnapshot& snapshot,
const InteractionTokens& tokens) {
StyleResolution out;
out.state = resolveState(snapshot);
out.border = tokens.border;
switch (out.state) {
case InteractionState::Idle:
out.surface = tokens.surface;
out.text = tokens.text;
break;
case InteractionState::Hover:
out.surface = tokens.surfaceHover;
out.text = tokens.text;
break;
case InteractionState::Focus:
out.surface = tokens.surfaceFocus;
out.text = tokens.text;
out.showFocusRing = true;
break;
case InteractionState::Active:
out.surface = tokens.surfaceActive;
out.text = tokens.text;
break;
case InteractionState::Pressed:
out.surface = tokens.surfacePressed;
out.text = tokens.text;
break;
case InteractionState::Disabled:
out.surface = tokens.surfaceDisabled;
out.text = tokens.textDisabled;
break;
}
out.contrastRatio = contrast(out.text, out.surface);
out.contrastPassAA = out.contrastRatio >= 4.5f;
return out;
}
static std::map<std::string, StyleResolution> resolveForAllStates(
const InteractionTokens& tokens) {
std::map<std::string, StyleResolution> out;
out["idle"] = resolveStyle({}, tokens);
out["hover"] = resolveStyle({true, false, false, false, false}, tokens);
out["focus"] = resolveStyle({false, true, false, false, false}, tokens);
out["active"] = resolveStyle({true, true, true, false, false}, tokens);
out["pressed"] = resolveStyle({true, true, true, true, false}, tokens);
out["disabled"] = resolveStyle({false, false, false, false, true}, tokens);
return out;
}
static std::vector<std::string> contrastFailures(const InteractionTokens& tokens) {
std::vector<std::string> failures;
auto all = resolveForAllStates(tokens);
for (const auto& [name, style] : all) {
if (!style.contrastPassAA) {
failures.push_back(name + ":" + std::to_string(style.contrastRatio));
}
}
return failures;
}
private:
static float channel(float c) {
if (c <= 0.03928f) return c / 12.92f;
return std::pow((c + 0.055f) / 1.055f, 2.4f);
}
static float luminance(const RGBA& c) {
float r = channel(std::max(0.0f, std::min(1.0f, c.r)));
float g = channel(std::max(0.0f, std::min(1.0f, c.g)));
float b = channel(std::max(0.0f, std::min(1.0f, c.b)));
return 0.2126f * r + 0.7152f * g + 0.0722f * b;
}
static float contrast(const RGBA& fg, const RGBA& bg) {
float l1 = luminance(fg);
float l2 = luminance(bg);
if (l1 < l2) std::swap(l1, l2);
return (l1 + 0.05f) / (l2 + 0.05f);
}
};

View File

@@ -0,0 +1,142 @@
// Step 511: Interaction State Model (12 tests)
#include "InteractionStateModel.h"
#include <iostream>
static int passed = 0, failed = 0;
#define TEST(name) { std::cout << " " << #name << "... "; }
#define PASS() { std::cout << "PASS\n"; ++passed; }
#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; }
#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {}
void test_state_priority_disabled_wins() {
TEST(state_priority_disabled_wins);
InteractionSnapshot s{true, true, true, true, true};
CHECK(InteractionStateModel::resolveState(s) == InteractionState::Disabled,
"disabled should dominate");
PASS();
}
void test_state_priority_pressed_over_active_focus_hover() {
TEST(state_priority_pressed_over_active_focus_hover);
InteractionSnapshot s{true, true, true, true, false};
CHECK(InteractionStateModel::resolveState(s) == InteractionState::Pressed,
"pressed should dominate active/focus/hover");
PASS();
}
void test_state_priority_active_over_focus_hover() {
TEST(state_priority_active_over_focus_hover);
InteractionSnapshot s{true, true, true, false, false};
CHECK(InteractionStateModel::resolveState(s) == InteractionState::Active,
"active should dominate focus/hover");
PASS();
}
void test_focus_state_sets_focus_ring() {
TEST(focus_state_sets_focus_ring);
auto style = InteractionStateModel::resolveStyle({false, true, false, false, false},
InteractionStateModel::defaultDarkTokens());
CHECK(style.state == InteractionState::Focus, "expected focus state");
CHECK(style.showFocusRing, "focus ring should be enabled");
PASS();
}
void test_disabled_state_uses_disabled_text() {
TEST(disabled_state_uses_disabled_text);
auto t = InteractionStateModel::defaultDarkTokens();
auto style = InteractionStateModel::resolveStyle({false, false, false, false, true}, t);
CHECK(style.state == InteractionState::Disabled, "expected disabled state");
CHECK(style.text.r == t.textDisabled.r && style.text.g == t.textDisabled.g,
"disabled text token not applied");
PASS();
}
void test_idle_state_uses_base_surface() {
TEST(idle_state_uses_base_surface);
auto t = InteractionStateModel::defaultDarkTokens();
auto style = InteractionStateModel::resolveStyle({}, t);
CHECK(style.state == InteractionState::Idle, "expected idle state");
CHECK(style.surface.r == t.surface.r && style.surface.g == t.surface.g,
"idle surface token not applied");
PASS();
}
void test_resolve_for_all_states_returns_six_entries() {
TEST(resolve_for_all_states_returns_six_entries);
auto all = InteractionStateModel::resolveForAllStates(
InteractionStateModel::defaultDarkTokens());
CHECK(all.size() == 6, "expected 6 state entries");
PASS();
}
void test_default_tokens_pass_contrast_for_core_states() {
TEST(default_tokens_pass_contrast_for_core_states);
auto all = InteractionStateModel::resolveForAllStates(
InteractionStateModel::defaultDarkTokens());
CHECK(all["idle"].contrastPassAA, "idle contrast should pass");
CHECK(all["hover"].contrastPassAA, "hover contrast should pass");
CHECK(all["focus"].contrastPassAA, "focus contrast should pass");
PASS();
}
void test_contrast_failures_detect_low_contrast_tokens() {
TEST(contrast_failures_detect_low_contrast_tokens);
auto t = InteractionStateModel::defaultDarkTokens();
t.text = {0.2f, 0.2f, 0.2f, 1.0f};
t.surface = {0.18f, 0.18f, 0.18f, 1.0f};
auto fails = InteractionStateModel::contrastFailures(t);
CHECK(!fails.empty(), "expected contrast failures");
PASS();
}
void test_focus_ring_not_shown_for_non_focus_states() {
TEST(focus_ring_not_shown_for_non_focus_states);
auto t = InteractionStateModel::defaultDarkTokens();
auto hover = InteractionStateModel::resolveStyle({true, false, false, false, false}, t);
auto active = InteractionStateModel::resolveStyle({true, true, true, false, false}, t);
CHECK(!hover.showFocusRing, "hover should not show focus ring");
CHECK(!active.showFocusRing, "active should not show focus ring");
PASS();
}
void test_pressed_and_active_surfaces_differ() {
TEST(pressed_and_active_surfaces_differ);
auto t = InteractionStateModel::defaultDarkTokens();
auto pressed = InteractionStateModel::resolveStyle({true, true, true, true, false}, t);
auto active = InteractionStateModel::resolveStyle({true, true, true, false, false}, t);
CHECK(pressed.surface.r != active.surface.r ||
pressed.surface.g != active.surface.g ||
pressed.surface.b != active.surface.b,
"pressed and active surfaces should differ");
PASS();
}
void test_contrast_ratio_is_positive() {
TEST(contrast_ratio_is_positive);
auto style = InteractionStateModel::resolveStyle({},
InteractionStateModel::defaultDarkTokens());
CHECK(style.contrastRatio > 1.0f, "contrast ratio should be > 1");
PASS();
}
int main() {
std::cout << "Step 511: Interaction State Model\n";
test_state_priority_disabled_wins(); // 1
test_state_priority_pressed_over_active_focus_hover(); // 2
test_state_priority_active_over_focus_hover(); // 3
test_focus_state_sets_focus_ring(); // 4
test_disabled_state_uses_disabled_text(); // 5
test_idle_state_uses_base_surface(); // 6
test_resolve_for_all_states_returns_six_entries(); // 7
test_default_tokens_pass_contrast_for_core_states(); // 8
test_contrast_failures_detect_low_contrast_tokens(); // 9
test_focus_ring_not_shown_for_non_focus_states(); // 10
test_pressed_and_active_surfaces_differ(); // 11
test_contrast_ratio_is_positive(); // 12
std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n";
return failed == 0 ? 0 : 1;
}

View File

@@ -8072,3 +8072,39 @@ to enforce reliable keyboard/mouse resize behavior.
- `editor/src/PanelBoundaryReliability.h` within header-size limit (`140` <= `600`)
- `editor/tests/step510_test.cpp` within test-file size guidance (`146` lines)
- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`
### Step 511: Interaction State Model
**Status:** PASS (12/12 tests)
Implements a shared interaction-state model for hover/focus/active/pressed/
disabled with deterministic token-driven style resolution and WCAG AA contrast
checks for state transitions.
**Files added:**
- `editor/src/InteractionStateModel.h` - state model module:
- precedence-based state resolver (`disabled > pressed > active > focus > hover > idle`)
- token-driven style resolution for surface/text/border/focus ring
- contrast-ratio computation with AA pass/fail signal per state
- whole-state map resolver and failure extraction for audits
- `editor/tests/step511_test.cpp` - 12 tests covering:
- state-precedence ordering correctness
- focus-ring semantics
- disabled/idle token application
- all-state resolution completeness
- AA contrast pass for default dark tokens
- low-contrast failure detection
- pressed-vs-active visual differentiation
**Files modified:**
- `editor/CMakeLists.txt` - `step511_test` target
**Verification run:**
- `cmake -S editor -B editor/build-native` - PASS
- `cmake --build editor/build-native --target step511_test` - PASS
- `./editor/build-native/step511_test` - PASS (12/12)
- `./editor/build-native/step510_test` - PASS (12/12) regression coverage
**Architecture gate check:**
- `editor/src/InteractionStateModel.h` within header-size limit (`166` <= `600`)
- `editor/tests/step511_test.cpp` within test-file size guidance (`142` lines)
- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`