#!/bin/bash # Round01.1 Space Lane dark-theme hover readability QA. # Verifies that dark theme keeps the light hover surface but flips Space tile # foreground elements to dark colors only when the tile actually uses a light # occupied active/associated fill. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # shellcheck source=build-output-paths.sh source "$SCRIPT_DIR/build-output-paths.sh" APP="$BUILD_CURRENT_APP" DOMAIN="com.ar.Aligner" THEME_KEY="com.ar.Aligner.preferences.appearance.theme" REPORT_WAIT="${ALIGNER_ROUND1_SPACE_DARK_HOVER_REPORT_WAIT:-6.0}" REPORT_DARK_BASE="$BUILD_REPORT_ROOT/round01-space-dark-hover-base-report.json" REPORT_DARK_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-report.json" REPORT_DARK_SPLIT_BASE="$BUILD_REPORT_ROOT/round01-space-dark-hover-split-base-report.json" REPORT_DARK_SPLIT_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-split-report.json" REPORT_DARK_EMPTY_BASE="$BUILD_REPORT_ROOT/round01-space-dark-hover-empty-base-report.json" REPORT_DARK_EMPTY_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-empty-report.json" REPORT_LIGHT_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-light-report.json" OLD_THEME_SET=0 OLD_THEME="" APP_PID="" fail() { echo "Round01.1 Space Lane dark hover QA failed: $*" >&2 exit 1 } aligner_pids_for_current_app() { ps -axo pid=,args= | while read -r pid command; do case "$command" in "$APP/Contents/MacOS/Aligner"*) echo "$pid" ;; esac done } stop_current_aligner() { if [ -n "${APP_PID:-}" ]; then kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true APP_PID="" fi for pid in $(aligner_pids_for_current_app); do kill "$pid" 2>/dev/null || true done for _ in {1..30}; do [ -z "$(aligner_pids_for_current_app)" ] && return sleep 0.1 done fail "current Aligner app did not exit before QA" } preserve_user_theme() { if OLD_THEME="$(/usr/bin/defaults read "$DOMAIN" "$THEME_KEY" 2>/dev/null)"; then OLD_THEME_SET=1 else OLD_THEME_SET=0 OLD_THEME="" fi } restore_user_theme() { if [ "$OLD_THEME_SET" -eq 1 ]; then /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$OLD_THEME" else /usr/bin/defaults delete "$DOMAIN" "$THEME_KEY" >/dev/null 2>&1 || true fi } wait_for_loaded_report() { local report="$1" local expected_theme="$2" local expected_mouse_command="${3:-}" /usr/bin/python3 - "$report" "$expected_theme" "$expected_mouse_command" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] expected_theme = sys.argv[2] expected_mouse_command = sys.argv[3] timeout = float(sys.argv[4]) deadline = time.monotonic() + timeout last_report = None while time.monotonic() < deadline: try: with open(path, "r", encoding="utf-8") as file: report = json.load(file) last_report = report root = report.get("rootView", {}) if ( report.get("snapshotLoaded") is True and report.get("quickSwitchVisible") is True and report.get("resolvedQuickSwitchTheme") == expected_theme and root.get("themeResolved") == expected_theme and (not expected_mouse_command or root.get("lastMouseCommand") == expected_mouse_command) ): sys.exit(0) except FileNotFoundError: pass except json.JSONDecodeError: pass time.sleep(0.2) if last_report is not None: print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr) print(f"report did not reach {expected_theme} within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } select_space_id() { local report="$1" local selector="$2" /usr/bin/python3 - "$report" "$selector" <<'PY' import json import sys with open(sys.argv[1], "r", encoding="utf-8") as file: report = json.load(file) selector = sys.argv[2] segments = report.get("rootView", {}).get("spaceLaneSegments", []) def unlocked(segment): return "locked" not in segment.get("visualStates", []) for segment in segments: if selector == "occupied" and segment.get("windowCount", 0) > 0 and unlocked(segment): print(segment["spaceID"]) sys.exit(0) if selector == "split" and len(segment.get("splitViewAppNames", [])) >= 2 and segment.get("windowCount", 0) > 0 and unlocked(segment): print(segment["spaceID"]) sys.exit(0) if selector == "empty" and segment.get("windowCount", 0) == 0 and unlocked(segment): print(segment["spaceID"]) sys.exit(0) print("", end="") PY } run_fixture() { local theme="$1" local report="$2" shift 2 stop_current_aligner /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme" rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count=4 \ --round01-fixture-windows-per-app=2 \ --round01-disable-screenshot-refresh \ --round01-quick-switch-report="$report" \ --round01-quick-switch-quit-after=1.8 \ "$@" >/dev/null 2>&1 & APP_PID=$! } run_split_fixture() { local theme="$1" local report="$2" shift 2 stop_current_aligner /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme" rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-split-view \ --round01-disable-screenshot-refresh \ --round01-quick-switch-report="$report" \ --round01-quick-switch-quit-after=1.8 \ "$@" >/dev/null 2>&1 & APP_PID=$! } run_space_filter_fixture() { local theme="$1" local report="$2" shift 2 stop_current_aligner /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme" rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-space-filter \ --round01-disable-screenshot-refresh \ --round01-quick-switch-report="$report" \ --round01-quick-switch-quit-after=1.8 \ "$@" >/dev/null 2>&1 & APP_PID=$! } assert_dark_on_light_hover() { local report="$1" local space_id="$2" local require_split="${3:-0}" /usr/bin/python3 - "$report" "$space_id" "$require_split" <<'PY' import json import sys path = sys.argv[1] space_id = int(sys.argv[2]) require_split = sys.argv[3] == "1" with open(path, "r", encoding="utf-8") as file: report = json.load(file) root = report.get("rootView", {}) segments = root.get("spaceLaneSegments", []) segment = next((item for item in segments if int(item.get("spaceID")) == space_id), None) def fail(message): print(message, file=sys.stderr) print(json.dumps(segment or report, indent=2, ensure_ascii=False), file=sys.stderr) sys.exit(1) def avg(color): return (color.get("red", 0) + color.get("green", 0) + color.get("blue", 0)) / 3 def require_dark(color, name, max_avg=0.36, min_alpha=0.12): if color.get("alpha", 0) < min_alpha or avg(color) > max_avg: fail(f"{name} must be dark enough on light hover fill") def require_light(color, name, min_avg=0.44, min_alpha=0.50): if color.get("alpha", 0) < min_alpha or avg(color) < min_avg: fail(f"{name} must remain a light hover fill") if root.get("themeResolved") != "dark" or report.get("resolvedQuickSwitchTheme") != "dark": fail("report must be in dark theme") if segment is None: fail("target space segment not found") if segment.get("isHovered") is not True or "hover" not in segment.get("visualStates", []): fail("target space must be hovered") if segment.get("usesLightActiveFill") is not True: fail("hovered occupied dark tile must report usesLightActiveFill") if segment.get("foregroundContrastStyle") != "darkOnLight": fail("hovered occupied dark tile must use darkOnLight foreground style") if segment.get("backgroundKind") != "solid": fail("hovered occupied dark tile must use a solid light hover background") require_light(segment.get("backgroundColor", {}), "hover background") require_dark(segment.get("labelColor", {}), "space label") require_dark(segment.get("countColor", {}), "window count") require_dark(segment.get("appColor", {}), "app text") if require_split and len(segment.get("splitViewAppNames", [])) < 2: fail("split fixture target must expose split view app names") for index, color in enumerate(segment.get("splitViewLabelColors", [])): require_dark(color, f"split app label {index}") if segment.get("fullscreenMarkerVisible"): require_dark(segment.get("fullscreenMarkerStrokeColor", {}), "fullscreen/split marker") for index, color in enumerate(segment.get("windowBlockColors", [])): require_dark(color, f"window block {index}", max_avg=0.38, min_alpha=0.05) print(json.dumps({ "spaceID": space_id, "label": segment.get("label"), "foregroundContrastStyle": segment.get("foregroundContrastStyle"), "backgroundKind": segment.get("backgroundKind"), "labelColor": segment.get("labelColor"), "countColor": segment.get("countColor"), "appColor": segment.get("appColor") }, indent=2, ensure_ascii=False)) PY } assert_no_light_active_fill() { local report="$1" local space_id="$2" local expected_theme="$3" /usr/bin/python3 - "$report" "$space_id" "$expected_theme" <<'PY' import json import sys with open(sys.argv[1], "r", encoding="utf-8") as file: report = json.load(file) space_id = int(sys.argv[2]) expected_theme = sys.argv[3] root = report.get("rootView", {}) segment = next((item for item in root.get("spaceLaneSegments", []) if int(item.get("spaceID")) == space_id), None) def fail(message): print(message, file=sys.stderr) print(json.dumps(segment or report, indent=2, ensure_ascii=False), file=sys.stderr) sys.exit(1) if root.get("themeResolved") != expected_theme or report.get("resolvedQuickSwitchTheme") != expected_theme: fail("theme mismatch") if segment is None: fail("target space segment not found") if segment.get("isHovered") is not True: fail("target space must be hovered") if segment.get("usesLightActiveFill") is not False: fail("segment must not use the dark-theme light active fill path") if segment.get("foregroundContrastStyle") != "themeDefault": fail("segment must keep theme default foreground style") print(json.dumps({ "theme": expected_theme, "spaceID": space_id, "label": segment.get("label"), "foregroundContrastStyle": segment.get("foregroundContrastStyle"), "backgroundKind": segment.get("backgroundKind"), "windowCount": segment.get("windowCount") }, indent=2, ensure_ascii=False)) PY } cleanup() { stop_current_aligner || true restore_user_theme } preserve_user_theme trap cleanup EXIT stop_current_aligner run_fixture "dark" "$REPORT_DARK_BASE" wait_for_loaded_report "$REPORT_DARK_BASE" "dark" OCCUPIED_SPACE_ID="$(select_space_id "$REPORT_DARK_BASE" occupied)" [ -n "$OCCUPIED_SPACE_ID" ] || fail "no occupied Space found in layout fixture" run_fixture "dark" "$REPORT_DARK_HOVER" --round01-debug-mouse-sequence="hover-space:$OCCUPIED_SPACE_ID" wait_for_loaded_report "$REPORT_DARK_HOVER" "dark" "hover-space:$OCCUPIED_SPACE_ID" assert_dark_on_light_hover "$REPORT_DARK_HOVER" "$OCCUPIED_SPACE_ID" run_split_fixture "dark" "$REPORT_DARK_SPLIT_BASE" wait_for_loaded_report "$REPORT_DARK_SPLIT_BASE" "dark" SPLIT_SPACE_ID="$(select_space_id "$REPORT_DARK_SPLIT_BASE" split)" [ -n "$SPLIT_SPACE_ID" ] || fail "no Split View Space found in split fixture" run_split_fixture "dark" "$REPORT_DARK_SPLIT_HOVER" --round01-debug-mouse-sequence="hover-space:$SPLIT_SPACE_ID" wait_for_loaded_report "$REPORT_DARK_SPLIT_HOVER" "dark" "hover-space:$SPLIT_SPACE_ID" assert_dark_on_light_hover "$REPORT_DARK_SPLIT_HOVER" "$SPLIT_SPACE_ID" 1 run_space_filter_fixture "dark" "$REPORT_DARK_EMPTY_BASE" wait_for_loaded_report "$REPORT_DARK_EMPTY_BASE" "dark" EMPTY_SPACE_ID="$(select_space_id "$REPORT_DARK_EMPTY_BASE" empty)" [ -n "$EMPTY_SPACE_ID" ] || fail "no empty Space found in space-filter fixture" run_space_filter_fixture "dark" "$REPORT_DARK_EMPTY_HOVER" --round01-debug-mouse-sequence="hover-space:$EMPTY_SPACE_ID" wait_for_loaded_report "$REPORT_DARK_EMPTY_HOVER" "dark" "hover-space:$EMPTY_SPACE_ID" assert_no_light_active_fill "$REPORT_DARK_EMPTY_HOVER" "$EMPTY_SPACE_ID" "dark" run_fixture "light" "$REPORT_LIGHT_HOVER" --round01-debug-mouse-sequence="hover-space:$OCCUPIED_SPACE_ID" wait_for_loaded_report "$REPORT_LIGHT_HOVER" "light" "hover-space:$OCCUPIED_SPACE_ID" assert_no_light_active_fill "$REPORT_LIGHT_HOVER" "$OCCUPIED_SPACE_ID" "light" stop_current_aligner restore_user_theme trap - EXIT cat <