diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 9f03213..38cd6e3 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/InteractionStateModel.h b/editor/src/InteractionStateModel.h new file mode 100644 index 0000000..af7e07a --- /dev/null +++ b/editor/src/InteractionStateModel.h @@ -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 +#include +#include +#include +#include + +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 resolveForAllStates( + const InteractionTokens& tokens) { + std::map 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 contrastFailures(const InteractionTokens& tokens) { + std::vector 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); + } +}; diff --git a/editor/tests/step511_test.cpp b/editor/tests/step511_test.cpp new file mode 100644 index 0000000..701857c --- /dev/null +++ b/editor/tests/step511_test.cpp @@ -0,0 +1,142 @@ +// Step 511: Interaction State Model (12 tests) + +#include "InteractionStateModel.h" + +#include + +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; +} diff --git a/progress.md b/progress.md index cb9852f..8a2b329 100644 --- a/progress.md +++ b/progress.md @@ -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`