diff --git a/openpilot/selfdrive/controls/controlsd.py b/openpilot/selfdrive/controls/controlsd.py index 05a01cc5a6..f1d037ff3e 100755 --- a/openpilot/selfdrive/controls/controlsd.py +++ b/openpilot/selfdrive/controls/controlsd.py @@ -73,7 +73,7 @@ class Controls: self.lateral_controller_selection = read_selection(self.params, self.CP) self.vehicle_startup_preferences = VehicleStartupPreferences.read( self.params, enabled=self.params.get_bool("OpenpilotEnabledToggle")) - self.torque_learning_allowed = learning_allowed(self.params, self.CP, selection=self.lateral_controller_selection) + manual_effects, manual_status = self._configure_manual_tune() self.aol_replay = feature_enabled(self.params, self.CP, 'aol', os.environ) self.aol_transition_diagnostic = TransitionDiagnostic() if self.aol_replay and self.CP.brand == 'gm' else None self.ordinary_axis_ack_required = axis_policy_for(self.CP).ordinary_axis_ack_required @@ -123,11 +123,16 @@ class Controls: self.LaC = create_lateral_controller(self.CP, self.CI, DT_CTRL, mode=self.lateral_controller_selection.mode, turn_assist=self.vehicle_startup_preferences.turn_assist) if isinstance(self.LaC, LatControlTorque): + # Only the actual startup owner reads configuration; replay clones stay pure. + if manual_effects is not None and manual_effects.profile.context[1] != str(self.LaC.controller_mode): + manual_effects, manual_status = None, 'context_changed' + self.LaC.manual_flm_effects, self.LaC.manual_flm_status = manual_effects, manual_status self.lateral_gain_owner = create_gain_owner(self.params, self.CP, self.LaC) cloudlog.info({"event": "torque controller selected", "controller": self.LaC.controller_mode.value, "source": self.lateral_controller_selection.source, "vehicle": self.CP.carFingerprint, "policy": self.LaC.controller_policy, "automaticLearning": self.torque_learning_allowed, "startupParameterSource": "vehicle", + "manualCoordinateStatus": self.LaC.manual_flm_status, "startupTorque": {"factor": self.LaC.torque_params.latAccelFactor, "offset": self.LaC.torque_params.latAccelOffset, "friction": self.LaC.torque_params.friction}, @@ -152,6 +157,15 @@ class Controls: assist_enabled = self.vehicle_startup_preferences.turn_assist and model_turn_assist_supported(self.CP) self.turn_assist_enabled = lambda: assist_enabled + def _configure_manual_tune(self): + manual_effects, manual_status = None, 'not_configured' + if self.CP.lateralTuning.which() == 'torque' and str(self.CP.steerControlType) == 'torque': + from openpilot.starpilot.flm.manual_coordinates import load as load_manual_coordinates + manual_effects, manual_status = load_manual_coordinates(self.CP, self.lateral_controller_selection.mode, self.params) + self.torque_learning_allowed = bool(manual_status == 'not_configured' and + learning_allowed(self.params, self.CP, selection=self.lateral_controller_selection)) + return manual_effects, manual_status + def update(self, timeout: int = 15): self.sm.update(timeout) if self.sm.updated["extrinsicsCalibration"]: diff --git a/openpilot/selfdrive/controls/lib/latcontrol_torque.py b/openpilot/selfdrive/controls/lib/latcontrol_torque.py index 2b07a370a6..c6cc74b6af 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_torque.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_torque.py @@ -67,6 +67,8 @@ class LatControlTorque(LatControl): self.pid._k_p = [[0.0], [STARPILOT_KP]] self.flm_source = flm.GmLiveSource(CP, self.controller_mode) self.flm_surface = None + self.manual_flm_effects = None + self.manual_flm_status = 'not_configured' from openpilot.starpilot.flm.runtime_adapter_registry import baseline_values self.native_learning_baseline = baseline_values(self) self.native_learning_refinement = None @@ -96,6 +98,8 @@ class LatControlTorque(LatControl): def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay): self.flm_surface = self.flm_source.sample(active=active, speed=CS.vEgo) + if self.manual_flm_effects is not None: + self.manual_flm_effects.begin_frame() if self.starpilot_extension is not None: result = self.starpilot_extension.update(active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay) diff --git a/openpilot/selfdrive/ui/layouts/arabesque/tools/clock.py b/openpilot/selfdrive/ui/layouts/arabesque/tools/clock.py index 5e87d7b46d..aaa0741bc6 100644 --- a/openpilot/selfdrive/ui/layouts/arabesque/tools/clock.py +++ b/openpilot/selfdrive/ui/layouts/arabesque/tools/clock.py @@ -206,24 +206,24 @@ class ClockEditor(Widget): self._dragged = False self._keyboard_focus = False - def _handle_mouse_event(self, event): + def _handle_mouse_event(self, mouse_event): if not isinstance(self._pressed, int): return - if event.left_pressed: - self._last_t = self._last_motion = event.t + if mouse_event.left_pressed: + self._last_t = self._last_motion = mouse_event.t return - delta = event.pos.y - self._last_y - self._dragged |= abs(event.pos.y - self._start_y) > 12 * self._scale + delta = mouse_event.pos.y - self._last_y + self._dragged |= abs(mouse_event.pos.y - self._start_y) > 12 * self._scale if delta: self._scroll(self._pressed, delta) - elapsed = event.t - self._last_t + elapsed = mouse_event.t - self._last_t if 0 < elapsed < 0.15: self._velocity = max(-1600 * self._scale, min(1600 * self._scale, delta / elapsed)) - self._last_motion = event.t - self._last_y, self._last_t = event.pos.y, event.t - if event.left_released and event.t - self._last_motion > 0.12: + self._last_motion = mouse_event.t + self._last_y, self._last_t = mouse_event.pos.y, mouse_event.t + if mouse_event.left_released and mouse_event.t - self._last_motion > 0.12: self._velocity = 0.0 - if event.left_released and not rl.check_collision_point_rec(event.pos, self._rect): + if mouse_event.left_released and not rl.check_collision_point_rec(mouse_event.pos, self._rect): self._pressed = None self._offset = self._velocity = 0.0 diff --git a/openpilot/starpilot/flm/global_runtime.py b/openpilot/starpilot/flm/global_runtime.py index e2f553b66f..5d5da44a4f 100644 --- a/openpilot/starpilot/flm/global_runtime.py +++ b/openpilot/starpilot/flm/global_runtime.py @@ -220,7 +220,9 @@ class GlobalRuntime: self.stage_effect = False self.centre_ack = None policy = getattr(getattr(self.controller, 'starpilot_extension', None), 'policy', None) - self.live_manual = bool(manual_current or getattr(self.controller, 'flm_surface', None) is not None or + self.live_manual = bool(manual_current or getattr(self.controller, 'manual_flm_effects', None) is not None or + getattr(self.controller, 'manual_flm_status', 'not_configured') not in ('configured', 'not_configured') or + getattr(self.controller, 'flm_surface', None) is not None or getattr(policy, 'surface', None) is not None) self.native_required = bool(native_required) self.native_witness = native_witness diff --git a/openpilot/starpilot/flm/learning_source.py b/openpilot/starpilot/flm/learning_source.py index fdc64f1883..43d06af854 100644 --- a/openpilot/starpilot/flm/learning_source.py +++ b/openpilot/starpilot/flm/learning_source.py @@ -105,6 +105,7 @@ def identity_for(CP, controller, params, conversion_version): return None, False, False manual = profile is not None and (profile.factor.mode == 'custom' or profile.friction.mode == 'custom' or profile.proportional_gain.mode == 'custom' or profile.flm is not None or + profile.manual_coordinates is not None and any(v for _, v in profile.manual_coordinates.values) or profile.geometry is not None and (profile.geometry.ratio.mode == 'custom' or profile.geometry.full_delay.mode == 'custom')) baseline = serialize_document({str(CP.carFingerprint): profile}).hex() if profile is not None else None diff --git a/openpilot/starpilot/flm/manual_coordinates.py b/openpilot/starpilot/flm/manual_coordinates.py new file mode 100644 index 0000000000..4dc88bdace --- /dev/null +++ b/openpilot/starpilot/flm/manual_coordinates.py @@ -0,0 +1,111 @@ +"""Explicit parked manual coordinates, separate from learned history and authority.""" + +from dataclasses import dataclass +import hashlib +import json +import math +import time + +from openpilot.starpilot.flm.canonical_surface_adapter import NAMES, MANIFEST_SHA256, coordinate_bounds + + +def context(cp, controller): + from openpilot.starpilot.flm.runtime_adapter_registry import car_params_digest + from openpilot.starpilot.lateral.controller_selection import ControllerMode + if (str(cp.steerControlType) != 'torque' or cp.lateralTuning.which() != 'torque' + or cp.passive or cp.notCar or cp.dashcamOnly): + raise ValueError('Manual coordinates require an active torque controller') + return car_params_digest(cp), str(ControllerMode(controller)), MANIFEST_SHA256 + + +@dataclass(frozen=True) +class ManualCoordinates: + context: tuple[str, str, str] + values: tuple[tuple[str, float], ...] + + def validate(self): + if (type(self.context) is not tuple or len(self.context) != 3 + or any(type(v) is not str for v in self.context) + or any(len(self.context[i]) != 64 or self.context[i].strip('0123456789abcdef') for i in (0, 2)) + or not self.context[1] or len(self.context[1]) > 32 or not self.context[1].isascii() + or type(self.values) is not tuple or len(self.values) != len(NAMES)): + raise ValueError('Invalid manual coordinate context') + from openpilot.starpilot.lateral.controller_selection import ControllerMode + ControllerMode(self.context[1]) + for name, row in zip(NAMES, self.values, strict=True): + if type(row) is not tuple or len(row) != 2 or row[0] != name: + raise ValueError('Exactly thirty ordered manual coordinates required') + low, high = coordinate_bounds(name) + if type(row[1]) not in (int, float) or not math.isfinite(row[1]) or not low <= row[1] <= high: + raise ValueError('Manual coordinate outside the selected envelope') + # Validate the complete composed surface as well as each individual value. + from openpilot.starpilot.flm.canonical_surface_adapter import CanonicalSurfaceEffects + CanonicalSurfaceEffects(dict(self.values[:1]), self.digest(), accumulated=dict(self.values)) + return self + + def digest(self): + return hashlib.sha256(json.dumps((self.context, self.values), separators=(',', ':'), allow_nan=False).encode()).hexdigest() + + def document(self): + self.validate() + return {'context': list(self.context), 'values': dict(self.values)} + + @classmethod + def from_document(cls, value): + if (type(value) is not dict or set(value) != {'context', 'values'} or type(value['context']) is not list + or type(value['values']) is not dict or set(value['values']) != set(NAMES)): + raise ValueError('Invalid manual coordinate document') + return cls(tuple(value['context']), tuple((n, value['values'][n]) for n in NAMES)).validate() + + +class ManualEffects: + """Human-selected bounded math; never a live trial or a native permission.""" + + def __init__(self, profile): + from openpilot.starpilot.flm.canonical_surface_adapter import CanonicalSurfaceEffects + if type(profile) is not ManualCoordinates: + raise ValueError('Typed manual coordinates required') + profile.validate() + self.profile = profile + self.source = CanonicalSurfaceEffects(dict(profile.values[:1]), profile.digest(), accumulated=dict(profile.values)) + self.original_effect_anchor = self.source.original_effect_anchor + + def begin_frame(self): + self.source.begin_frame(time.monotonic_ns()) + + def selected_stage(self, *args, **kwargs): + return self.source.selected_stage(*args, **kwargs) + + def selected_friction(self, *args, **kwargs): + return self.source.selected_friction(*args, **kwargs) + + def selected_deadzone(self, *args, **kwargs): + return self.source.selected_deadzone(*args, **kwargs) + + def selected_output(self, *args, **kwargs): + return self.source.selected_output(*args, **kwargs) + + +def load(cp, controller, params=None): + """Load once at controller startup. Unreadable is distinct from absent.""" + from openpilot.common.params import Params + from openpilot.starpilot.saved_source import read_saved + from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, MAX_DOCUMENT_BYTES, parse_document + params = Params() if params is None else params + raw, readable = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES) + if not readable: + return None, 'unreadable' + if raw is None: + return None, 'not_configured' + try: + profile = parse_document(raw).get(str(cp.carFingerprint)) + manual = None if profile is None else profile.manual_coordinates + if manual is None or not any(v for _, v in manual.values): + return None, 'not_configured' + tune = cp.lateralTuning.torque + if (profile.basis != (float(tune.latAccelFactor), float(tune.latAccelOffset), float(tune.friction)) + or profile.flm is not None or manual.context != context(cp, controller)): + return None, 'context_changed' + return ManualEffects(manual), 'configured' + except (AttributeError, KeyError, TypeError, ValueError, OverflowError, UnicodeError): + return None, 'invalid' diff --git a/openpilot/starpilot/flm/selected_effects.py b/openpilot/starpilot/flm/selected_effects.py index 460279778b..81483d58d7 100644 --- a/openpilot/starpilot/flm/selected_effects.py +++ b/openpilot/starpilot/flm/selected_effects.py @@ -337,12 +337,26 @@ class EffectReplaySource: return self.source.selected_output(*args, **kwargs) +def _selected_source(parent): + from openpilot.starpilot.flm.manual_coordinates import ManualEffects + source = getattr(parent, 'manual_flm_effects', None) + if source is not None: + policy = getattr(getattr(parent, 'starpilot_extension', None), 'policy', None) + if getattr(parent, 'flm_surface', None) is not None or getattr(policy, 'surface', None) is not None: + return None + return source if type(source) is ManualEffects else None + return getattr(parent, 'automatic_flm_effects', None) + + def manual(parent): policy = getattr(getattr(parent, 'starpilot_extension', None), 'policy', None) - return getattr(parent, 'flm_surface', None) is not None or getattr(policy, 'surface', None) is not None + return (getattr(parent, 'manual_flm_effects', None) is not None + or getattr(parent, 'flm_surface', None) is not None or getattr(policy, 'surface', None) is not None) def bounded_profile_on_limited_frame(parent): + if getattr(parent, 'manual_flm_effects', None) is not None: + return False # This is parameter shaping, not a native grant. The live owner must already # hold current source/native permission; replay uses the same bounded math. # Legacy residual owners and arbitrary callback objects remain inhibited. @@ -357,14 +371,14 @@ def bounded_profile_on_limited_frame(parent): def apply_selected_stage(parent, frame): - source = getattr(parent, 'automatic_flm_effects', None) + source = _selected_source(parent) if ( source is None or not frame.valid() or not frame.active or frame.driver_override or (frame.limited and (frame.curvature_limited or not bounded_profile_on_limited_frame(parent))) - or manual(parent) + or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None)) ): return frame.baseline_ff, frame.baseline_threshold try: @@ -379,13 +393,13 @@ def apply_selected_stage(parent, frame): def apply_selected_friction(parent, cs, setpoint, baseline, *, active, limited, curvature_limited=False): - source = getattr(parent, 'automatic_flm_effects', None) + source = _selected_source(parent) if ( source is None or not active or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent))) or cs.steeringPressed - or manual(parent) + or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None)) or not finite(baseline) ): return baseline @@ -399,8 +413,9 @@ def apply_selected_friction(parent, cs, setpoint, baseline, *, active, limited, def apply_selected_deadzone(parent, cs, vm, baseline_accel, requested_curvature, *, active, limited, curvature_limited=False): - source = getattr(parent, 'automatic_flm_effects', None) - if source is None or not active or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent))) or cs.steeringPressed or manual(parent): + source = _selected_source(parent) + if (source is None or not active or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent))) + or cs.steeringPressed or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None))): return baseline_accel try: speed = float(cs.vEgo) @@ -415,13 +430,13 @@ def apply_selected_deadzone(parent, cs, vm, baseline_accel, requested_curvature, def apply_selected_output(parent, cs, vm, params, requested_curvature, result, *, active, limited, curvature_limited=False): - source = getattr(parent, 'automatic_flm_effects', None) + source = _selected_source(parent) if ( source is None or not active or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent))) or cs.steeringPressed - or manual(parent) + or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None)) or cs.vEgo < 0.1 * 0.44704 ): return result diff --git a/openpilot/starpilot/flm/tests/test_manual_coordinates.py b/openpilot/starpilot/flm/tests/test_manual_coordinates.py new file mode 100644 index 0000000000..bdd6c07b81 --- /dev/null +++ b/openpilot/starpilot/flm/tests/test_manual_coordinates.py @@ -0,0 +1,306 @@ +"""Manual edits are explicit bounded configuration, never accepted learning.""" + +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from opendbc.car import structs +from opendbc.car.hyundai.values import CAR +from openpilot.starpilot.flm.canonical_surface_adapter import NAMES +from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates, ManualEffects, context, load +from openpilot.starpilot.flm.selected_effects import apply_selected_stage +from openpilot.starpilot.flm.torque_stages import StageFrame +from openpilot.starpilot.lateral.controller_selection import default_selection +from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, FieldChoice, PlatformProfile, parse_document, serialize_document +from openpilot.starpilot.saved_source import read_saved +from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest +from openpilot.starpilot.ui.torque_feature import TorqueFeature + + +@pytest.fixture +def cp(): + value = structs.CarParams.new_message() + value.carFingerprint = str(CAR.HYUNDAI_IONIQ_6) + value.brand = 'hyundai' + value.steerControlType = 'torque' + tune = value.lateralTuning.init('torque') + tune.latAccelFactor, tune.latAccelOffset, tune.friction = 2.5, 0., .1 + return value + + +class PrivateParams: + def __init__(self, root): + self.root = root + (root / 'd').mkdir() + def get_param_path(self, key): + return str(self.root / 'd' / key) + def put(self, *args, **kwargs): + raise AssertionError('Manual editing must use the exact-source document writer') + + +def basis(cp): + t = cp.lateralTuning.torque + return float(t.latAccelFactor), float(t.latAccelOffset), float(t.friction) + + +def manual(cp, **changes): + values = dict.fromkeys(NAMES, 0.) + values.update(changes) + return ManualCoordinates(context(cp, default_selection(cp).mode), tuple(values.items())).validate() + + +def save(params, cp, value): + profile = PlatformProfile(basis(cp), FieldChoice(), FieldChoice(), manual_coordinates=value) + raw = serialize_document({str(cp.carFingerprint): profile}) + from pathlib import Path + Path(params.get_param_path(DOCUMENT_KEY)).write_bytes(raw) + return raw + + +def saved_profile(params, cp): + raw, readable = read_saved(params, DOCUMENT_KEY, 16384) + assert readable and raw is not None + return parse_document(raw)[str(cp.carFingerprint)] + + +@pytest.mark.parametrize('name', NAMES) +def test_each_of_thirty_values_roundtrips_without_learning_history(cp, name): + value = manual(cp, **{name: .01}) + restored = ManualCoordinates.from_document(value.document()) + assert restored == value + assert dict(restored.values)[name] == .01 + assert sum(v != 0 for _, v in restored.values) == 1 + + +@pytest.mark.parametrize('bad', [True, float('nan'), float('inf'), -.001, .251]) +def test_deadband_refuses_wrong_types_nonfinite_and_bounds(cp, bad): + with pytest.raises((ValueError, OverflowError)): + manual(cp, center_deadband_low_deg=bad) + + +def test_full_profile_is_required_and_ordered(cp): + value = manual(cp) + with pytest.raises(ValueError): + replace(value, values=value.values[:-1]).validate() + with pytest.raises(ValueError): + replace(value, values=tuple(reversed(value.values))).validate() + + +def test_opposite_speed_window_edges_stay_ordered_inside_original_limits(cp): + value = manual(cp, curvy_turn_in_trim_speed_min=.25, curvy_turn_in_trim_speed_max=-.25) + assert dict(value.values)['curvy_turn_in_trim_speed_min'] == .25 + assert dict(value.values)['curvy_turn_in_trim_speed_max'] == -.25 + + +def test_same_actual_context_loads_once_and_does_not_write_history(tmp_path, cp): + params = PrivateParams(tmp_path) + raw = save(params, cp, manual(cp, center_taper_max=.01)) + owner, status = load(cp, default_selection(cp).mode, params) + assert type(owner) is ManualEffects and status == 'configured' + assert dict(owner.profile.values)['center_taper_max'] == .01 + assert read_saved(params, DOCUMENT_KEY, 16384) == (raw, True) + assert read_saved(params, 'AutomaticSteeringLiveTrials', 65536) == (None, True) + + +def test_changed_vehicle_context_refuses_instead_of_retagging(tmp_path, cp): + params = PrivateParams(tmp_path) + raw = save(params, cp, manual(cp, center_taper_max=.01)) + cp.mass = 1800. + assert load(cp, default_selection(cp).mode, params) == (None, 'context_changed') + assert read_saved(params, DOCUMENT_KEY, 16384) == (raw, True) + + +def test_unreadable_is_not_absence(tmp_path, cp): + params = PrivateParams(tmp_path) + (tmp_path / 'd' / DOCUMENT_KEY).mkdir() + assert load(cp, default_selection(cp).mode, params) == (None, 'unreadable') + + +def test_neutral_profile_does_not_activate_manual_override(tmp_path, cp): + params = PrivateParams(tmp_path) + save(params, cp, manual(cp)) + assert load(cp, default_selection(cp).mode, params) == (None, 'not_configured') + + +@pytest.mark.parametrize('active,override,limited,curvature_limited', [ + (False, False, False, False), (True, True, False, False), + (True, False, True, False), (True, False, True, True), +]) +def test_manual_does_not_bypass_inactive_override_or_limiter(cp, active, override, limited, curvature_limited): + source = ManualEffects(manual(cp, center_taper_max=.01)) + source.begin_frame() + parent = SimpleNamespace(manual_flm_effects=source, flm_surface=None, starpilot_extension=None, automatic_flm_effects=None) + frame = StageFrame(15., 0., 0., .5, .3, active, override, limited, curvature_limited) + assert apply_selected_stage(parent, frame) == (.5, .3) + + +def test_manual_uses_existing_selected_stage_with_no_auto_owner(cp): + source = ManualEffects(manual(cp, center_taper_max=.01)) + source.begin_frame() + parent = SimpleNamespace(manual_flm_effects=source, flm_surface=None, starpilot_extension=None, automatic_flm_effects=None) + frame = StageFrame(15., 0., 0., .5, .3, True, False, False) + ff, threshold = apply_selected_stage(parent, frame) + assert 0 < ff < .5 + assert .3 <= threshold <= .375 + assert parent.automatic_flm_effects is None + + +def feature(cp, params, state): + capability = (str(cp.carFingerprint), 'hyundai', 'torque', 'torque', False, *basis(cp), None) + def raw(key): + return read_saved(params, key, 16384)[0] + owner = SimpleNamespace(params=params, authority=lambda group: state['parked'] if group == 'parked_preferences' else True, + vehicle_fingerprint=lambda: str(cp.carFingerprint), vehicle_params=lambda: cp, + _capability=lambda group: capability, _raw=raw, _readable=lambda key: read_saved(params, key, 16384)[1], + _dependents=lambda *keys: tuple((key, raw(key)) for key in keys)) + return TorqueFeature(owner), capability + + +def request(row, value): + return FeatureSettingsRequest(row.key, row.source, str(value), capability=row.capability, + related_source=row.related_source, dependencies=row.dependencies, vehicle_fingerprint=row.vehicle_fingerprint) + + +def test_parked_edit_exact_readback_and_single_value_reset(tmp_path, cp): + params = PrivateParams(tmp_path) + state = {'parked': True} + editor, capability = feature(cp, params, state) + rows = editor.manual_rows(capability, True) + assert len(rows) == 30 and all(row.available for row in rows) + row = next(r for r in rows if r.key.endswith(':center_taper_max')) + row = replace(row, vehicle_fingerprint=str(cp.carFingerprint)) + assert editor.apply(request(row, 1.)) + profile = saved_profile(params, cp) + assert profile.manual_coordinates is not None + assert dict(profile.manual_coordinates.values)['center_taper_max'] == .01 + row = next(r for r in editor.manual_rows(capability, True) if r.key.endswith(':center_taper_max')) + assert editor.apply(request(replace(row, vehicle_fingerprint=str(cp.carFingerprint)), 0.)) + assert saved_profile(params, cp).manual_coordinates is None + + +def test_stale_source_and_onroad_writes_are_refused(tmp_path, cp): + params = PrivateParams(tmp_path) + state = {'parked': True} + editor, capability = feature(cp, params, state) + row = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint)) + assert editor.apply(request(row, 1.)) + assert not editor.apply(request(row, 2.)) + current = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint)) + state['parked'] = False + assert not editor.apply(request(current, 2.)) + profile = saved_profile(params, cp) + assert profile.manual_coordinates is not None + assert dict(profile.manual_coordinates.values)[NAMES[0]] == .01 + + +def test_invalid_saved_document_is_not_replaced_by_a_new_manual_tune(tmp_path, cp): + params = PrivateParams(tmp_path) + (tmp_path / 'd' / DOCUMENT_KEY).write_bytes(b'{broken') + editor, capability = feature(cp, params, {'parked': True}) + assert not any(row.available for row in editor.manual_rows(capability, True)) + assert (tmp_path / 'd' / DOCUMENT_KEY).read_bytes() == b'{broken' + + +def test_authority_loss_under_native_lock_refuses_and_removes_stage(tmp_path, cp, monkeypatch): + from openpilot.starpilot import saved_document + params = PrivateParams(tmp_path) + state = {'parked': True} + editor, capability = feature(cp, params, state) + row = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint)) + original = saved_document.acquire_native_lock + def acquire(fd): + original(fd) + state['parked'] = False + monkeypatch.setattr(saved_document, 'acquire_native_lock', acquire) + assert not editor.apply(request(row, 1.)) + assert read_saved(params, DOCUMENT_KEY, 16384) == (None, True) + assert not list(tmp_path.glob('.manual-steering-*')) + + +def test_source_change_under_native_lock_is_not_overwritten(tmp_path, cp, monkeypatch): + from openpilot.starpilot import saved_document + from pathlib import Path + params = PrivateParams(tmp_path) + editor, capability = feature(cp, params, {'parked': True}) + row = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint)) + other = serialize_document({str(cp.carFingerprint): PlatformProfile(basis(cp), FieldChoice(), FieldChoice('custom', .12))}) + original = saved_document.acquire_native_lock + def acquire(fd): + original(fd) + Path(params.get_param_path(DOCUMENT_KEY)).write_bytes(other) + monkeypatch.setattr(saved_document, 'acquire_native_lock', acquire) + assert not editor.apply(request(row, 1.)) + assert read_saved(params, DOCUMENT_KEY, 16384) == (other, True) + assert not list(tmp_path.glob('.manual-steering-*')) + + +def test_reset_all_uses_original_row_request_and_requires_confirmation(tmp_path, cp): + from openpilot.starpilot.ui.feature_settings_state import row_change + params = PrivateParams(tmp_path) + save(params, cp, manual(cp, ff_gain_left=.01, center_taper_max=.01)) + editor, capability = feature(cp, params, {'parked': True}) + reset = replace(editor.manual_rows(capability, True)[-1], vehicle_fingerprint=str(cp.carFingerprint)) + unconfirmed = row_change(reset) + assert unconfirmed is not None and unconfirmed.value == 'Reset' + assert not editor.apply(unconfirmed) + assert editor.apply(replace(unconfirmed, confirmation=True)) + assert saved_profile(params, cp).manual_coordinates is None + + +def test_replay_controller_construction_does_not_read_manual_preferences(cp, monkeypatch): + from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque + from openpilot.starpilot.flm import manual_coordinates + def forbidden(*args, **kwargs): + raise AssertionError('Only the actual Controls owner may load manual configuration') + monkeypatch.setattr(manual_coordinates, 'load', forbidden) + ci = SimpleNamespace( + torque_from_lateral_accel=lambda: lambda value, tune: value / tune.latAccelFactor, + lateral_accel_from_torque=lambda: lambda value, tune: value * tune.latAccelFactor) + controller = LatControlTorque(cp.as_reader(), ci, .01, controller_mode=default_selection(cp).mode) + assert controller.manual_flm_effects is None + assert controller.manual_flm_status == 'not_configured' + + +def test_next_startup_loads_new_saved_values_without_mutating_running_profile(tmp_path, cp): + params = PrivateParams(tmp_path) + save(params, cp, manual(cp, center_taper_max=.01)) + original, status = load(cp, default_selection(cp).mode, params) + assert status == 'configured' + save(params, cp, manual(cp, center_taper_max=.02)) + following, status = load(cp, default_selection(cp).mode, params) + assert status == 'configured' + assert dict(original.profile.values)['center_taper_max'] == .01 + assert dict(following.profile.values)['center_taper_max'] == .02 + + +@pytest.mark.parametrize('saved,expected', [(False, True), (True, False)]) +def test_actual_controls_startup_holds_native_learning_only_for_manual_tune(tmp_path, cp, monkeypatch, saved, expected): + from openpilot.selfdrive.controls import controlsd + params = PrivateParams(tmp_path) + if saved: + save(params, cp, manual(cp, center_taper_max=.01)) + (tmp_path / 'd' / 'ForceAutoTuneOff').write_bytes(b'0') + monkeypatch.setattr(controlsd, 'learning_allowed', lambda *args, **kwargs: True) + owner = object.__new__(controlsd.Controls) + owner.CP = cp + monkeypatch.setattr(owner, 'params', params, raising=False) + owner.lateral_controller_selection = default_selection(cp) + effects, status = owner._configure_manual_tune() + assert owner.torque_learning_allowed is expected + assert (effects is not None) is saved + assert status == ('configured' if saved else 'not_configured') + assert (tmp_path / 'd' / 'ForceAutoTuneOff').read_bytes() == b'0' + + +def test_actual_controls_startup_does_not_treat_unreadable_as_automatic_permission(tmp_path, cp, monkeypatch): + from openpilot.selfdrive.controls import controlsd + params = PrivateParams(tmp_path) + (tmp_path / 'd' / DOCUMENT_KEY).mkdir() + monkeypatch.setattr(controlsd, 'learning_allowed', lambda *args, **kwargs: True) + owner = object.__new__(controlsd.Controls) + owner.CP = cp + monkeypatch.setattr(owner, 'params', params, raising=False) + owner.lateral_controller_selection = default_selection(cp) + assert owner._configure_manual_tune() == (None, 'unreadable') + assert owner.torque_learning_allowed is False diff --git a/openpilot/starpilot/flm/tests/test_source_baseline_recheck.py b/openpilot/starpilot/flm/tests/test_source_baseline_recheck.py index fab75efd20..20088c5992 100644 --- a/openpilot/starpilot/flm/tests/test_source_baseline_recheck.py +++ b/openpilot/starpilot/flm/tests/test_source_baseline_recheck.py @@ -10,6 +10,18 @@ from openpilot.starpilot.flm.tests.test_live_tracking import episode from openpilot.starpilot.flm.tests.test_tracking_source_context import context, refresh +def offer_state(owner): + state = owner.offer_state + assert state is not None + return state + + +def selector_hint(owner): + hint = owner.selector_hint + assert hint is not None + return hint + + class Drive: """Declared whole episodes and typed ACK operands, never vehicle evidence.""" def __init__(self, initial=.0): @@ -41,7 +53,7 @@ class Drive: profile_sha256=profile or o._hash(o.accepted), goal_rms=goal, normalization_accel=max(.1, goal), goal_shape=shape or (goal / max(.1, goal),) * 8) refresh(o, self.now) - if renew and o.offer_state is not None and o.offer_state['phase'] == 'rollback': + if renew and o.offer_state is not None and offer_state(o)['phase'] == 'rollback': assert self.ack() return row, o.observe_episode(row, now_ns=self.now) @@ -49,11 +61,11 @@ class Drive: o = self.owner for _ in range(20): self.row(name, **row_values) - if o.offer_state is not None and o.offer_state['phase'] == 'trial': + if o.offer_state is not None and offer_state(o)['phase'] == 'trial': offer = o.offer(self.now) assert offer is not None return offer - pytest.fail('No bounded source trial selected') + raise AssertionError('No bounded source trial selected') def batch(self, offer, name='ff_gain_left', **row_values): rows = [] @@ -68,9 +80,9 @@ def return_phase(d, name='ff_gain_left', **values): assert d.ack(trial) d.batch(trial, name, error=.03, goal=values.get('goal', .09), shape=values.get('shape')) o = d.owner - assert o.offer_state['phase'] == 'rollback', 'A first positive batch must return to A, not confirm B' + assert offer_state(o)['phase'] == 'rollback', 'A first positive batch must return to A, not confirm B' assert o.source_recheck is not None - assert not o.offer_state['accepted'] and o.source_recheck.first_ack_ns == 0 + assert not offer_state(o)['accepted'] and o.source_recheck.first_ack_ns == 0 return trial @@ -95,14 +107,14 @@ def test_each_coordinate_requires_return_baseline_and_fresh_reapplied_performanc initial = dict(o.accepted) first, second = reapply_phase(d, name) assert o.accepted == initial and o.settled_ns is None and not o.future - assert len(o.before) == 3 and len(o.offer_state['source_rechecked_before']) == 3 + assert len(o.before) == 3 and len(offer_state(o)['source_rechecked_before']) == 3 assert not d.ack(first) # Old B1 trial/sequence cannot acknowledge B2. early, admitted = d.row(name, profile=second.target_sha256, error=.03) assert not admitted and not o.future assert d.ack(second) for index in range(3): d.row(name, profile=second.target_sha256, error=.03) - assert o.offer_state['phase'] == ('confirmed' if index == 2 else 'trial') + assert offer_state(o)['phase'] == ('confirmed' if index == 2 else 'trial') assert o.accepted == initial confirmed = o.offer(d.now) assert confirmed is not None and confirmed.phase == 'confirmed' @@ -149,12 +161,12 @@ def test_null_response_cannot_confirm_when_fresh_baseline_has_same_apparent_bene assert d.ack(trial) d.batch(trial, **new) o = d.owner - assert o.offer_state['phase'] == 'rollback', 'An easier input cannot immediately confirm an unchanged-response B' + assert offer_state(o)['phase'] == 'rollback', 'An easier input cannot immediately confirm an unchanged-response B' assert o.source_recheck is not None assert not any(o.accepted.values()) and d.ack() for _ in range(3): d.row(**new) - assert o.source_recheck is None and o.offer_state['phase'] == 'rollback' and o.offer_state['accepted'] + assert o.source_recheck is None and offer_state(o)['phase'] == 'rollback' and offer_state(o)['accepted'] assert o.reason == 'return_baseline_did_not_confirm_improvement' assert not any(o.accepted.values()) @@ -190,7 +202,7 @@ def test_original_deadline_ends_every_recheck_transition(phase): if phase == 'confirmed_ack_pending': d.row(profile=target.target_sha256, error=.03) d.row(profile=target.target_sha256, error=.03) - assert o.offer_state['phase'] == 'confirmed' + assert offer_state(o)['phase'] == 'confirmed' d.now = first.deadline_ns + 1 refresh(o,d.now) offer = o.offer(d.now) @@ -206,7 +218,7 @@ def test_actual_gross_oscillation_rejection_remains_terminal(stage): offer = d.trial() if stage == 'first_trial' else reapply_phase(d)[1] assert d.ack(offer) d.row(profile=offer.target_sha256, error=.03, reversal=10.) - assert o.reason == 'trial_tracking_or_oscillation_worse' and o.offer_state['phase'] == 'rollback' + assert o.reason == 'trial_tracking_or_oscillation_worse' and offer_state(o)['phase'] == 'rollback' assert getattr(o,'source_recheck',None) is None and o.paused_intent is None and not any(o.accepted.values()) @@ -217,7 +229,7 @@ def test_interrupted_return_baseline_never_retains_provisional_candidate(reason, assert d.ack() d.row(error=.045) d.owner.interrupt(reason,context_changed=changed) - assert d.owner.source_recheck is None and d.owner.offer_state['phase'] == 'rollback' + assert d.owner.source_recheck is None and offer_state(d.owner)['phase'] == 'rollback' assert all(value == .002 for value in d.owner.accepted.values()) @@ -230,17 +242,17 @@ def test_pretrial_numbers_wait_for_original_demand_without_an_offer_or_expiry_re break assert o.discovery is not None discovery = o.discovery - selected,expires = o.selector_hint['selected_ns'],o.selector_hint['expires_ns'] + selected,expires = selector_hint(o)['selected_ns'],selector_hint(o)['expires_ns'] d.row() # First whole independent baseline. ids = tuple(v.episode_id for v in o.comparison) assert len(ids) == 1 d.row(goal=.4,kind='large') assert o.discovery is discovery and tuple(v.episode_id for v in o.comparison) == ids - assert o.selector_hint['selected_ns'] == selected and o.selector_hint['expires_ns'] == expires + assert selector_hint(o)['selected_ns'] == selected and selector_hint(o)['expires_ns'] == expires assert o.offer(d.now) is None for _ in range(2): d.row() - assert o.offer_state is not None and o.offer_state['phase'] == 'trial' + assert o.offer_state is not None and offer_state(o)['phase'] == 'trial' assert {v.episode_id for v in o.before}.issuperset(ids) @@ -254,7 +266,7 @@ def test_return_baseline_requires_its_own_current_exact_ack(bad): 'wrong_hash': {'target_sha256': first.target_sha256}, 'stale': {'control_ns': d.now-100_000_001}}[bad] assert not d.ack(offer, **changes) - assert o.source_recheck.first_ack_ns == 0 and not o.offer_state['accepted'] + assert o.source_recheck.first_ack_ns == 0 and not offer_state(o)['accepted'] row, admitted = d.row(error=.045, renew=False) assert not admitted and not o.source_recheck.baseline assert row.episode_id not in {v.episode_id for v in o.source_recheck.before} @@ -297,7 +309,7 @@ def test_recheck_numeric_state_does_not_survive_identity_model_or_baseline_chang o.accepted['ff_gain_left'] = .002 o.offer(d.now) assert o.source_recheck is None - assert o.offer_state['phase'] == 'rollback' + assert offer_state(o)['phase'] == 'rollback' def test_zero_error_return_baseline_is_not_censored_to_keep_candidate(): @@ -308,7 +320,7 @@ def test_zero_error_return_baseline_is_not_censored_to_keep_candidate(): d.row(error=0.) o = d.owner assert o.source_recheck is None and not any(o.accepted.values()) - assert o.offer_state['phase'] == 'rollback' and o.offer_state['accepted'] + assert offer_state(o)['phase'] == 'rollback' and offer_state(o)['accepted'] assert o.reason == 'return_baseline_did_not_confirm_improvement' @@ -332,7 +344,7 @@ def test_missing_current_response_witness_cannot_advance_any_recheck_transition( d.now += 100_000_001 assert o.offer(d.now) is None assert o.source_recheck is None and not any(o.accepted.values()) - assert o.offer_state is None or o.offer_state['phase'] == 'rollback' + assert o.offer_state is None or offer_state(o)['phase'] == 'rollback' @pytest.mark.parametrize('cause',['expiry','context']) @@ -349,7 +361,7 @@ def test_waiting_pretrial_comparison_cannot_renew_or_survive_changed_context(cau old_ids = {v.episode_id for v in o.comparison} assert old_ids if cause == 'expiry': - d.now = o.selector_hint['expires_ns']+1 + d.now = selector_hint(o)['expires_ns']+1 d.row() else: o.interrupt('changed_context',context_changed=True) diff --git a/openpilot/starpilot/flm/torque_stages.py b/openpilot/starpilot/flm/torque_stages.py index 49f0b42d07..b1c7fa6104 100644 --- a/openpilot/starpilot/flm/torque_stages.py +++ b/openpilot/starpilot/flm/torque_stages.py @@ -52,6 +52,8 @@ def apply_stages(parent, cs, setpoint, jerk, ff, threshold, *, active, limited, observer = getattr(parent, 'automatic_flm_observation', None) if observer is not None and frame.valid(): observer.observe_stage(frame, ff, (0.0, 0.0)) + if getattr(parent, 'manual_flm_effects', None) is not None: + return apply_selected_stage(parent, frame) if source is None: return ff, threshold if not frame.valid() or not frame.active or frame.driver_override: diff --git a/openpilot/starpilot/galaxy/setting_descriptions.py b/openpilot/starpilot/galaxy/setting_descriptions.py index 6e3f72b586..a5dec080fe 100644 --- a/openpilot/starpilot/galaxy/setting_descriptions.py +++ b/openpilot/starpilot/galaxy/setting_descriptions.py @@ -252,6 +252,10 @@ def purpose(row): return 'Selects camera crop dimensions matching the source camera image.' if key.startswith('pip:mask:'): return 'Moves the camera-image crop center along the indicated axis.' + if key.startswith('torque:surface:'): + return ('Removes the thirty manual steering overrides while keeping saved Auto Learn history.' + if key == 'torque:surface:reset' else + 'Adjusts this manual steering correction.') if key.startswith('torque:'): field = key.split(':')[1] return {'factor': 'Sets the lateral-acceleration-to-torque conversion for compatible steering controllers.', @@ -279,6 +283,8 @@ def description(row): suffix = INITIALIZATION.get(row.key, '') if row.key.startswith('lane_change:') and row.key not in ('lane_change:close_gap', 'lane_change:close_gap_seconds', 'lane_change:reset'): suffix = 'Changes apply when the current signal and maneuver end; turning assistance off cancels it after the preference refresh.' + if row.key.startswith('torque:surface:'): + suffix = row.reason.strip() if row.key.startswith('torque:') and row.key.split(':')[1] in ('factor', 'friction', 'gain'): suffix = ('First activation of a manual tuning owner requires a driving-process restart. An ' + 'already active owner refreshes saved adjustments during the drive.') diff --git a/openpilot/starpilot/galaxy/settings.py b/openpilot/starpilot/galaxy/settings.py index f2b6ae3f0a..01b33e477c 100644 --- a/openpilot/starpilot/galaxy/settings.py +++ b/openpilot/starpilot/galaxy/settings.py @@ -41,7 +41,7 @@ from openpilot.starpilot.ui.vasm_owner import ANNOTATION as VASM_ANNOTATION, RES PAGES = frozenset(("hub", "localization", "developer", "vehicle", "slc", "lane", "lane_change", "profiles", "personalities", "traffic", "aggressive", "standard", "relaxed", - "curve", "torque", "torque/bluepilot", "aol", "wheel", "appearance", "display", "data", "power", "sounds", "pip", "sentry", "vasm", + "curve", "torque", "tuning", "torque/bluepilot", "aol", "wheel", "appearance", "display", "data", "power", "sounds", "pip", "sentry", "vasm", "conditional", "conditional/cem", "conditional/ccm", *(f"{name}/{category}" for name in ("traffic", "aggressive", "standard", "relaxed") for category in ("acceleration", "braking", "following")))) @@ -393,6 +393,7 @@ def _projection(row: FeatureRow) -> dict: row.key in (PIP_RESET, PIP_EDITOR, VASM_RESET, VASM_ANNOTATION) or row.key.startswith(PIP_FORMAT_PREFIX)), "repairValue": row.repair_value, "defaultValue": row.default_value, "resetAvailable": row_default(row) is not None, "displayScale": "lane-strength" if row.key == "LaneCenteringStrength" else None, + **({"coordinate": row.key.removeprefix("torque:surface:")} if row.key.startswith("torque:surface:") and row.key != "torque:surface:reset" else {}), "heading": is_heading(row.key)} @@ -457,6 +458,8 @@ def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str: if key in ("profile:global_acceleration", "profile:global_braking"): return (f"Save {row.label.lower()} as {request.value}? Personalities set to Selected Profile follow this choice; " + "explicit personality overrides stay unchanged.") + if key == "torque:surface:reset": + return "Remove the thirty manual overrides for this vehicle? Saved Auto Learn history is kept." if key == "torque_gain_rebase": return "Apply the saved steering response to the selected controller and current vehicle tune? Friction and other models stay unchanged." if key == "torque_rebase": @@ -788,6 +791,8 @@ class SettingsGateway: if direction != 0: raise SettingsChanged("Invalid action") value = ("Off" if row.value == "On" else "On") if row.key == SETUP_ACTION else "confirm" + if row.key == "torque:surface:reset": + value = "Reset" if row.key == VASM_ANNOTATION: if draft is None: raise SettingsChanged("Camera regions are required") diff --git a/openpilot/starpilot/galaxy/tests/settings_http_fixture.py b/openpilot/starpilot/galaxy/tests/settings_http_fixture.py new file mode 100644 index 0000000000..cdf4df7926 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/settings_http_fixture.py @@ -0,0 +1,72 @@ +"""Shared declared-context HTTP fixture; no physical vehicle evidence.""" + +from pathlib import Path +from types import SimpleNamespace +import http.client +import json +import tempfile +import threading +import unittest + +from opendbc.car.structs import car +from opendbc.car.toyota.interface import CarInterface as ToyotaInterface +from opendbc.car.toyota.values import CAR as ToyotaCar +from openpilot.common.params import Params +from openpilot.starpilot.galaxy.settings import AuthorityContext, SettingsGateway +from openpilot.starpilot.galaxy.access import GalaxyAccessOwner +from openpilot.starpilot.galaxy.server import make_server + + +class MutableContext: + def __init__(self, cp): + self.value = AuthorityContext(True, cp, b"verified-cp") + + def sample(self): + return self.value + + +class SettingsHttpFixture(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.params = Params(temporary.name) + native = ToyotaInterface.get_non_essential_params(ToyotaCar.TOYOTA_COROLLA_TSS2) + cp = SimpleNamespace(carFingerprint="TOYOTA_COROLLA_TSS2", openpilotLongitudinalControl=True, + pcmCruise=False, notCar=False, dashcamOnly=False, passive=False, + brand="toyota", steerControlType=native.steerControlType, carVin="", + lateralTuning=native.lateralTuning, minSteerSpeed=native.minSteerSpeed, safetyConfigs=native.safetyConfigs, + transmissionType=car.CarParams.TransmissionType.automatic) + self.context = MutableContext(cp) + self.gateway = SettingsGateway(self.params, self.context) + self.access = GalaxyAccessOwner(Path(temporary.name) / "access") + self.assertTrue(self.access.configure("password123", lambda: True)) + self.server = make_server(port=0, owner=self.access, settings=self.gateway, + parked=lambda: self.context.sample().parked) + self.worker = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + self.worker.start() + self.addCleanup(self.stop) + + def stop(self): + self.server.shutdown() + self.worker.join(timeout=2) + self.server.server_close() + + def request(self, path, *, payload=None, cookie="", origin=None): + connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=3) + headers = {"Cookie": cookie} + if payload is not None: + headers.update({"Content-Type": "application/json", + "Origin": origin if origin is not None else f"http://127.0.0.1:{self.server.server_port}"}) + try: + connection.request("POST" if payload is not None else "GET", path, + body=json.dumps(payload) if payload is not None else None, headers=headers) + result = connection.getresponse() + return result.status, json.loads(result.read()), dict(result.getheaders()) + finally: + connection.close() + + def login(self): + status, _, headers = self.request("/api/auth/login", payload={"password": "password123"}) + self.assertEqual(status, 200) + return headers["Set-Cookie"].split(";", 1)[0] + diff --git a/openpilot/starpilot/galaxy/tests/test_manual_reset_http.py b/openpilot/starpilot/galaxy/tests/test_manual_reset_http.py new file mode 100644 index 0000000000..906c467837 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_manual_reset_http.py @@ -0,0 +1,55 @@ +"""Manual reset uses the real HTTP preview/confirm path in private settings.""" + +from pathlib import Path + +from openpilot.starpilot.galaxy.settings import AuthorityContext +from openpilot.starpilot.galaxy.tests.settings_http_fixture import SettingsHttpFixture +from openpilot.starpilot.saved_source import read_saved + + +class ManualResetHttpTest(SettingsHttpFixture): + def test_manual_reset_http_uses_row_choice_and_preserves_history(self): + from opendbc.car.hyundai.interface import CarInterface + from opendbc.car.hyundai.values import CAR + from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, parse_document + + cp = CarInterface.get_non_essential_params(CAR.HYUNDAI_IONIQ_6).as_reader() + self.context.value = AuthorityContext(True, cp, cp.as_builder().to_bytes()) + history = Path(self.params.get_param_path("AutomaticSteeringLiveTrials")) + history.write_bytes(b"unit-only inert history preservation sentinel") + cookie = self.login() + status, page, _ = self.request("/api/settings/pages/tuning", cookie=cookie) + self.assertEqual(status, 200) + index = next(i for i, row in enumerate(page["rows"]) if row.get("coordinate") == "center_taper_max") + status, intent, _ = self.request("/api/settings/preview", cookie=cookie, + payload={"view": page["view"], "row": index, "value": 1}) + self.assertEqual(status, 200) + self.assertEqual(self.request("/api/settings/confirm", cookie=cookie, + payload={"intent": intent["intent"], "confirmed": True})[:2], (200, {"saved": True})) + raw, readable = read_saved(self.params, DOCUMENT_KEY, 16384) + self.assertTrue(readable) + assert raw is not None + manual = parse_document(raw)[str(cp.carFingerprint)].manual_coordinates + assert manual is not None + self.assertEqual(dict(manual.values)["center_taper_max"], .01) + + status, page, _ = self.request("/api/settings/pages/tuning", cookie=cookie) + self.assertEqual(status, 200) + index = next(i for i, row in enumerate(page["rows"]) if row["label"] == "Reset manual tune") + status, intent, _ = self.request("/api/settings/preview", cookie=cookie, + payload={"view": page["view"], "row": index, "direction": 0}) + self.assertEqual(status, 200) + self.assertEqual(intent["proposed"], "Reset") + self.assertEqual(self.request("/api/settings/confirm", cookie=cookie, + payload={"intent": intent["intent"], "confirmed": True})[:2], (200, {"saved": True})) + raw, readable = read_saved(self.params, DOCUMENT_KEY, 16384) + self.assertTrue(readable) + assert raw is not None + self.assertIsNone(parse_document(raw)[str(cp.carFingerprint)].manual_coordinates) + self.assertEqual(history.read_bytes(), b"unit-only inert history preservation sentinel") + status, page, _ = self.request("/api/settings/pages/tuning", cookie=cookie) + self.assertEqual(status, 200) + self.assertFalse(any(row["label"] == "Reset manual tune" for row in page["rows"])) + coordinates = [row for row in page["rows"] if row.get("coordinate")] + self.assertEqual(len(coordinates), 30) + self.assertTrue(all(float(row["value"]) == 0 for row in coordinates)) diff --git a/openpilot/starpilot/galaxy/tests/test_plots.mjs b/openpilot/starpilot/galaxy/tests/test_plots.mjs index 55c6362842..78132941a5 100644 --- a/openpilot/starpilot/galaxy/tests/test_plots.mjs +++ b/openpilot/starpilot/galaxy/tests/test_plots.mjs @@ -43,6 +43,19 @@ let history = [] for (let i = 1; i <= 260; i++) history = appendPlotSample(history, payload(i), i * 750) assert.equal(history.length, MAX_POINTS) assert.equal(history[0].index, 21) + +// Optional source ages must not move the shared observation clock backwards. +let variedAge = [] +for (const [i, age] of [.1, 1.2, .1, .1, .1].entries()) { + variedAge = appendPlotSample(variedAge, validatePlotPayload(payload(i + 1, { sampleAgeSeconds: age })), 1000 + i * 750) +} +assert.deepEqual(variedAge.map(sample => sample.at), [1000, 1750, 2500, 3250, 4000]) +assert.deepEqual(chartGeometry(variedAge, ['desiredLateralAccel', 'actualLateralAccel']).paths.map(paths => paths.length), [1, 1]) +const missingObservedInterval = variedAge.map((sample, index) => ({ ...sample, at: sample.at + (index >= 2 ? 2000 : 0) })) +assert.deepEqual(chartGeometry(missingObservedInterval, ['desiredLateralAccel', 'actualLateralAccel']).paths.map(paths => paths.length), [2, 2]) +const unavailableSignal = variedAge.map((sample, index) => index === 2 ? { ...sample, actualLateralAccel: null } : sample) +assert.deepEqual(chartGeometry(unavailableSignal, ['desiredLateralAccel', 'actualLateralAccel']).paths.map(paths => paths.length), [1, 2]) + assert.equal(appendPlotSample(history, payload(260), 10000).length, MAX_POINTS) const restarted = appendPlotSample(history, payload(260, { sessionId: 'cd'.repeat(8) }), 10000) assert.equal(restarted.length, 1) @@ -141,7 +154,7 @@ aged.feed.start() aged.advance(90) await aged.reply(0, payload(1, { sampleAgeSeconds: 1.4 })) assert.equal(aged.states.at(-1).status, 'current') -assert.equal(aged.states.at(-1).history[0].at, -400) +assert.equal(aged.states.at(-1).history[0].at, 1090) // Receipt time, independent of source age. aged.advance(10) assert.equal(aged.states.at(-1).status, 'stale') aged.feed.stop() diff --git a/openpilot/starpilot/galaxy/tests/test_preview_contract.py b/openpilot/starpilot/galaxy/tests/test_preview_contract.py index 223530d988..551e21254d 100644 --- a/openpilot/starpilot/galaxy/tests/test_preview_contract.py +++ b/openpilot/starpilot/galaxy/tests/test_preview_contract.py @@ -19,7 +19,7 @@ class PreviewContractTest(unittest.TestCase): self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "partial-preview"], ["/logs"]) self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "local-only"], ["/bluetooth", "/cameras", "/galaxy", "/manage_models", "/model_laboratory", "/navigation", "/system", - "/theme_maker", "/tuning/plots", "/vehicle", "/starport"]) + "/theme_maker", "/tuning", "/vehicle", "/starport"]) self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "always"], []) self.assertTrue(all(tool["availability"] in {"always", "unavailable", "partial-preview", "local-only"} for tool in catalog["tools"])) self.assertTrue(all(tool["name"] and tool["description"] and tool["icon"] for tool in catalog["tools"])) diff --git a/openpilot/starpilot/galaxy/tests/test_setting_descriptions.py b/openpilot/starpilot/galaxy/tests/test_setting_descriptions.py index b28944cf87..56327c1893 100644 --- a/openpilot/starpilot/galaxy/tests/test_setting_descriptions.py +++ b/openpilot/starpilot/galaxy/tests/test_setting_descriptions.py @@ -114,3 +114,16 @@ def test_actual_owner_projections_have_no_uncovered_setting(tmp_path): for page, owner in (('sounds', SoundsOwner(params, lambda: True)), ('pip', PiPOwner(params, lambda: True, lambda: False)), ('vasm', VASMOwner(params, lambda: True)), ('sentry', SentryOwner(params, lambda: True))): check(owner.snapshot().rows, page) + + +def test_manual_coordinate_purposes_preserve_startup_and_context_warnings(): + from openpilot.starpilot.flm.canonical_surface_adapter import NAMES + startup = "Parked only. Saved for the next drive; zero restores this stage's original baseline. Manual overrides pause Auto Learn." + changed = "Vehicle tune, controller or effect definition changed. Reset this manual tune before editing." + for name in NAMES: + row = FeatureRow('torque:surface:' + name, 'Manual correction', '0', reason=startup) + assert purpose(row) == 'Adjusts this manual steering correction.' + assert description(row).endswith(startup) + assert description(replace(row, available=False, reason=changed)).endswith(changed) + reset = FeatureRow('torque:surface:reset', 'Reset manual tune', 'Reset') + assert purpose(reset) == 'Removes the thirty manual steering overrides while keeping saved Auto Learn history.' diff --git a/openpilot/starpilot/galaxy/tests/test_settings.py b/openpilot/starpilot/galaxy/tests/test_settings.py index 2a12eae913..042578a7aa 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings.py +++ b/openpilot/starpilot/galaxy/tests/test_settings.py @@ -4,7 +4,6 @@ from dataclasses import replace from pathlib import Path from types import SimpleNamespace from concurrent.futures import ThreadPoolExecutor -import http.client import json import os import tempfile @@ -16,25 +15,16 @@ from opendbc.car.structs import car from opendbc.car.toyota.interface import CarInterface as ToyotaInterface from opendbc.car.toyota.values import CAR as ToyotaCar from openpilot.common.params import Params +from openpilot.starpilot.galaxy.tests.settings_http_fixture import MutableContext, SettingsHttpFixture from openpilot.starpilot.galaxy.settings import ( AuthorityContext, LiveContextSource, PAGES, SettingsChanged, SettingsGateway, ) -from openpilot.starpilot.galaxy.access import GalaxyAccessOwner -from openpilot.starpilot.galaxy.server import make_server from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.lateral.controller_selection import learning_allowed from openpilot.starpilot.lateral.learning_preferences import KEY as LEARNING_KEY, read_preference, replace_preference from openpilot.starpilot import saved_document -class MutableContext: - def __init__(self, cp): - self.value = AuthorityContext(True, cp, b"verified-cp") - - def sample(self): - return self.value - - class SettingsGatewayTest(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() @@ -1512,50 +1502,7 @@ class LiveContextTest(unittest.TestCase): self.assertEqual(path.read_bytes(), b"not a versioned cache") -class SettingsHttpTest(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.params = Params(temporary.name) - native = ToyotaInterface.get_non_essential_params(ToyotaCar.TOYOTA_COROLLA_TSS2) - cp = SimpleNamespace(carFingerprint="TOYOTA_COROLLA_TSS2", openpilotLongitudinalControl=True, - pcmCruise=False, notCar=False, dashcamOnly=False, passive=False, - brand="toyota", steerControlType=native.steerControlType, carVin="", - lateralTuning=native.lateralTuning, minSteerSpeed=native.minSteerSpeed, safetyConfigs=native.safetyConfigs, - transmissionType=car.CarParams.TransmissionType.automatic) - self.context = MutableContext(cp) - self.gateway = SettingsGateway(self.params, self.context) - self.access = GalaxyAccessOwner(Path(temporary.name) / "access") - self.assertTrue(self.access.configure("password123", lambda: True)) - self.server = make_server(port=0, owner=self.access, settings=self.gateway) - self.worker = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) - self.worker.start() - self.addCleanup(self.stop) - - def stop(self): - self.server.shutdown() - self.worker.join(timeout=2) - self.server.server_close() - - def request(self, path, *, payload=None, cookie="", origin=None): - connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=3) - headers = {"Cookie": cookie} - if payload is not None: - headers.update({"Content-Type": "application/json", - "Origin": origin if origin is not None else f"http://127.0.0.1:{self.server.server_port}"}) - try: - connection.request("POST" if payload is not None else "GET", path, - body=json.dumps(payload) if payload is not None else None, headers=headers) - result = connection.getresponse() - return result.status, json.loads(result.read()), dict(result.getheaders()) - finally: - connection.close() - - def login(self): - status, _, headers = self.request("/api/auth/login", payload={"password": "password123"}) - self.assertEqual(status, 200) - return headers["Set-Cookie"].split(";", 1)[0] - +class SettingsHttpTest(SettingsHttpFixture): def test_force_stop_disable_http_while_onroad(self): self.context.value = AuthorityContext(False, self.context.value.cp, b"verified-cp") self.params.put_bool("ForceStops", True, block=True) diff --git a/openpilot/starpilot/galaxy/tests/test_tuning_editor.mjs b/openpilot/starpilot/galaxy/tests/test_tuning_editor.mjs index 73c21a3526..43379de1fb 100644 --- a/openpilot/starpilot/galaxy/tests/test_tuning_editor.mjs +++ b/openpilot/starpilot/galaxy/tests/test_tuning_editor.mjs @@ -2,47 +2,61 @@ import assert from 'node:assert/strict' import { FlmPage } from '../web/js/flm.js' import { PlotsPage } from '../web/js/plots.js' import { SettingsFeed } from '../web/js/settings.js' +import { SURFACE_GROUPS } from '../web/js/flm-training-results.js' const vm = { ...FlmPage.data(), activePage: true, mode: 'local', unauthorized() {} } for (const [name, method] of Object.entries(FlmPage.methods)) vm[name] = method.bind(vm) for (const [name, method] of Object.entries(FlmPage.computed)) Object.defineProperty(vm, name, { get: () => method.call(vm) }) -const calls = [] -const snapshot = { page: 'torque', view: 'original-view', subtitle: 'Saved steering tune', rows: [ - { label: 'Use StarPilot Auto Learn', value: 'On', choices: ['Off', 'On'], available: true, action: true }, - { label: 'Lat Accel', value: '2', minimum: 1, maximum: 3, step: .05, available: true, action: true }, - { label: 'Friction', value: '.1', minimum: 0, maximum: .2, step: .01, available: false, action: true }, -] } -const timers = new Map() +const names = [...new Set(SURFACE_GROUPS.flatMap(group => group.names))] +assert.equal(names.length, 30) +const selected = names.indexOf(vm.selectedGroup.names[0]) +const locked = names.indexOf(vm.selectedGroup.names[1]) +const snapshot = { page: 'tuning', view: 'original-view', subtitle: 'Parked edits apply next drive', parked: true, + rows: names.map((coordinate, index) => ({ label: coordinate, coordinate, value: '0', + minimum: 0, maximum: 25, step: .25, available: index !== locked, action: true })) } +const calls = [], timers = new Map() +let edited = null vm.manualFeed = new SettingsFeed({ publish: update => Object.assign(vm.manualState, update), later: fn => { const key = timers.size + 1; timers.set(key, fn); return key }, cancelTimer: id => timers.delete(id), fetcher: async (path, options) => { - calls.push({ path, body: options.body ? JSON.parse(options.body) : null }) - if (path.endsWith('/torque')) return { ok: true, json: async () => structuredClone(snapshot) } - if (path.endsWith('/preview')) return { ok: true, json: async () => ({ intent: 'actual-settings-intent' }) } + const body = options.body ? JSON.parse(options.body) : null + calls.push({ path, body }) + if (path.endsWith('/tuning')) return { ok: true, json: async () => structuredClone(snapshot) } + assert(!path.endsWith('/torque'), 'Tuning does not fetch the duplicate scalar controls') + if (path.endsWith('/preview')) { edited = body; return { ok: true, json: async () => ({ intent: 'manual-coordinate-intent' }) } } assert(path.endsWith('/confirm')) return { ok: true, json: async () => ({ saved: true, page: { ...structuredClone(snapshot), view: 'saved-view', - rows: snapshot.rows.map((row, index) => index === 1 ? { ...row, value: '2.1' } : row) } }) } + rows: snapshot.rows.map((row, index) => index === edited.row ? { ...row, value: String(edited.value) } : row) } }) } } }) - await vm.selectView('overview') -assert.equal(calls.length, 0, 'Numeric settings do not poll outside Your tune') +assert.equal(calls.length, 0) await vm.selectView('tune') -assert.deepEqual(vm.manualRows.map(item => [item.row.label, item.index]), [['Lat Accel', 1], ['Friction', 2]]) -assert.equal(vm.manualRows[1].row.available, false, 'Backend unavailable control stays unavailable') -assert.equal(await vm.saveManual(2, .12), false, 'Unavailable controls cannot dispatch edits') -assert.equal(await vm.saveManual(0, 'Off'), false, 'Only numeric rows are editable here') -assert(await vm.saveManual(1, 2.1)) -assert.deepEqual(calls[1].body, { view: 'original-view', row: 1, value: 2.1 }) -assert.deepEqual(calls[2].body, { intent: 'actual-settings-intent', confirmed: true, page: 'torque' }) -assert.equal(vm.manualRows[0].row.value, '2.1', 'Display changes only after saved readback') -assert.equal(calls.length, 3, 'One edit uses existing preview then confirm') +assert.equal(calls[0].path, './api/settings/pages/tuning') +assert.equal(vm.manualRows.length, vm.selectedGroup.names.length, 'Only the selected group is shown') +assert.equal(await vm.saveManual(locked, 1), false, 'Unavailable controls cannot dispatch edits') +assert.equal(await vm.saveManual(selected, 1), true) +assert.deepEqual(calls[1].body, { view: 'original-view', row: selected, value: 1 }) +assert.deepEqual(calls[2].body, { intent: 'manual-coordinate-intent', confirmed: true, page: 'tuning' }) +assert.equal(vm.manualState.data.rows[selected].value, '1', 'Only confirmed saved readback changes the displayed setting') +assert.equal(vm.manualResetRequested, false, 'Numeric save does not open the reset dialog') vm.manualState.error = 'Saved settings changed' -assert.equal(await vm.saveManual(1, 2.2), false) -assert.equal(calls.length, 3, 'No writes with stale or failed settings') +assert.equal(await vm.saveManual(selected, 2), false) +assert.equal(calls.length, 3) +vm.manualState.error = '' +const resetIndex = vm.manualState.data.rows.length +vm.manualState.data.rows.push({ label: 'Reset manual tune', value: 'Reset', choices: ['Reset'], confirm: true, available: true, action: true }) +await vm.reviewManualReset(resetIndex, 1) +assert.equal(vm.manualResetRequested, true) +assert(vm.manualState.pending, 'Reset is held for explicit confirmation') +assert.deepEqual(calls.at(-1).body, { view: 'saved-view', row: resetIndex, direction: 1 }) +vm.cancelManualReset() +assert.equal(vm.manualState.pending, null) +assert.equal(vm.manualResetRequested, false) +assert.equal(vm.manualState.data.rows[selected].value, '1', 'Cancel preserves the saved manual value') await vm.selectView('plots') assert.equal(vm.manualFeed.active, false) -assert.equal(timers.size, 0, 'Leaving Your tune stops settings timers') -assert.equal(await vm.saveManual(1, 2.3), false) +assert.equal(timers.size, 0) +assert.equal(await vm.saveManual(selected, 2), false) const oldDocument = globalThis.document let started = 0, stopped = 0 @@ -66,4 +80,4 @@ try { PlotsPage.beforeUnmount.call(page) assert.equal(stopped, 1) } finally { globalThis.document = oldDocument } -console.log('Tuning numeric save/readback, stale write refusal, tab polling and retained plot lifecycle passed') +console.log('Thirty-coordinate editor, saved readback, stale-write refusal and retained plot lifecycle passed') diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 4b5d5c48d9..b3bbb581cb 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -31,278 +31,238 @@ .gx-map-manager__region small { display: block; margin-top: 4px; color: var(--on-surface-variant); } .gx-map-manager__region .gx-btn { flex-shrink: 0; } @media (max-width: 520px) { - .gx-card.gx-settings__header, .gx-card.gx-settings__row, .gx-card.gx-settings__dialog { padding: 16px; } - .gx-card.gx-vasm__editor { padding: 16px; } - .gx-map-manager { padding: 16px; } - .gx-map-manager__region { align-items: flex-start; flex-direction: column; } +.gx-card.gx-settings__header, .gx-card.gx-settings__row, .gx-card.gx-settings__dialog { padding: 16px; } +.gx-card.gx-vasm__editor { padding: 16px; } +.gx-map-manager { padding: 16px; } +.gx-map-manager__region { align-items: flex-start; flex-direction: column; } } @media (max-width: 680px) { .gx-settings__header { align-items: flex-start; } .gx-settings__subhead { align-items: flex-start; flex-direction: column; } } :root { - color-scheme: dark; - --primary: #9d72ff; - --primary-hover: #b38fff; - --primary-glow: rgba(157, 114, 255, 0.35); - --on-primary: #ffffff; - --primary-container: rgba(157, 114, 255, 0.18); - --on-primary-container: #ede4ff; - - --secondary: #4de8e8; - --secondary-glow: rgba(77, 232, 232, 0.3); - --error: #ff5277; - --on-error: #ffffff; - --warning: #ffb865; - --success: #4de8e8; - --bluetooth: #60a5fa; - - --background: #070712; - --surface: rgba(18, 15, 32, 0.65); - --surface-container: rgba(26, 21, 46, 0.55); - --surface-container-high: rgba(36, 30, 62, 0.7); - - --on-surface: #f3f2f8; - --on-surface-variant: #9d9bb8; - --text-muted: #8b88a8; - - --glass-bg: rgba(22, 18, 38, 0.7); - --glass-bg-hover: rgba(35, 29, 60, 0.8); - --glass-active-bg: rgba(157, 114, 255, 0.22); - --glass-border: rgba(255, 255, 255, 0.12); - --glass-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.35); - --glass-blur: blur(4px); - --glass-text: #f3f2f8; - - --outline: rgba(255, 255, 255, 0.08); - --outline-variant: rgba(255, 255, 255, 0.05); - --radius-sm: 14px; - --radius-md: 18px; - --radius-lg: 24px; - --radius-full: 9999px; - - --elev-1: 0 4px 12px rgba(0, 0, 0, 0.2); - --elev-2: 0 6px 18px rgba(0, 0, 0, 0.28); - --elev-3: 0 12px 32px rgba(0, 0, 0, 0.4); - - --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; - --sp-5: 24px; --sp-6: 32px; - - --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --fs-xs: 0.75rem; --fs-sm: 0.875rem; --fs-base: 1rem; - --fs-lg: 1.2rem; --fs-xl: 1.45rem; - --fw-medium: 500; --fw-bold: 600; - - --motion-fast: 150ms cubic-bezier(0.2, 0.9, 0.3, 1); - --motion-base: 240ms cubic-bezier(0.2, 0.9, 0.3, 1); - --motion-slow: 350ms cubic-bezier(0.16, 1, 0.3, 1); - --bottomnav-height: 72px; - --bottomnav-clearance: calc(var(--bottomnav-height) + env(safe-area-inset-bottom, 0px) + var(--sp-5)); - --touch-target: 44px; - --z-appbar: 100; - --z-drawer: 200; - --z-snackbar: 1300; - - --scrollbar-track: rgba(255, 255, 255, 0.03); - --scrollbar-thumb: rgba(157, 114, 255, 0.4); - --scrollbar-thumb-hover: rgba(157, 114, 255, 0.7); - --scrollbar-border: rgba(157, 114, 255, 0.3); - - --content-width: 1200px; - --panel-padding: var(--sp-5); +color-scheme: dark; +--primary: #9d72ff; +--primary-hover: #b38fff; +--primary-glow: rgba(157, 114, 255, 0.35); +--on-primary: #ffffff; +--primary-container: rgba(157, 114, 255, 0.18); +--on-primary-container: #ede4ff; +--secondary: #4de8e8; +--secondary-glow: rgba(77, 232, 232, 0.3); +--error: #ff5277; +--on-error: #ffffff; +--warning: #ffb865; +--success: #4de8e8; +--bluetooth: #60a5fa; +--background: #070712; +--surface: rgba(18, 15, 32, 0.65); +--surface-container: rgba(26, 21, 46, 0.55); +--surface-container-high: rgba(36, 30, 62, 0.7); +--on-surface: #f3f2f8; +--on-surface-variant: #9d9bb8; +--text-muted: #8b88a8; +--glass-bg: rgba(22, 18, 38, 0.7); +--glass-bg-hover: rgba(35, 29, 60, 0.8); +--glass-active-bg: rgba(157, 114, 255, 0.22); +--glass-border: rgba(255, 255, 255, 0.12); +--glass-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.35); +--glass-blur: blur(4px); +--glass-text: #f3f2f8; +--outline: rgba(255, 255, 255, 0.08); +--outline-variant: rgba(255, 255, 255, 0.05); +--radius-sm: 14px; +--radius-md: 18px; +--radius-lg: 24px; +--radius-full: 9999px; +--elev-1: 0 4px 12px rgba(0, 0, 0, 0.2); +--elev-2: 0 6px 18px rgba(0, 0, 0, 0.28); +--elev-3: 0 12px 32px rgba(0, 0, 0, 0.4); +--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; +--sp-5: 24px; --sp-6: 32px; +--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +--fs-xs: 0.75rem; --fs-sm: 0.875rem; --fs-base: 1rem; +--fs-lg: 1.2rem; --fs-xl: 1.45rem; +--fw-medium: 500; --fw-bold: 600; +--motion-fast: 150ms cubic-bezier(0.2, 0.9, 0.3, 1); +--motion-base: 240ms cubic-bezier(0.2, 0.9, 0.3, 1); +--motion-slow: 350ms cubic-bezier(0.16, 1, 0.3, 1); +--bottomnav-height: 72px; +--bottomnav-clearance: calc(var(--bottomnav-height) + env(safe-area-inset-bottom, 0px) + var(--sp-5)); +--touch-target: 44px; +--z-appbar: 100; +--z-drawer: 200; +--z-snackbar: 1300; +--scrollbar-track: rgba(255, 255, 255, 0.03); +--scrollbar-thumb: rgba(157, 114, 255, 0.4); +--scrollbar-thumb-hover: rgba(157, 114, 255, 0.7); +--scrollbar-border: rgba(157, 114, 255, 0.3); +--content-width: 1200px; +--panel-padding: var(--sp-5); } - [data-theme="light"] { - color-scheme: light; - --primary: #7849e8; - --primary-hover: #6737d9; - --primary-glow: rgba(120, 73, 232, 0.25); - --on-primary: #ffffff; - --primary-container: rgba(120, 73, 232, 0.12); - --on-primary-container: #471fa8; - - --secondary: #0c9b9b; - --secondary-glow: rgba(12, 155, 155, 0.25); - --error: #d63a62; - --on-error: #ffffff; - --warning: #c9821f; - --success: #0c9b9b; - --bluetooth: #2563eb; - - --background: #f2f4fc; - --surface: rgba(255, 255, 255, 0.85); - --surface-container: rgba(255, 255, 255, 0.75); - --surface-container-high: rgba(255, 255, 255, 0.95); - - --on-surface: #171526; - --on-surface-variant: #5f5b7d; - --text-muted: #6f6a8f; - - --glass-bg: rgba(255, 255, 255, 0.82); - --glass-bg-hover: rgba(255, 255, 255, 0.95); - --glass-active-bg: rgba(120, 73, 232, 0.15); - --glass-border: rgba(120, 73, 232, 0.28); - --glass-shadow: 0 8px 24px 0 rgba(80, 60, 140, 0.08); - --glass-text: #171526; - - --outline: rgba(120, 73, 232, 0.14); - --outline-variant: rgba(120, 73, 232, 0.08); - - --scrollbar-track: rgba(0, 0, 0, 0.05); - --scrollbar-thumb: #7849e8; - --scrollbar-thumb-hover: #5d31ca; - --scrollbar-border: #471fa8; +color-scheme: light; +--primary: #7849e8; +--primary-hover: #6737d9; +--primary-glow: rgba(120, 73, 232, 0.25); +--on-primary: #ffffff; +--primary-container: rgba(120, 73, 232, 0.12); +--on-primary-container: #471fa8; +--secondary: #0c9b9b; +--secondary-glow: rgba(12, 155, 155, 0.25); +--error: #d63a62; +--on-error: #ffffff; +--warning: #c9821f; +--success: #0c9b9b; +--bluetooth: #2563eb; +--background: #f2f4fc; +--surface: rgba(255, 255, 255, 0.85); +--surface-container: rgba(255, 255, 255, 0.75); +--surface-container-high: rgba(255, 255, 255, 0.95); +--on-surface: #171526; +--on-surface-variant: #5f5b7d; +--text-muted: #6f6a8f; +--glass-bg: rgba(255, 255, 255, 0.82); +--glass-bg-hover: rgba(255, 255, 255, 0.95); +--glass-active-bg: rgba(120, 73, 232, 0.15); +--glass-border: rgba(120, 73, 232, 0.28); +--glass-shadow: 0 8px 24px 0 rgba(80, 60, 140, 0.08); +--glass-text: #171526; +--outline: rgba(120, 73, 232, 0.14); +--outline-variant: rgba(120, 73, 232, 0.08); +--scrollbar-track: rgba(0, 0, 0, 0.05); +--scrollbar-thumb: #7849e8; +--scrollbar-thumb-hover: #5d31ca; +--scrollbar-border: #471fa8; } - * { - box-sizing: border-box; - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +box-sizing: border-box; +scrollbar-width: thin; +scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } - *::-webkit-scrollbar { - width: 8px; - height: 8px; +width: 8px; +height: 8px; } - *::-webkit-scrollbar-track { - background: var(--scrollbar-track); - border-radius: var(--radius-full); +background: var(--scrollbar-track); +border-radius: var(--radius-full); } - *::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border: 1px solid var(--scrollbar-border); - border-radius: var(--radius-full); +background: var(--scrollbar-thumb); +border: 1px solid var(--scrollbar-border); +border-radius: var(--radius-full); } - *::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); +background: var(--scrollbar-thumb-hover); } - /* Galaxy is a touch-first PWA: hide scrollbars everywhere, scrolling stays fully functional. */ * { - scrollbar-width: none; - -ms-overflow-style: none; +scrollbar-width: none; +-ms-overflow-style: none; } *::-webkit-scrollbar { - width: 0; - height: 0; +width: 0; +height: 0; } - button::-moz-focus-inner { - border: 0; - padding: 0; +border: 0; +padding: 0; } - html, body { - background: var(--background); - color: var(--on-surface); - font-family: var(--font-body); - margin: 0; - min-height: 100dvh; - padding: 0; - -webkit-tap-highlight-color: transparent; - text-rendering: optimizeSpeed; - touch-action: pan-x pan-y; - -webkit-text-size-adjust: 100%; - text-size-adjust: 100%; +background: var(--background); +color: var(--on-surface); +font-family: var(--font-body); +margin: 0; +min-height: 100dvh; +padding: 0; +-webkit-tap-highlight-color: transparent; +text-rendering: optimizeSpeed; +touch-action: pan-x pan-y; +-webkit-text-size-adjust: 100%; +text-size-adjust: 100%; } - html { cursor: default; } - body { - overscroll-behavior-y: contain; - padding-bottom: env(safe-area-inset-bottom); +overscroll-behavior-y: contain; +padding-bottom: env(safe-area-inset-bottom); } - #galaxy-bg { - position: fixed; - inset: 0; - overflow: hidden; - z-index: 0; - background-color: var(--background); - background-image: - radial-gradient(ellipse at 15% 20%, rgba(157, 114, 255, 0.22) 0%, transparent 55%), - radial-gradient(ellipse at 85% 75%, rgba(85, 45, 180, 0.18) 0%, transparent 60%), - radial-gradient(circle at 75% 15%, rgba(77, 232, 232, 0.12) 0%, transparent 48%); - pointer-events: none; - isolation: isolate; - contain: strict; +position: fixed; +inset: 0; +overflow: hidden; +z-index: 0; +background-color: var(--background); +background-image: +radial-gradient(ellipse at 15% 20%, rgba(157, 114, 255, 0.22) 0%, transparent 55%), +radial-gradient(ellipse at 85% 75%, rgba(85, 45, 180, 0.18) 0%, transparent 60%), +radial-gradient(circle at 75% 15%, rgba(77, 232, 232, 0.12) 0%, transparent 48%); +pointer-events: none; +isolation: isolate; +contain: strict; } - #galaxy-bg::before, #galaxy-bg::after { - content: ""; - position: absolute; - inset: 0; +content: ""; +position: absolute; +inset: 0; } - #galaxy-bg::before { - background-image: - radial-gradient(1px 1px at 30px 40px, #ffffff, rgba(255,255,255,0)), - radial-gradient(1px 1px at 110px 220px, rgba(220, 200, 255, 0.8), rgba(255,255,255,0)), - radial-gradient(1.5px 1.5px at 200px 90px, #ffffff, rgba(255,255,255,0)), - radial-gradient(1.5px 1.5px at 310px 340px, rgba(160, 240, 255, 0.9), rgba(255,255,255,0)), - radial-gradient(1px 1px at 410px 150px, #ffffff, rgba(255,255,255,0)), - radial-gradient(1.5px 1.5px at 640px 110px, #ffffff, rgba(255,255,255,0)), - radial-gradient(1px 1px at 890px 180px, #ffffff, rgba(255,255,255,0)); - background-size: 800px 400px; - opacity: 0.5; +background-image: +radial-gradient(1px 1px at 30px 40px, #ffffff, rgba(255,255,255,0)), +radial-gradient(1px 1px at 110px 220px, rgba(220, 200, 255, 0.8), rgba(255,255,255,0)), +radial-gradient(1.5px 1.5px at 200px 90px, #ffffff, rgba(255,255,255,0)), +radial-gradient(1.5px 1.5px at 310px 340px, rgba(160, 240, 255, 0.9), rgba(255,255,255,0)), +radial-gradient(1px 1px at 410px 150px, #ffffff, rgba(255,255,255,0)), +radial-gradient(1.5px 1.5px at 640px 110px, #ffffff, rgba(255,255,255,0)), +radial-gradient(1px 1px at 890px 180px, #ffffff, rgba(255,255,255,0)); +background-size: 800px 400px; +opacity: 0.5; } - #galaxy-bg::after { - background-image: - radial-gradient(1.5px 1.5px at 80px 140px, #ffffff, rgba(255,255,255,0)), - radial-gradient(2px 2px at 240px 390px, rgba(190, 235, 255, 0.9), rgba(255,255,255,0)), - radial-gradient(1.5px 1.5px at 380px 60px, rgba(230, 195, 255, 0.85), rgba(255,255,255,0)), - radial-gradient(2px 2px at 560px 480px, #ffffff, rgba(255,255,255,0)), - radial-gradient(1.5px 1.5px at 730px 220px, rgba(255, 255, 255, 0.9), rgba(255,255,255,0)); - background-size: 900px 450px; - opacity: 0.6; +background-image: +radial-gradient(1.5px 1.5px at 80px 140px, #ffffff, rgba(255,255,255,0)), +radial-gradient(2px 2px at 240px 390px, rgba(190, 235, 255, 0.9), rgba(255,255,255,0)), +radial-gradient(1.5px 1.5px at 380px 60px, rgba(230, 195, 255, 0.85), rgba(255,255,255,0)), +radial-gradient(2px 2px at 560px 480px, #ffffff, rgba(255,255,255,0)), +radial-gradient(1.5px 1.5px at 730px 220px, rgba(255, 255, 255, 0.9), rgba(255,255,255,0)); +background-size: 900px 450px; +opacity: 0.6; } - #galaxy-bg .galaxy-hero { - position: absolute; - width: 8px; - height: 8px; - margin: calc((var(--galaxy-star-core) - 8px) / 2); - border-radius: 50%; - background: radial-gradient(circle, - #fff 0 calc(var(--galaxy-star-core) / 2), - rgba(255, 255, 255, 0.15) calc(var(--galaxy-star-core) / 2 + 0.3px), - rgba(255, 255, 255, 0.04) calc(var(--galaxy-star-core) / 2 + 1.5px), - transparent 4px); - animation: galaxy-twinkle 6s ease-in-out infinite alternate; +position: absolute; +width: 8px; +height: 8px; +margin: calc((var(--galaxy-star-core) - 8px) / 2); +border-radius: 50%; +background: radial-gradient(circle, +#fff 0 calc(var(--galaxy-star-core) / 2), +rgba(255, 255, 255, 0.15) calc(var(--galaxy-star-core) / 2 + 0.3px), +rgba(255, 255, 255, 0.04) calc(var(--galaxy-star-core) / 2 + 1.5px), +transparent 4px); +animation: galaxy-twinkle 6s ease-in-out infinite alternate; } - @keyframes galaxy-twinkle { - 0%, 100% { opacity: 0.3; transform: rotateZ(0.05deg) scale(0.9); } - 50% { opacity: 1; transform: rotateZ(0.05deg) scale(1.2); } +0%, 100% { opacity: 0.3; transform: rotateZ(0.05deg) scale(0.9); } +50% { opacity: 1; transform: rotateZ(0.05deg) scale(1.2); } } - #galaxy-bg.galaxy-stars-paused .galaxy-hero { animation-play-state: paused; } - @media (prefers-reduced-motion: reduce) { - #galaxy-bg .galaxy-hero { animation: none !important; } +#galaxy-bg .galaxy-hero { animation: none !important; } } - /* Keep decorative animation out of route viewers, modal backdrops and drawer content. */ body:has(.gx-dialog[open], .gx-underlay, .gx-drawer.open, .gx-route-workspace) #galaxy-bg .galaxy-hero { - display: none; +display: none; } - [data-theme="light"] #galaxy-bg { - background-color: #eef1fb; - background-image: - radial-gradient(ellipse at 20% 20%, rgba(180, 145, 255, 0.28) 0%, transparent 55%), - radial-gradient(ellipse at 80% 80%, rgba(120, 220, 235, 0.22) 0%, transparent 60%), - radial-gradient(circle at 70% 15%, rgba(255, 170, 210, 0.18) 0%, transparent 48%); +background-color: #eef1fb; +background-image: +radial-gradient(ellipse at 20% 20%, rgba(180, 145, 255, 0.28) 0%, transparent 55%), +radial-gradient(ellipse at 80% 80%, rgba(120, 220, 235, 0.22) 0%, transparent 60%), +radial-gradient(circle at 70% 15%, rgba(255, 170, 210, 0.18) 0%, transparent 48%); } - [data-theme="light"] #galaxy-bg::before, [data-theme="light"] #galaxy-bg::after, [data-theme="light"] #galaxy-bg .galaxy-hero { - opacity: 0.12; +opacity: 0.12; } - [v-cloak] { display: none !important; } a { color: inherit; text-decoration: none; } .gx-link { color: #60a5fa; } @@ -310,441 +270,394 @@ a { color: inherit; text-decoration: none; } .gx-link:hover { text-decoration: underline; } button { font-family: var(--font-body); } ul { list-style: none; margin: 0; padding: 0; } - :focus-visible { - outline: 2px solid var(--primary); - outline-offset: 3px; +outline: 2px solid var(--primary); +outline-offset: 3px; } - .gx-app { - display: flex; - flex-direction: column; - min-height: 100dvh; - position: relative; - z-index: 1; - animation: gx-app-in 460ms cubic-bezier(0.16, 1, 0.3, 1) both; +display: flex; +flex-direction: column; +min-height: 100dvh; +position: relative; +z-index: 1; +animation: gx-app-in 460ms cubic-bezier(0.16, 1, 0.3, 1) both; } - .gx-appbar { - container: galaxy-header / inline-size; - align-items: center; - display: flex; - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-3); - padding-top: calc(env(safe-area-inset-top) + var(--sp-2)); - position: sticky; - top: 0; - z-index: var(--z-appbar); - transform: translateZ(0); - isolation: isolate; - width: 100%; - max-width: var(--content-width); - margin-inline: auto; +container: galaxy-header / inline-size; +align-items: center; +display: flex; +gap: var(--sp-2); +padding: var(--sp-2) var(--sp-3); +padding-top: calc(env(safe-area-inset-top) + var(--sp-2)); +position: sticky; +top: 0; +z-index: var(--z-appbar); +transform: translateZ(0); +isolation: isolate; +width: 100%; +max-width: var(--content-width); +margin-inline: auto; } - .gx-appbar__pill { - align-items: center; - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-border); - border-radius: var(--radius-full); - box-shadow: var(--glass-shadow); - color: var(--glass-text); - display: flex; - flex: 1; - gap: var(--sp-2); - min-width: 0; - padding: 6px 12px; - transition: background-color var(--motion-fast), border-color var(--motion-fast); +align-items: center; +background: var(--glass-bg); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border: 1px solid var(--glass-border); +border-radius: var(--radius-full); +box-shadow: var(--glass-shadow); +color: var(--glass-text); +display: flex; +flex: 1; +gap: var(--sp-2); +min-width: 0; +padding: 6px 12px; +transition: background-color var(--motion-fast), border-color var(--motion-fast); } - .gx-appbar .gx-appbar__back, .gx-appbar .gx-theme-toggle, .gx-appbar .gx-appbar__menu { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - margin: 0; - line-height: 1; - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-border); - border-radius: 50%; - box-shadow: var(--glass-shadow); - color: var(--glass-text); - flex: none; - height: var(--touch-target); - width: var(--touch-target); - transition: transform var(--motion-fast), background-color var(--motion-fast); +display: inline-flex; +align-items: center; +justify-content: center; +padding: 0; +margin: 0; +line-height: 1; +background: var(--glass-bg); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border: 1px solid var(--glass-border); +border-radius: 50%; +box-shadow: var(--glass-shadow); +color: var(--glass-text); +flex: none; +height: var(--touch-target); +width: var(--touch-target); +transition: transform var(--motion-fast), background-color var(--motion-fast); } - .gx-appbar .gx-appbar__back:active, .gx-appbar .gx-theme-toggle:active, .gx-appbar .gx-appbar__menu:active { transform: scale(0.92); } - .gx-appbar__title { - background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - font-size: var(--fs-lg); - font-weight: var(--fw-bold); - white-space: nowrap; - letter-spacing: -0.02em; +background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%); +-webkit-background-clip: text; +background-clip: text; +-webkit-text-fill-color: transparent; +font-size: var(--fs-lg); +font-weight: var(--fw-bold); +white-space: nowrap; +letter-spacing: -0.02em; } - .gx-appbar__home { - flex: none; - min-height: var(--touch-target); - display: inline-flex; - align-items: center; - gap: 10px; - cursor: pointer; - padding: 2px 12px 2px 8px; - margin-right: 4px; - border-radius: var(--radius-full); - transition: background-color var(--motion-fast), transform var(--motion-fast); +flex: none; +min-height: var(--touch-target); +display: inline-flex; +align-items: center; +gap: 10px; +cursor: pointer; +padding: 2px 12px 2px 8px; +margin-right: 4px; +border-radius: var(--radius-full); +transition: background-color var(--motion-fast), transform var(--motion-fast); } - .gx-brand { display: block; width: 28px; height: 28px; flex: none; background: currentColor; mask: url("../assets/starpilot-logo.svg") center / contain no-repeat; } [data-theme="light"] .gx-brand { color: #6f42a6; } .gx-drawer__header .gx-brand { width: 44px; height: 44px; } - .gx-appbar__home:hover { background-color: var(--glass-bg-hover); } - .gx-appbar__home:active { transform: scale(0.96); } - .gx-appbar__home:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; +outline: 2px solid var(--primary); +outline-offset: 2px; } - .gx-appbar__right { - flex: none; - align-items: center; - display: flex; - gap: var(--sp-2); - /* Nudge the status cluster off the pill edge so it reads balanced with the search gap. */ - margin-left: auto; - margin-right: 4px; +flex: none; +align-items: center; +display: flex; +gap: var(--sp-2); +/* Nudge the status cluster off the pill edge so it reads balanced with the search gap. */ +margin-left: auto; +margin-right: 4px; } - .gx-search { - background: var(--surface-container); - border: 1px solid var(--glass-border); - border-radius: var(--radius-full); - color: var(--on-surface); - font-size: max(16px, var(--fs-sm)); - height: 40px; - outline: none; - padding: 0 var(--sp-4); - transition: border-color var(--motion-fast), box-shadow var(--motion-fast); +background: var(--surface-container); +border: 1px solid var(--glass-border); +border-radius: var(--radius-full); +color: var(--on-surface); +font-size: max(16px, var(--fs-sm)); +height: 40px; +outline: none; +padding: 0 var(--sp-4); +transition: border-color var(--motion-fast), box-shadow var(--motion-fast); } - .gx-search:focus { - background: var(--surface-container-high); - border-color: var(--primary); - box-shadow: 0 0 0 2px var(--primary-glow); +background: var(--surface-container-high); +border-color: var(--primary); +box-shadow: 0 0 0 2px var(--primary-glow); } - [data-theme="light"] .gx-search { - border-color: rgba(120, 73, 232, 0.3); +border-color: rgba(120, 73, 232, 0.3); } - .gx-icon-btn { - align-items: center; - background: transparent; - border: none; - border-radius: var(--radius-full); - color: inherit; - cursor: pointer; - display: inline-flex; - height: var(--touch-target); - justify-content: center; - min-height: var(--touch-target); - min-width: var(--touch-target); - padding: 0; - margin: 0; - line-height: 1; - position: relative; - transition: background-color var(--motion-fast), transform var(--motion-fast); - width: var(--touch-target); +align-items: center; +background: transparent; +border: none; +border-radius: var(--radius-full); +color: inherit; +cursor: pointer; +display: inline-flex; +height: var(--touch-target); +justify-content: center; +min-height: var(--touch-target); +min-width: var(--touch-target); +padding: 0; +margin: 0; +line-height: 1; +position: relative; +transition: background-color var(--motion-fast), transform var(--motion-fast); +width: var(--touch-target); } - .gx-icon-btn:hover { background: var(--glass-bg-hover); } .gx-icon-btn:active { transform: scale(0.9); } .gx-icon-btn i, .gx-appbar .bi, .gx-appbar i { - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; - height: 1em; - width: 1em; - font-size: 1.3rem; - vertical-align: middle; +display: inline-flex; +align-items: center; +justify-content: center; +line-height: 1; +height: 1em; +width: 1em; +font-size: 1.3rem; +vertical-align: middle; } .gx-back-btn { display: none; } .gx-appbar__menu { display: inline-flex; } - .gx-searchbox { - align-items: center; - display: flex; - flex: 1; - gap: var(--sp-1); - min-width: 0; - position: relative; +align-items: center; +display: flex; +flex: 1; +gap: var(--sp-1); +min-width: 0; +position: relative; } - .gx-search-toggle { display: none; } - .gx-searchwrap { - align-items: center; - display: flex; - flex: 1; - gap: var(--sp-2); - min-width: 0; - padding-left: var(--sp-4); - position: relative; +align-items: center; +display: flex; +flex: 1; +gap: var(--sp-2); +min-width: 0; +padding-left: var(--sp-4); +position: relative; } - .gx-search__icon { color: var(--text-muted); flex: none; font-size: 1rem; width: 1rem; text-align: center; } - .gx-searchwrap .gx-search { - padding-left: var(--sp-3); - padding-right: var(--sp-5); - width: 100%; +padding-left: var(--sp-3); +padding-right: var(--sp-5); +width: 100%; } - .gx-search-results { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-border); - border-radius: var(--radius-lg); - box-shadow: 0 12px 32px rgba(0, 0, 0, .28); - animation: gx-fade-in var(--motion-fast) ease-out; - box-sizing: border-box; - max-height: min(60vh, 420px); - overscroll-behavior: contain; - overflow-y: auto; - position: fixed; - z-index: 1100; +background: var(--glass-bg); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border: 1px solid var(--glass-border); +border-radius: var(--radius-lg); +box-shadow: 0 12px 32px rgba(0, 0, 0, .28); +animation: gx-fade-in var(--motion-fast) ease-out; +box-sizing: border-box; +max-height: min(60vh, 420px); +overscroll-behavior: contain; +overflow-y: auto; +position: fixed; +z-index: 1100; } - .gx-search-results__item { - background: transparent; - border: 0; - color: var(--on-surface); - cursor: pointer; - display: flex; - flex-direction: column; - gap: 2px; - padding: 10px 14px; - text-align: left; - width: 100%; +background: transparent; +border: 0; +color: var(--on-surface); +cursor: pointer; +display: flex; +flex-direction: column; +gap: 2px; +padding: 10px 14px; +text-align: left; +width: 100%; } .gx-search-results__item:hover, .gx-search-results__item.active { background: var(--glass-bg-hover); } .gx-search-results__item span, .gx-search-results__status { color: var(--text-muted); font-size: var(--fs-xs); } .gx-search-results__status { margin: 0; padding: 12px 14px; } - @keyframes gx-fade-in { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } +from { opacity: 0; transform: translateY(-4px); } +to { opacity: 1; transform: translateY(0); } } - @keyframes gx-app-in { - from { opacity: 0; } - to { opacity: 1; } +from { opacity: 0; } +to { opacity: 1; } } - .gx-status-pill { - align-items: center; - background: var(--surface-container); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-border); - border-radius: var(--radius-full); - display: inline-flex; - font-size: var(--fs-xs); - font-weight: var(--fw-medium); - gap: var(--sp-2); - padding: 6px 12px; +align-items: center; +background: var(--surface-container); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border: 1px solid var(--glass-border); +border-radius: var(--radius-full); +display: inline-flex; +font-size: var(--fs-xs); +font-weight: var(--fw-medium); +gap: var(--sp-2); +padding: 6px 12px; } - [data-theme="light"] .gx-status-pill { - border: 1px solid rgba(120, 73, 232, 0.3); +border: 1px solid rgba(120, 73, 232, 0.3); } - .gx-status-dot { - border-radius: 50%; - height: 8px; - width: 8px; +border-radius: 50%; +height: 8px; +width: 8px; } - .gx-status-dot.offline { - background: var(--error); - box-shadow: 0 0 8px var(--error); +background: var(--error); +box-shadow: 0 0 8px var(--error); } - .gx-content { - contain: layout style; - display: flex; - flex: 1; - flex-direction: column; - margin: 0 auto; - max-width: var(--content-width); - padding: var(--sp-4) var(--sp-4) calc(var(--bottomnav-height) + var(--sp-5)); - width: 100%; +contain: layout style; +display: flex; +flex: 1; +flex-direction: column; +margin: 0 auto; +max-width: var(--content-width); +padding: var(--sp-4) var(--sp-4) calc(var(--bottomnav-height) + var(--sp-5)); +width: 100%; } - .blur-nav { - position: fixed; - bottom: calc(18px + env(safe-area-inset-bottom, 0px)); - left: 50%; - transform: translateX(-50%) translateZ(0); - will-change: transform; - isolation: isolate; - contain: layout style; - z-index: 1000; - - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - align-items: center; - width: min(420px, calc(100vw - 32px)); - padding: 6px; - - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - - border-radius: var(--radius-full); - border: 1px solid var(--glass-border); - box-shadow: var(--glass-shadow); - - transition: transform var(--motion-slow), opacity var(--motion-base), background-color var(--motion-fast); - max-width: calc(100vw - 32px); +position: fixed; +bottom: calc(18px + env(safe-area-inset-bottom, 0px)); +left: 50%; +transform: translateX(-50%) translateZ(0); +will-change: transform; +isolation: isolate; +contain: layout style; +z-index: 1000; +display: grid; +grid-template-columns: repeat(4, minmax(0, 1fr)); +align-items: center; +width: min(420px, calc(100vw - 32px)); +padding: 6px; +background: var(--glass-bg); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border-radius: var(--radius-full); +border: 1px solid var(--glass-border); +box-shadow: var(--glass-shadow); +transition: transform var(--motion-slow), opacity var(--motion-base), background-color var(--motion-fast); +max-width: calc(100vw - 32px); } - .gx-nav-indicator { position: absolute; inset: 6px auto 6px 6px; width: calc((100% - 12px) / 4); border-radius: var(--radius-full); background: linear-gradient(135deg, rgba(255, 255, 255, .2), transparent 60%), var(--glass-active-bg); border: 1px solid var(--glass-border); box-shadow: inset 0 1px 1px rgba(255, 255, 255, .3), 0 3px 12px rgba(0, 0, 0, .15); transform: translateX(calc(var(--active-tab, 0) * 100%)); transition: transform 420ms cubic-bezier(.22, 1, .36, 1), scale 180ms ease; pointer-events: none; } .blur-nav:has(.nav-item:active) .gx-nav-indicator { scale: 1.035 1.06; } .blur-nav .nav-item i { transition: transform var(--motion-base); } - [data-theme="light"] .blur-nav { - border: 1.5px solid var(--primary); +border: 1.5px solid var(--primary); } - .blur-nav .nav-item { - position: relative; - z-index: 1; - min-width: 0; - min-height: var(--touch-target); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 8px 4px; - border-radius: var(--radius-full); - color: var(--on-surface-variant); - text-decoration: none; - font-size: 0.75rem; - font-weight: var(--fw-medium); - transition: color var(--motion-fast), background-color var(--motion-fast); - background: transparent; - border: none; - cursor: pointer; +position: relative; +z-index: 1; +min-width: 0; +min-height: var(--touch-target); +display: flex; +flex-direction: column; +align-items: center; +justify-content: center; +padding: 8px 4px; +border-radius: var(--radius-full); +color: var(--on-surface-variant); +text-decoration: none; +font-size: 0.75rem; +font-weight: var(--fw-medium); +transition: color var(--motion-fast), background-color var(--motion-fast); +background: transparent; +border: none; +cursor: pointer; } - .blur-nav .nav-item i { - font-size: 1.3rem; - margin-bottom: 2px; - transition: transform var(--motion-fast); +font-size: 1.3rem; +margin-bottom: 2px; +transition: transform var(--motion-fast); } - .blur-nav .nav-item:disabled { opacity: .4; cursor: not-allowed; } - .blur-nav .nav-item:hover:not(:disabled) { - color: var(--on-surface); - background: rgba(255, 255, 255, 0.08); +color: var(--on-surface); +background: rgba(255, 255, 255, 0.08); } - .blur-nav .nav-item:active i { - transform: scale(0.88); +transform: scale(0.88); } - .blur-nav .nav-item.active { - background: transparent; - color: var(--primary); - font-weight: var(--fw-bold); +background: transparent; +color: var(--primary); +font-weight: var(--fw-bold); } - .blur-nav .nav-item.active i { - transform: translateY(-2px); +transform: translateY(-2px); } - .gx-card { - contain: layout style; - background: var(--surface-container); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-border); - border-radius: var(--radius-lg); - box-shadow: var(--elev-1); - margin-bottom: var(--sp-4); - overflow: hidden; - transition: transform var(--motion-fast), box-shadow var(--motion-fast), background-color 0.15s ease-out; +contain: layout style; +background: var(--surface-container); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border: 1px solid var(--glass-border); +border-radius: var(--radius-lg); +box-shadow: var(--elev-1); +margin-bottom: var(--sp-4); +overflow: hidden; +transition: transform var(--motion-fast), box-shadow var(--motion-fast), background-color 0.15s ease-out; } - .gx-btn { - align-items: center; - background: var(--primary); - border: none; - border-radius: var(--radius-full); - box-shadow: 0 4px 12px var(--primary-glow); - color: var(--on-primary); - cursor: pointer; - display: inline-flex; - font-family: var(--font-body); - font-size: var(--fs-sm); - font-weight: var(--fw-bold); - line-height: 1.4; - gap: var(--sp-2); - justify-content: center; - min-height: 44px; - padding: 0 var(--sp-5); - position: relative; - transition: transform var(--motion-fast), background-color var(--motion-fast); +align-items: center; +background: var(--primary); +border: none; +border-radius: var(--radius-full); +box-shadow: 0 4px 12px var(--primary-glow); +color: var(--on-primary); +cursor: pointer; +display: inline-flex; +font-family: var(--font-body); +font-size: var(--fs-sm); +font-weight: var(--fw-bold); +line-height: 1.4; +gap: var(--sp-2); +justify-content: center; +min-height: 44px; +padding: 0 var(--sp-5); +position: relative; +transition: transform var(--motion-fast), background-color var(--motion-fast); } - .gx-btn:hover:not(:disabled) { background: var(--primary-hover); } .gx-btn:active:not(:disabled) { transform: scale(0.96); } .gx-btn:disabled { opacity: 0.4; box-shadow: none; cursor: not-allowed; } .gx-btn.gx-btn--danger { background: var(--error); color: var(--on-error); } - .gx-grid { - display: grid; - gap: var(--sp-3); - grid-auto-rows: 1fr; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); +display: grid; +gap: var(--sp-3); +grid-auto-rows: 1fr; +grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } - .gx-card-grid { - display: grid; - gap: var(--sp-3); - grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); +display: grid; +gap: var(--sp-3); +grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); } - .gx-card-grid .gx-card { margin-bottom: 0; } - .gx-plots__toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; margin: 1rem 0; } -.gx-plots__status { flex: 0 0 100%; min-height: 2.8em; display: flex; align-items: center; } -.gx-plots__hint { min-height: 2.8em; } +.gx-plots__status { flex: 0 0 100%; height: 2.8em; overflow: auto; display: flex; align-items: center; } +.gx-plots__hint { height: 2.8em; overflow: auto; } .gx-plots__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; } -.gx-plots__panel { min-width: 0; --card-inset: clamp(.85rem, 2vw, 1.25rem); padding: var(--card-inset); } +.gx-card.gx-plots__panel { min-width: 0; --card-inset: clamp(.85rem, 2vw, 1.25rem); padding: var(--card-inset); background: linear-gradient(var(--surface), var(--surface)), var(--background); backdrop-filter: none; } +/* Telemetry can change length without moving the chart or the next card. */ +.gx-plots__source { height: 2.8em; overflow: auto; } +.gx-plots__readout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin: .85rem 0; } +.gx-plots__readout dt { color: var(--on-surface-variant); font-size: .8rem; } +.gx-plots__readout dd { margin: .2rem 0 0; min-height: 1.5em; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: auto; } +.gx-plots__match { height: 4.5em; overflow: auto; line-height: 1.5; } .gx-plots__panel svg { display: block; width: 100%; height: auto; min-height: 8rem; background: rgba(127,127,127,.06); border-radius: .6rem; } .gx-plots__panel polyline { fill: none; stroke-width: 2.5; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; } .gx-plots__panel .gx-plot-axis { stroke: var(--on-surface-variant); opacity: .5; stroke-width: 1; } @@ -763,197 +676,168 @@ ul { list-style: none; margin: 0; padding: 0; } .gx-plot-actual, .gx-plot-term-1 { stroke: #ffa352; } .gx-plot-term-2 { stroke: #ad8cff; } .gx-plot-term-3 { stroke: #58c9a3; } - button.gx-crash-row:active { transform: scale(0.97); } button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: translateY(-2px); } - .gx-underlay { - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - inset: 0; - position: fixed; - z-index: var(--z-drawer); +background: rgba(0, 0, 0, 0.6); +backdrop-filter: blur(4px); +-webkit-backdrop-filter: blur(4px); +inset: 0; +position: fixed; +z-index: var(--z-drawer); } - .gx-drawer { - background: var(--surface-container-high); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-right: 1px solid var(--glass-border); - box-shadow: var(--elev-3); - height: 100dvh; - left: 0; - max-width: 320px; - overflow-y: auto; - padding: env(safe-area-inset-top) var(--sp-3) var(--sp-5); - position: fixed; - top: 0; - transform: translateX(-100%); - transition: transform var(--motion-slow); - width: 84vw; - z-index: calc(var(--z-drawer) + 1); - display: flex; - flex-direction: column; +background: var(--surface-container-high); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border-right: 1px solid var(--glass-border); +box-shadow: var(--elev-3); +height: 100dvh; +left: 0; +max-width: 320px; +overflow-y: auto; +padding: env(safe-area-inset-top) var(--sp-3) var(--sp-5); +position: fixed; +top: 0; +transform: translateX(-100%); +transition: transform var(--motion-slow); +width: 84vw; +z-index: calc(var(--z-drawer) + 1); +display: flex; +flex-direction: column; } - [data-theme="light"] .gx-drawer { - border-right: 1.5px solid var(--primary); +border-right: 1.5px solid var(--primary); } - .gx-drawer.open { transform: translateX(0); } - .gx-drawer.open ~ .blur-nav { - opacity: 0; - pointer-events: none; - transform: translateX(-50%) translateY(12px) translateZ(0); +opacity: 0; +pointer-events: none; +transform: translateX(-50%) translateY(12px) translateZ(0); } - .gx-drawer__header { - align-items: center; - display: flex; - gap: var(--sp-3); - margin-bottom: var(--sp-3); - padding: var(--sp-3) var(--sp-2); +align-items: center; +display: flex; +gap: var(--sp-3); +margin-bottom: var(--sp-3); +padding: var(--sp-3) var(--sp-2); } - .gx-drawer__header img { height: 44px; width: 44px; } - .gx-drawer__header .gx-drawer-title { - font-size: var(--fs-lg); - font-weight: var(--fw-bold); - letter-spacing: -0.02em; +font-size: var(--fs-lg); +font-weight: var(--fw-bold); +letter-spacing: -0.02em; } - .gx-drawer__pin { - color: var(--text-muted); - margin-left: auto; +color: var(--text-muted); +margin-left: auto; } - .gx-drawer__pin:hover, .gx-drawer__pin[aria-pressed="true"] { - color: var(--primary); +color: var(--primary); } - @media (min-width: 768px) { - .gx-app.gx-nav-pinned { padding-left: 320px; } +.gx-app.gx-nav-pinned { padding-left: 320px; } } - .gx-nav-section { margin-bottom: var(--sp-3); } - .gx-nav-section__title { - color: var(--text-muted); - font-size: var(--fs-xs); - font-weight: var(--fw-bold); - letter-spacing: 0.08em; - padding: var(--sp-2); - text-transform: uppercase; +color: var(--text-muted); +font-size: var(--fs-xs); +font-weight: var(--fw-bold); +letter-spacing: 0.08em; +padding: var(--sp-2); +text-transform: uppercase; } - .gx-nav-item { - align-items: center; - border-radius: var(--radius-full); - color: var(--on-surface); - cursor: pointer; - display: flex; - font-weight: var(--fw-medium); - gap: var(--sp-3); - min-height: 46px; - padding: 0 var(--sp-4); - position: relative; - transition: background-color var(--motion-fast); +align-items: center; +border-radius: var(--radius-full); +color: var(--on-surface); +cursor: pointer; +display: flex; +font-weight: var(--fw-medium); +gap: var(--sp-3); +min-height: 46px; +padding: 0 var(--sp-4); +position: relative; +transition: background-color var(--motion-fast); } - .gx-nav-item:hover { background: rgba(255, 255, 255, 0.08); } - .gx-nav-item.active { - background: var(--glass-active-bg); - color: var(--primary); - font-weight: var(--fw-bold); +background: var(--glass-active-bg); +color: var(--primary); +font-weight: var(--fw-bold); } - [data-theme="light"] .gx-nav-item.active { - border: 1px solid rgba(120, 73, 232, 0.35); +border: 1px solid rgba(120, 73, 232, 0.35); } - .gx-nav-item i { font-size: 1.3rem; width: 24px; } - @container galaxy-header (max-width: 743px) { - .gx-appbar__pill { padding-inline: 12px; gap: var(--sp-2); } - /* A real, filling search field instead of a lone icon; the status sits after it. */ - .gx-searchbox { flex: 1 1 auto; justify-content: flex-start; min-width: 0; } - .gx-appbar__right { margin-left: 0; } - .gx-searchbox.open { flex: 1; justify-content: stretch; } - .gx-searchbox.open .gx-searchwrap { flex: 1; } - /* While searching, the field takes over the whole floating bar. */ - .gx-appbar:has(.gx-searchbox.open) .gx-appbar__home, - .gx-appbar:has(.gx-searchbox.open) .gx-appbar__right, - .gx-appbar:has(.gx-searchbox.open) .gx-back-btn, - .gx-appbar:has(.gx-searchbox.open) .gx-theme-toggle, - .gx-appbar:has(.gx-searchbox.open) .gx-appbar__menu { display: none; } - .gx-back-btn { display: inline-flex; } - .gx-appbar__search { flex: 1; min-width: 0; } - .gx-status-pill { display: none; } - .gx-status-pill.gx-device-state { display: inline-flex; flex: none; width: 24px; height: 32px; padding: 0; justify-content: center; border: 0; background: none; } - .gx-device-state__label { display: none; } +.gx-appbar__pill { padding-inline: 12px; gap: var(--sp-2); } +/* A real, filling search field instead of a lone icon; the status sits after it. */ +.gx-searchbox { flex: 1 1 auto; justify-content: flex-start; min-width: 0; } +.gx-appbar__right { margin-left: 0; } +.gx-searchbox.open { flex: 1; justify-content: stretch; } +.gx-searchbox.open .gx-searchwrap { flex: 1; } +/* While searching, the field takes over the whole floating bar. */ +.gx-appbar:has(.gx-searchbox.open) .gx-appbar__home, +.gx-appbar:has(.gx-searchbox.open) .gx-appbar__right, +.gx-appbar:has(.gx-searchbox.open) .gx-back-btn, +.gx-appbar:has(.gx-searchbox.open) .gx-theme-toggle, +.gx-appbar:has(.gx-searchbox.open) .gx-appbar__menu { display: none; } +.gx-back-btn { display: inline-flex; } +.gx-appbar__search { flex: 1; min-width: 0; } +.gx-status-pill { display: none; } +.gx-status-pill.gx-device-state { display: inline-flex; flex: none; width: 24px; height: 32px; padding: 0; justify-content: center; border: 0; background: none; } +.gx-device-state__label { display: none; } } - @container galaxy-header (max-width: 743px) { - .gx-searchwrap { display: none; } - .gx-searchbox { container: galaxy-search / inline-size; } - .gx-search-toggle { - display: inline-flex; flex: 1 1 auto; align-items: center; justify-content: flex-start; gap: var(--sp-2); - width: auto; min-width: 0; height: 40px; padding: 0 var(--sp-3); - background: var(--surface-container); border: 1px solid var(--glass-border); - border-radius: var(--radius-full); color: var(--text-muted); - } - .gx-search-toggle i { flex: none; font-size: 1rem; } - .gx-search-toggle__label { display: inline; font-size: var(--fs-sm); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .gx-searchbox.open .gx-searchwrap { display: flex; } - .gx-searchbox.open .gx-search-toggle { flex: none; order: 2; width: var(--touch-target); padding: 0; background: none; border: 0; color: inherit; } +.gx-searchwrap { display: none; } +.gx-searchbox { container: galaxy-search / inline-size; } +.gx-search-toggle { +display: inline-flex; flex: 1 1 auto; align-items: center; justify-content: flex-start; gap: var(--sp-2); +width: auto; min-width: 0; height: 40px; padding: 0 var(--sp-3); +background: var(--surface-container); border: 1px solid var(--glass-border); +border-radius: var(--radius-full); color: var(--text-muted); +} +.gx-search-toggle i { flex: none; font-size: 1rem; } +.gx-search-toggle__label { display: inline; font-size: var(--fs-sm); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gx-searchbox.open .gx-searchwrap { display: flex; } +.gx-searchbox.open .gx-search-toggle { flex: none; order: 2; width: var(--touch-target); padding: 0; background: none; border: 0; color: inherit; } } - @container galaxy-search (max-width: 100px) { - .gx-searchbox:not(.open) .gx-search-toggle { flex: none; width: var(--touch-target); height: var(--touch-target); padding: 0; justify-content: center; } - .gx-search-toggle__label { display: none; } +.gx-searchbox:not(.open) .gx-search-toggle { flex: none; width: var(--touch-target); height: var(--touch-target); padding: 0; justify-content: center; } +.gx-search-toggle__label { display: none; } } - @container galaxy-header (max-width: 380px) { - .gx-appbar__title { display: none; } - /* Without the label, keep the logo centered in its clickable circle. */ - .gx-appbar__home { padding: 2px 8px; margin-right: 0; } +.gx-appbar__title { display: none; } +/* Without the label, keep the logo centered in its clickable circle. */ +.gx-appbar__home { padding: 2px 8px; margin-right: 0; } } - @media (max-width: 767px) { - .blur-nav { display: grid; } - /* The bottom nav replaces the drawer, so never show both entry points. */ - .gx-appbar .gx-appbar__menu { display: none; } - .gx-drawer { padding-bottom: calc(var(--sp-5) + env(safe-area-inset-bottom, 0px)); } - .gx-content { padding-left: var(--sp-3); padding-right: var(--sp-3); } +.blur-nav { display: grid; } +/* The bottom nav replaces the drawer, so never show both entry points. */ +.gx-appbar .gx-appbar__menu { display: none; } +.gx-drawer { padding-bottom: calc(var(--sp-5) + env(safe-area-inset-bottom, 0px)); } +.gx-content { padding-left: var(--sp-3); padding-right: var(--sp-3); } } - @media (min-width: 768px) { - .blur-nav { display: none; } - .gx-back-btn { display: none; } - .gx-appbar .gx-appbar__menu { display: inline-flex; } - .gx-content { padding-bottom: var(--sp-6); } +.blur-nav { display: none; } +.gx-back-btn { display: none; } +.gx-appbar .gx-appbar__menu { display: inline-flex; } +.gx-content { padding-bottom: var(--sp-6); } } - .gx-appbar__home { border: 1px solid transparent; background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; } [data-theme="light"] .gx-appbar__home { background: rgba(120, 73, 232, 0.08); } .gx-appbar__home:hover { border-color: var(--glass-border); background: var(--glass-bg-hover); } .gx-search:disabled { cursor: not-allowed; opacity: .58; } .gx-nav-item { width: 100%; border: 0; background: transparent; text-align: left; font: inherit; } - .gx-message { padding: var(--sp-6) var(--panel-padding); width: 100%; margin-inline: auto; text-align: center; display: grid; justify-items: center; gap: var(--sp-3); } .gx-message h3, .gx-message p { margin: 0; } .gx-message__body { color: var(--on-surface-variant); line-height: 1.5; min-width: 0; } .gx-message__actions { justify-content: center; } .gx-auth { max-width: 460px; margin: var(--sp-6) auto; padding: var(--sp-6); } - .gx-auth form { display: grid; gap: var(--sp-3); } .gx-auth form .gx-btn { justify-self: start; } - /* Focused System Monitor adaptation from frozen Galaxy material.css:2424-2443. */ .gx-view, .gx-monitor { min-width: 0; max-width: 100%; } /* Every tool page shares the Colors & Layout rhythm: one grid, one gap. */ @@ -961,7 +845,6 @@ button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: transl .gx-view > *, .gx-driving > *, .gx-long-curves > *, .gx-vehicle > * { margin-block: 0; } .gx-view h2, .gx-monitor h2 { margin-top: 0; } .gx-note { color: var(--text-muted); font-size: var(--fs-sm); } - /* Shared banners: message first, optional actions on their own row. */ .gx-alert { --notice-accent: var(--primary); align-items: center; backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); border: 1px solid var(--glass-border); border-left: 4px solid var(--notice-accent); border-radius: var(--radius-md); display: flex; gap: var(--sp-3); width: 100%; margin: 0 0 var(--sp-4); padding: var(--sp-4); background: color-mix(in srgb, var(--notice-accent) 12%, transparent); } .gx-alert--warn { --notice-accent: var(--warning); } @@ -980,24 +863,23 @@ button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: transl .gx-alert__actions .gx-btn { max-width: 100%; white-space: normal; overflow-wrap: anywhere; padding-block: var(--sp-2); box-shadow: none; } .gx-alert .gx-spinner { border-top-color: var(--notice-accent); } .gx-settings > .gx-card, .gx-settings > .gx-alert, .gx-stack > .gx-card, .gx-stack > .gx-alert { margin-bottom: 0; } - /* Shared submenu cube so Tools, Logs and Plots render identically. */ .gx-menu-tile { - align-items: center; - background: var(--surface-container); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-border); - border-radius: var(--radius-lg); - color: var(--on-surface); - cursor: pointer; - display: flex; - flex-direction: column; - gap: var(--sp-2); - min-height: 118px; - padding: var(--sp-4); - text-align: center; - transition: transform var(--motion-fast), background-color var(--motion-fast); +align-items: center; +background: var(--surface-container); +backdrop-filter: var(--glass-blur); +-webkit-backdrop-filter: var(--glass-blur); +border: 1px solid var(--glass-border); +border-radius: var(--radius-lg); +color: var(--on-surface); +cursor: pointer; +display: flex; +flex-direction: column; +gap: var(--sp-2); +min-height: 118px; +padding: var(--sp-4); +text-align: center; +transition: transform var(--motion-fast), background-color var(--motion-fast); } [data-theme="light"] .gx-menu-tile { border: 1px solid rgba(120, 73, 232, 0.25); } .gx-menu-tile i { color: var(--primary); font-size: 1.75rem; } @@ -1011,34 +893,30 @@ button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: transl .gx-note--danger { color: var(--error); } .gx-empty { padding: var(--sp-4); color: var(--text-muted); } .gx-chip { - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--primary-container); - color: var(--on-primary-container); - border-radius: var(--radius-full); - font-size: var(--fs-xs); - font-weight: var(--fw-bold); - padding: 4px 9px; - line-height: 1.2; - vertical-align: middle; +display: inline-flex; +align-items: center; +justify-content: center; +background: var(--primary-container); +color: var(--on-primary-container); +border-radius: var(--radius-full); +font-size: var(--fs-xs); +font-weight: var(--fw-bold); +padding: 4px 9px; +line-height: 1.2; +vertical-align: middle; } - button.gx-chip { - -webkit-appearance: none; - appearance: none; - background: var(--surface-container-high); - border: 1px solid var(--glass-border); - color: var(--on-surface-variant); - cursor: pointer; - font-family: var(--font-body); +-webkit-appearance: none; +appearance: none; +background: var(--surface-container-high); +border: 1px solid var(--glass-border); +color: var(--on-surface-variant); +cursor: pointer; +font-family: var(--font-body); } - button.gx-chip:hover { - background: var(--glass-bg-hover); +background: var(--glass-bg-hover); } - - .gx-field { min-width: 0; max-width: 100%; min-height: var(--touch-target); padding: 9px 12px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--surface-container-high); color: var(--on-surface); font: inherit; font-size: max(16px, 1em); } .gx-btn--tonal:hover:not(:disabled) { background: var(--glass-active-bg); } .gx-btn--tonal { background: var(--primary-container); color: var(--on-primary-container); box-shadow: none; } @@ -1063,9 +941,8 @@ button.gx-chip:hover { .gx-starport-send__row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); } .gx-starport-send__row .gx-field { flex: 1 1 220px; min-width: 0; } .gx-starport-send__count { font-size: var(--fs-xs); opacity: .7; font-variant-numeric: tabular-nums; } - @media (max-width: 700px) { - .gx-starport-log { gap: var(--sp-3); padding: var(--sp-3); } +.gx-starport-log { gap: var(--sp-3); padding: var(--sp-3); } } .gx-crash-preview pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 60vh; overflow-y: auto; font-size: var(--fs-sm); } .gx-panel.gx-stack > h3, .gx-panel.gx-stack > p, .gx-software-card h3, .gx-software-card > p { margin: 0; } @@ -1096,7 +973,6 @@ button.gx-chip:hover { .gx-monitor tbody tr:hover { background: var(--surface); } .gx-monitor__feature { color: var(--primary); font-weight: 500; } @media(max-width:600px) { .gx-monitor__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .gx-monitor__metric { min-width: 0; padding: 12px; } .gx-monitor__metric strong { font-size: 22px; } } - /* Dashboard */ .gx-home { width: 100%; margin: 0 auto; } .gx-home__hero { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); } @@ -1146,9 +1022,9 @@ button.gx-chip:hover { .gx-home__week h3 { margin: var(--sp-4) 0 var(--sp-2); } .gx-home__donut, .gx-home__model-donut { width: 112px; height: 112px; flex: 0 0 auto; border-radius: 50%; display: grid; place-content: center; text-align: center; position: relative; } .gx-home__donut::before { content: ""; position: absolute; inset: 0; border-radius: 50%; - background: conic-gradient(var(--primary) var(--gx-home-progress, 0%), rgba(157, 114, 255, .18) 0); - -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px)); - mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px)); } +background: conic-gradient(var(--primary) var(--gx-home-progress, 0%), rgba(157, 114, 255, .18) 0); +-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px)); +mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px)); } .gx-home__donut strong, .gx-home__donut span { position: relative; z-index: 1; } .gx-home__bars { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); align-items: end; gap: var(--sp-2); } .gx-home__bars > div { min-width: 0; display: grid; justify-items: center; gap: var(--sp-1); } @@ -1171,7 +1047,7 @@ button.gx-chip:hover { .gx-home__drive .gx-home__link:disabled { opacity: .5; cursor: wait; } .gx-home__model-layout { display: flex; align-items: center; gap: var(--sp-5); margin-top: var(--sp-4); } .gx-home__model-donut { -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(100% - 21px)); - mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(100% - 21px)); } +mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(100% - 21px)); } .gx-home__model-layout > div:last-child { display: grid; gap: var(--sp-3); } .gx-home__model-row { display: flex; align-items: start; gap: var(--sp-2); } .gx-home__model-row > div { display: grid; gap: var(--sp-1); } @@ -1210,7 +1086,6 @@ button.gx-chip:hover { .gx-vehicle__models button[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); } @media (max-width: 700px) { .gx-long-curves__points { grid-template-columns: 1fr; } } @media (max-width: 520px) { .gx-vehicle__summary { grid-template-columns: 1fr; } } - /* Navigation shares the same route and preferences as the onroad display. */ .gx-navigation__search{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap} .gx-navigation__section{padding:24px;margin:0 0 20px} @@ -1225,11 +1100,8 @@ button.gx-chip:hover { .gx-navigation__route{display:block;width:100%;height:220px;border-radius:16px;background:rgba(161,112,244,.06);margin:16px 0} .gx-navigation--stale .gx-navigation__route{opacity:.45} @media(max-width:540px){.gx-navigation__places>li{align-items:flex-start;flex-direction:column}.gx-navigation__section{padding:18px}.gx-navigation__section > .gx-section__header{margin:-18px -18px var(--sp-4)}} - .gx-navigation__route-actions{padding-bottom:calc(var(--sp-4) + 56px)} - .gx-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} - .gx-release-notes{white-space:pre-wrap;font:inherit;line-height:1.6;overflow-wrap:anywhere} .gx-software-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: var(--sp-3); margin: 0; } .gx-software-summary dt { font-size: var(--fs-sm); color: var(--on-surface-variant); } @@ -1239,12 +1111,12 @@ button.gx-chip:hover { .gx-software-card details{padding:12px 0}.gx-software-card summary{cursor:pointer;font-weight:600} .gx-version-action--pending { animation: gx-version-heartbeat 1.8s ease-in-out infinite; } @keyframes gx-version-heartbeat { - 0%, 45%, 100% { box-shadow: 0 0 0 0 transparent; } - 12%, 32% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), 0 0 20px color-mix(in srgb, var(--primary) 55%, transparent); } - 22% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 15%, transparent), 0 0 10px color-mix(in srgb, var(--primary) 30%, transparent); } +0%, 45%, 100% { box-shadow: 0 0 0 0 transparent; } +12%, 32% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), 0 0 20px color-mix(in srgb, var(--primary) 55%, transparent); } +22% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 15%, transparent), 0 0 10px color-mix(in srgb, var(--primary) 30%, transparent); } } @media (prefers-reduced-motion: reduce) { - .gx-version-action--pending { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 35%, transparent); } +.gx-version-action--pending { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 35%, transparent); } } .gx-restart-cron { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)) minmax(180px, 2fr); gap: var(--sp-3); } @media (max-width: 800px) { .gx-restart-cron { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -1262,17 +1134,14 @@ button.gx-chip:hover { .gx-version-badge { padding: 2px 8px; border-radius: 999px; font-size: var(--fs-sm); color: var(--on-surface-variant); background: var(--surface-container-high); } .gx-version-badge--installed, .gx-version-badge--downloaded { color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); } .gx-version-gap { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-2); } - .gx-navigation-map { position:relative; border-radius:12px; overflow:hidden; background:#20303b; margin-bottom:16px; } .gx-navigation-map canvas { display:block; width:100%; height:360px; touch-action:none; cursor:grab; } .gx-navigation-map__controls { position:absolute; top:12px; right:12px; display:flex; gap:6px; } .gx-navigation-map__credits { position:absolute; bottom:0; right:0; background:#fff; color:#222; padding:3px 7px; font-size:11px; } .gx-navigation-map__credits a { color:#2563eb; } .gx-navigation-map > p { padding:8px; } - .gx-navigation-map__logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px} .gx-navigation-map__logo { position:absolute; bottom:20px; left:8px; } - /* Original record motifs, adapted to Galaxy's purple accent without image assets. */ .gx-home__record .gx-home__record-art { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); } .gx-home__record-art svg { width: 25px; height: 25px; } @@ -1281,14 +1150,12 @@ button.gx-chip:hover { .gx-force-drive__actions { margin-top: var(--sp-3); } .gx-force-drive__actions .gx-btn[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); box-shadow: 0 0 0 2px var(--primary) inset; } @media (max-width: 600px) { - .gx-home__record { grid-template-columns: 36px minmax(0, 1fr); } - .gx-home__record .gx-home__record-art { width: 36px; height: 36px; grid-row: span 2; } - .gx-home__record strong { grid-column: 2; text-align: left; } +.gx-home__record { grid-template-columns: 36px minmax(0, 1fr); } +.gx-home__record .gx-home__record-art { width: 36px; height: 36px; grid-row: span 2; } +.gx-home__record strong { grid-column: 2; text-align: left; } } - .gx-sentry-channel__fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; } .gx-sentry-channel__fields .gx-field { width: 100%; min-width: 0; box-sizing: border-box; } - /* Recording library follows the shared section/row control hierarchy. */ .gx-recordings__library, .gx-recordings__list, .gx-recordings__delete { overflow: hidden; } .gx-recordings__heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); } @@ -1355,50 +1222,48 @@ button.gx-chip:hover { .gx-route-workspace .gx-replay__player:fullscreen .gx-replay__screen { flex: 1; min-height: 0; display: flex; align-items: center; } .gx-route-workspace .gx-replay__player:fullscreen .gx-replay__video { height: 100%; max-height: 100%; } @container (min-width: 1100px) { - .gx-route-workspace__body { grid-template-columns: minmax(0, 1fr) 17rem; } - .gx-route-workspace__files .gx-route-workspace__route-files .gx-actions { align-items: stretch; flex-direction: column; } +.gx-route-workspace__body { grid-template-columns: minmax(0, 1fr) 17rem; } +.gx-route-workspace__files .gx-route-workspace__route-files .gx-actions { align-items: stretch; flex-direction: column; } } @container (max-width: 700px) { - .gx-route-workspace__toolbar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 6.5rem); } - .gx-route-workspace__modes { grid-column: 1 / -1; } - .gx-route-workspace__modes > button { flex: 1; } - .gx-route-workspace__segment { grid-column: 1; grid-row: 2; } - .gx-route-workspace__camera-select { grid-column: 2; grid-row: 2; width: 100%; } - .gx-route-workspace__toolbar:not(:has(.gx-route-workspace__camera-select)) .gx-route-workspace__segment { grid-column: 1 / -1; } +.gx-route-workspace__toolbar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 6.5rem); } +.gx-route-workspace__modes { grid-column: 1 / -1; } +.gx-route-workspace__modes > button { flex: 1; } +.gx-route-workspace__segment { grid-column: 1; grid-row: 2; } +.gx-route-workspace__camera-select { grid-column: 2; grid-row: 2; width: 100%; } +.gx-route-workspace__toolbar:not(:has(.gx-route-workspace__camera-select)) .gx-route-workspace__segment { grid-column: 1 / -1; } } @container (max-width: 340px) { - .gx-route-workspace__toolbar .gx-route-workspace__segment { grid-column: 1 / -1; } - .gx-route-workspace__camera-select { grid-column: 1 / -1; grid-row: 3; } +.gx-route-workspace__toolbar .gx-route-workspace__segment { grid-column: 1 / -1; } +.gx-route-workspace__camera-select { grid-column: 1 / -1; grid-row: 3; } } @media (max-width: 700px) { - .gx-recordings__filters { grid-template-columns: minmax(0, 1fr) auto; } - .gx-recordings__filters > input { grid-column: 1 / -1; } - .gx-recordings__filters .gx-btn { padding-inline: var(--sp-3); } +.gx-recordings__filters { grid-template-columns: minmax(0, 1fr) auto; } +.gx-recordings__filters > input { grid-column: 1 / -1; } +.gx-recordings__filters .gx-btn { padding-inline: var(--sp-3); } } @media (max-width: 560px) { - .gx-recordings__row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--sp-2); } - .gx-recordings__row > .gx-recordings__drive { grid-column: 1; } - .gx-recordings__row > .gx-note { grid-column: 1; grid-row: 2; margin: 0; } - .gx-recordings__row > .gx-recordings__actions { grid-column: 2; grid-row: 1 / 3; } - .gx-recordings__row > .gx-route-workspace__shortcuts { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; } - .gx-route-workspace__shortcuts .gx-btn { min-width: 0; white-space: nowrap; padding-inline: var(--sp-2); } - .gx-route-workspace__header { align-items: center; } - .gx-route-workspace__identity { display: contents; } - .gx-route-workspace__identity h2 { grid-column: 2; grid-row: 1; } - .gx-route-workspace__identity p { grid-column: 2 / -1; grid-row: 2; } - .gx-route-workspace__back { grid-column: 1; grid-row: 1 / 3; } - .gx-route-workspace__header h2 { font-size: 1.125rem; } - .gx-route-workspace__header > .gx-recordings__actions { grid-column: 3; grid-row: 1; justify-content: flex-end; max-width: 6rem; } - .gx-route-workspace__download-row { align-items: stretch; flex-direction: column; } - .gx-route-workspace__download-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } - .gx-route-workspace__download-actions .gx-btn { padding-inline: var(--sp-2); } +.gx-recordings__row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--sp-2); } +.gx-recordings__row > .gx-recordings__drive { grid-column: 1; } +.gx-recordings__row > .gx-note { grid-column: 1; grid-row: 2; margin: 0; } +.gx-recordings__row > .gx-recordings__actions { grid-column: 2; grid-row: 1 / 3; } +.gx-recordings__row > .gx-route-workspace__shortcuts { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; } +.gx-route-workspace__shortcuts .gx-btn { min-width: 0; white-space: nowrap; padding-inline: var(--sp-2); } +.gx-route-workspace__header { align-items: center; } +.gx-route-workspace__identity { display: contents; } +.gx-route-workspace__identity h2 { grid-column: 2; grid-row: 1; } +.gx-route-workspace__identity p { grid-column: 2 / -1; grid-row: 2; } +.gx-route-workspace__back { grid-column: 1; grid-row: 1 / 3; } +.gx-route-workspace__header h2 { font-size: 1.125rem; } +.gx-route-workspace__header > .gx-recordings__actions { grid-column: 3; grid-row: 1; justify-content: flex-end; max-width: 6rem; } +.gx-route-workspace__download-row { align-items: stretch; flex-direction: column; } +.gx-route-workspace__download-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.gx-route-workspace__download-actions .gx-btn { padding-inline: var(--sp-2); } } @media (max-width: 380px) { - .gx-recordings__filters { grid-template-columns: minmax(0, 1fr); } +.gx-recordings__filters { grid-template-columns: minmax(0, 1fr); } } - .gx-recordings .gx-section__header { cursor: default; } - .gx-field[type="file"] { max-width: 100%; min-width: 0; font: inherit; } .gx-field[type="file"]::file-selector-button { border: 0; border-radius: var(--radius-full); background: var(--primary-container); color: var(--on-primary-container); font: inherit; font-weight: var(--fw-bold); padding: .65rem 1rem; margin-right: var(--sp-3); cursor: pointer; } .gx-field[type="file"]::file-selector-button:hover { background: var(--primary); color: var(--on-primary); } @@ -1407,7 +1272,6 @@ input[type="checkbox"]:not(.gx-switch input):checked { background: var(--primary input[type="checkbox"]:not(.gx-switch input):checked::after { content: ""; display: block; width: .3rem; height: .6rem; border: solid var(--on-primary); border-width: 0 2px 2px 0; transform: translate(.3rem, .08rem) rotate(45deg); } input[type="checkbox"]:not(.gx-switch input):focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; } input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } - /* Shared action groups wrap while keeping their controls within the page. */ .gx-btn { white-space: normal; max-width: 100%; overflow-wrap: anywhere; } .gx-actions, .gx-tabs { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; min-width: 0; max-width: 100%; } @@ -1428,7 +1292,6 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-home__last .gx-home__date { font-size: var(--fs-sm); color: var(--text-muted); margin-block: var(--sp-2) var(--sp-4); } .gx-home__model-tag { min-width: 0; overflow-wrap: anywhere; } .gx-camera-status, .gx-tool-footer { font-size: var(--fs-xs); color: var(--text-muted); } - .gx-boot { position: fixed; inset: 0; z-index: 1000; isolation: isolate; overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--panel-padding); text-align: center; background: url("../assets/restart-background.webp") center 42% / cover no-repeat #0c0a14; color: #f5edff; transition: opacity 460ms cubic-bezier(0.16, 1, 0.3, 1); } .gx-boot::before { content: ""; position: absolute; inset: 0; z-index: -1; background: radial-gradient(ellipse at center, #38205499 0%, #151020c7 65%, #0c0a14f2 100%); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .gx-boot--arriving { animation: gx-boot-arrive 460ms cubic-bezier(0.16, 1, 0.3, 1) both; } @@ -1444,10 +1307,8 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-boot [hidden] { display: none; } @media (prefers-reduced-motion: reduce) { .gx-boot__orbit, .gx-boot--arriving { animation: none; } } @keyframes gx-spin { to { transform: rotate(360deg); } } - .gx-device-state__icon { color: var(--secondary); flex: none; } .gx-device-state__icon.is-stale { color: var(--text-muted); } - .gx-route-stage { display: grid; min-width: 0; overflow-x: clip; } .gx-route { grid-area: 1 / 1; align-self: start; width: 100%; min-width: 0; } .gx-route > *, .gx-settings__body > * { min-width: 0; max-width: 100%; } @@ -1456,7 +1317,6 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-route-leave-active { pointer-events: none; } .gx-route-enter-from { opacity: 0; transform: translateX(calc(var(--route-direction, 1) * 100%)); } .gx-route-leave-to { opacity: 0; transform: translateX(calc(var(--route-direction, 1) * -100%)); } - /* Reusable nested-panel transition: a seamless directional crossfade used inside pages. */ .gx-panel-stage { display: grid; min-width: 0; } .gx-panel-stage > * { grid-area: 1 / 1; align-self: start; width: 100%; min-width: 0; } @@ -1464,15 +1324,12 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-panel-leave-active { pointer-events: none; } .gx-panel-enter-from { opacity: 0; transform: translateX(calc(var(--panel-direction, 1) * 28px)); } .gx-panel-leave-to { opacity: 0; transform: translateX(calc(var(--panel-direction, 1) * -28px)); } - .gx-nav-footer { margin-top: auto; } @media (prefers-reduced-motion: reduce) { .gx-route-enter-active, .gx-route-leave-active, .gx-nav-indicator, .blur-nav .nav-item i { transition: none; } .gx-app { animation: none; } .gx-panel-enter-active, .gx-panel-leave-active { transition: none; } } - .gx-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-2); margin: 0; padding: var(--sp-3) var(--sp-4); } .gx-summary > div { display: flex; flex-direction: column; min-width: 0; text-align: center; } .gx-summary dt { color: var(--text-muted); font-size: var(--fs-xs); line-height: 1.35; overflow-wrap: anywhere; } .gx-summary dd { order: -1; margin: 0 0 var(--sp-1); font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--primary); overflow-wrap: anywhere; } - /* Android Auto setup layout; controls reuse shared Galaxy styles. */ .gx-starport__steps { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-4); } .gx-starport__steps > .gx-card { margin-bottom: 0; } @@ -1482,7 +1339,7 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-starport-step__head p { margin: 0; color: var(--text-muted); line-height: 1.45; } .gx-starport-step__head > .gx-btn { margin-left: auto; flex: none; } .gx-starport-step__num { flex: 0 0 auto; display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--radius-full); - background: var(--primary-container); color: var(--on-primary-container); font-weight: var(--fw-bold); } +background: var(--primary-container); color: var(--on-primary-container); font-weight: var(--fw-bold); } .gx-starport-step--done .gx-starport-step__num { background: var(--success); color: var(--background); } .gx-starport-home { margin-bottom: 0; } .gx-starport-step__num.gx-starport-home__icon--live { background: var(--success); color: var(--background); } @@ -1493,8 +1350,8 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-starport-status { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); } .gx-starport-status__actions { margin-left: auto; } .gx-starport-pill { display: inline-flex; align-items: center; gap: var(--sp-2); width: fit-content; max-width: 100%; margin: 0; padding: 6px 14px; - border-radius: var(--radius-full); font-size: var(--fs-sm); font-weight: var(--fw-medium); overflow-wrap: anywhere; - background: var(--surface-container-high); color: var(--on-surface-variant); border: 1px solid var(--outline); } +border-radius: var(--radius-full); font-size: var(--fs-sm); font-weight: var(--fw-medium); overflow-wrap: anywhere; +background: var(--surface-container-high); color: var(--on-surface-variant); border: 1px solid var(--outline); } .gx-starport-pill::before { content: ""; flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } .gx-starport-pill:has(> i)::before { display: none; } .gx-starport-pill--ready { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, transparent); background: color-mix(in srgb, var(--success) 12%, transparent); } @@ -1527,166 +1384,161 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-starport-wiki ul { list-style: disc; } .gx-starport-mirror-steps { margin: 0; } .gx-card.gx-starport-sheet__dialog { - position: fixed; - inset: max(20px, env(safe-area-inset-top, 0px)) 0 max(20px, env(safe-area-inset-bottom, 0px)); - max-height: calc(100dvh - max(20px, env(safe-area-inset-top, 0px)) - max(20px, env(safe-area-inset-bottom, 0px))); - width: min(calc(100% - 32px), 560px); - border-radius: var(--radius-lg); - box-shadow: var(--elev-3); - overflow: hidden; - grid-template-rows: auto minmax(0, 1fr); - gap: 0; - padding: 0; +position: fixed; +inset: max(20px, env(safe-area-inset-top, 0px)) 0 max(20px, env(safe-area-inset-bottom, 0px)); +max-height: calc(100dvh - max(20px, env(safe-area-inset-top, 0px)) - max(20px, env(safe-area-inset-bottom, 0px))); +width: min(calc(100% - 32px), 560px); +border-radius: var(--radius-lg); +box-shadow: var(--elev-3); +overflow: hidden; +grid-template-rows: auto minmax(0, 1fr); +gap: 0; +padding: 0; } @media (max-width: 767px) { - .gx-card.gx-starport-sheet__dialog { - bottom: var(--bottomnav-clearance); - max-height: calc(100dvh - max(20px, env(safe-area-inset-top, 0px)) - var(--bottomnav-clearance)); - width: min(calc(100% - 24px), 560px); - border-radius: var(--radius-md); - } +.gx-card.gx-starport-sheet__dialog { +bottom: var(--bottomnav-clearance); +max-height: calc(100dvh - max(20px, env(safe-area-inset-top, 0px)) - var(--bottomnav-clearance)); +width: min(calc(100% - 24px), 560px); +border-radius: var(--radius-md); +} } .gx-starport-sheet__toolbar { - display: flex; - align-items: center; - justify-content: flex-end; - padding: var(--sp-3) var(--sp-4) 0; - min-height: 48px; +display: flex; +align-items: center; +justify-content: flex-end; +padding: var(--sp-3) var(--sp-4) 0; +min-height: 48px; } .gx-starport-sheet__body { - display: grid; - gap: var(--sp-4); - padding: 0 var(--sp-5) var(--sp-5); - min-height: 0; - min-width: 0; - overflow-y: auto; - overscroll-behavior: contain; +display: grid; +gap: var(--sp-4); +padding: 0 var(--sp-5) var(--sp-5); +min-height: 0; +min-width: 0; +overflow-y: auto; +overscroll-behavior: contain; } @media (max-width: 500px) { - .gx-starport-sheet__body { padding: 0 var(--sp-3) var(--sp-4); gap: var(--sp-3); } - .gx-starport-sheet__toolbar { padding: var(--sp-2) var(--sp-3) 0; } +.gx-starport-sheet__body { padding: 0 var(--sp-3) var(--sp-4); gap: var(--sp-3); } +.gx-starport-sheet__toolbar { padding: var(--sp-2) var(--sp-3) 0; } } .gx-starport-sheet__head { - gap: var(--sp-1); +gap: var(--sp-1); } .gx-starport-sheet__lead { - align-items: center; +align-items: center; } .gx-starport-sheet__dialog h3 { - font-size: var(--fs-xl); - font-weight: var(--fw-bold); - letter-spacing: -0.01em; +font-size: var(--fs-xl); +font-weight: var(--fw-bold); +letter-spacing: -0.01em; } .gx-starport-sheet__dialog .gx-note { - line-height: 1.45; +line-height: 1.45; } - .gx-starport-sheet__steps { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: var(--sp-3); +list-style: none; +margin: 0; +padding: 0; +display: grid; +gap: var(--sp-3); } .gx-starport-step-card { - display: grid; - gap: var(--sp-3); - padding: var(--sp-3) var(--sp-4); - border-radius: var(--radius-md); - background: var(--surface-container); - border: 1px solid var(--outline); - transition: border-color var(--motion-fast); +display: grid; +gap: var(--sp-3); +padding: var(--sp-3) var(--sp-4); +border-radius: var(--radius-md); +background: var(--surface-container); +border: 1px solid var(--outline); +transition: border-color var(--motion-fast); } .gx-starport-step-card:focus-within { - border-color: var(--primary); +border-color: var(--primary); } .gx-starport-step-card__head { - display: flex; - align-items: center; - gap: var(--sp-3); +display: flex; +align-items: center; +gap: var(--sp-3); } .gx-starport-step-card__num { - width: 28px; - height: 28px; - font-size: var(--fs-sm); +width: 28px; +height: 28px; +font-size: var(--fs-sm); } .gx-starport-step-card__title { - display: grid; - gap: 1px; +display: grid; +gap: 1px; } .gx-starport-step-card__title strong { - font-size: var(--fs-sm); - font-weight: var(--fw-bold); - color: var(--on-surface); +font-size: var(--fs-sm); +font-weight: var(--fw-bold); +color: var(--on-surface); } .gx-starport-step-card__title small { - font-size: var(--fs-xs); - color: var(--on-surface-variant); +font-size: var(--fs-xs); +color: var(--on-surface-variant); } .gx-starport-step-card__body { - display: grid; - gap: var(--sp-2); - padding-left: 40px; +display: grid; +gap: var(--sp-2); +padding-left: 40px; } @media (max-width: 500px) { - .gx-starport-step-card__body { padding-left: 0; } - .gx-starport-step-card { padding: var(--sp-3); } +.gx-starport-step-card__body { padding-left: 0; } +.gx-starport-step-card { padding: var(--sp-3); } } .gx-starport-sheet__step--muted { opacity: .7; } - .gx-starport-file { position: relative; } .gx-starport-file input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } .gx-starport-file:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; } .gx-starport-file--disabled { opacity: .4; pointer-events: none; } .gx-starport-file-chip { - max-width: 100%; - overflow-wrap: anywhere; +max-width: 100%; +overflow-wrap: anywhere; } .gx-starport-file-chip small { - color: var(--text-muted); +color: var(--text-muted); } - .gx-starport-checks { display: grid; gap: var(--sp-2); width: 100%; } .gx-starport-checks li { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--fs-sm); } .gx-starport-checks li::before { - flex: 0 0 auto; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-size: 12px; font-weight: var(--fw-bold); - content: ""; border: 2px solid var(--outline); box-sizing: border-box; +flex: 0 0 auto; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-size: 12px; font-weight: var(--fw-bold); +content: ""; border: 2px solid var(--outline); box-sizing: border-box; } .gx-starport-checks .gx-starport-check--done, .gx-starport-checks .gx-starport-check--active { color: var(--on-surface); } .gx-starport-checks .gx-starport-check--done::before { content: "✓"; border: 0; background: var(--success); color: var(--background); } .gx-starport-checks .gx-starport-check--active::before { border-top-color: var(--primary); animation: gx-spin .9s linear infinite; } .gx-starport-checks .gx-starport-check--failed { color: var(--error); } .gx-starport-checks .gx-starport-check--failed::before { content: "✕"; border: 0; background: var(--error); color: var(--on-error); } - .gx-starport-sheet__done { - padding: var(--sp-4) var(--sp-2); +padding: var(--sp-4) var(--sp-2); } .gx-starport-done-badge { - width: 64px; - height: 64px; - border-radius: 50%; - background: var(--primary-container); - color: var(--primary); - display: grid; - place-items: center; - font-size: 2.2rem; - box-shadow: 0 0 24px var(--primary-glow); +width: 64px; +height: 64px; +border-radius: 50%; +background: var(--primary-container); +color: var(--primary); +display: grid; +place-items: center; +font-size: 2.2rem; +box-shadow: 0 0 24px var(--primary-glow); } - .gx-starport-sheet__footer { - gap: var(--sp-1); - margin-top: var(--sp-1); - padding-top: var(--sp-2); - border-top: 1px solid var(--outline-variant); +gap: var(--sp-1); +margin-top: var(--sp-1); +padding-top: var(--sp-2); +border-top: 1px solid var(--outline-variant); } .gx-starport-trademark { display: block; font-size: var(--fs-xs); opacity: .75; line-height: 1.35; } @media (prefers-reduced-motion: reduce) { .gx-spinner, .gx-starport-checks .gx-starport-check--active::before { animation: none; } } @media (max-width: 600px) { - .gx-starport-step { padding: var(--sp-4); } - .gx-starport-step__body { padding-left: 0; } - .gx-starport-status__actions { margin-left: 0; width: 100%; } - .gx-starport-status__actions .gx-btn { flex: 1 1 auto; } - .gx-starport-row { flex-wrap: wrap; } +.gx-starport-step { padding: var(--sp-4); } +.gx-starport-step__body { padding-left: 0; } +.gx-starport-status__actions { margin-left: 0; width: 100%; } +.gx-starport-status__actions .gx-btn { flex: 1 1 auto; } +.gx-starport-row { flex-wrap: wrap; } } .gx-update-progress { margin: var(--sp-4) 0; } .gx-update-progress__heading { display: flex; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2); } @@ -1695,7 +1547,6 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-update-progress__fill--indeterminate { width: 35%; animation: gx-update-progress 1.6s ease-in-out infinite; } @keyframes gx-update-progress { from { transform: translateX(-100%); } to { transform: translateX(385%); } } @media (prefers-reduced-motion: reduce) { .gx-update-progress__fill--indeterminate { width: 100%; animation: none; opacity: .6; } } - /* Composition utilities: shared page chrome and controls need no inline styles. */ .gx-panel { padding: var(--panel-padding); min-width: 0; } .gx-card.gx-panel > .gx-section__header { margin: calc(-1 * var(--panel-padding)) calc(-1 * var(--panel-padding)) var(--sp-4); } @@ -1724,8 +1575,7 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-device-picker__row { display: flex; align-items: center; gap: var(--sp-1); } .gx-device-picker__form { padding: var(--sp-2); display: grid; gap: var(--sp-2); } .gx-tunnel__qr { display: block; width: min(250px, 100%); background: white; padding: var(--sp-2); border-radius: var(--radius-sm); } - -.gx-plots__hint { min-height: 2.8em; } +.gx-plots__hint { height: 2.8em; overflow: auto; } .gx-tabs > button { min-height: var(--touch-target); padding: var(--sp-2) var(--sp-4); } .gx-tabs > button[aria-pressed="true"], .gx-tabs > button[aria-selected="true"], .gx-chip--selected { background: var(--primary); color: var(--on-primary); } .gx-field:disabled, .gx-icon-btn:disabled { opacity: .4; cursor: not-allowed; } @@ -1734,7 +1584,6 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-note { line-height: 1.5; } progress { max-width: 100%; accent-color: var(--primary); } @media (max-width: 600px) { :root { --panel-padding: var(--sp-4); } } - .gx-page-header h2, .gx-view > h2, .gx-tunnel > h2, .gx-monitor h2 { font-size: var(--fs-xl); line-height: 1.3; margin: 0 0 var(--sp-2); } .gx-page-header p, .gx-page-description { margin: 0; color: var(--on-surface-variant); font-size: var(--fs-sm); line-height: 1.5; } .gx-details { display: grid; grid-template-columns: minmax(130px, 1fr) minmax(0, 2fr); gap: var(--sp-2) var(--sp-3); margin: 0; } @@ -1752,17 +1601,15 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-bt-device__actions { display: grid; grid-template-columns: repeat(2, 7.5rem); gap: var(--sp-2); } .gx-bt-device__actions > .gx-btn { padding-inline: var(--sp-2); } @media (max-width: 520px) { - .gx-bt-device__actions { grid-template-columns: repeat(2, minmax(0, 1fr)); flex: 1 1 100%; } +.gx-bt-device__actions { grid-template-columns: repeat(2, minmax(0, 1fr)); flex: 1 1 100%; } } .gx-card-grid > .gx-card, .gx-settings__grid > .gx-card, .gx-favorites__slots > .gx-card, .gx-home__grid > .gx-card, .gx-home__pair > .gx-card { margin-bottom: 0; } @media (max-width: 600px) { - .gx-details { grid-template-columns: 1fr; gap: var(--sp-1); } - .gx-details dd { margin-bottom: var(--sp-2); } +.gx-details { grid-template-columns: 1fr; gap: var(--sp-1); } +.gx-details dd { margin-bottom: var(--sp-2); } } - /* Descriptions are ordinary page content; notices communicate changing state. */ .gx-settings-tabs > button[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); } - .gx-replay__player { --replay-accent: var(--primary); position: relative; background: #000; border-radius: 0; overflow: hidden; } .gx-replay__screen { position: relative; } .gx-replay__video { display: block; width: 100%; aspect-ratio: 16 / 9; max-height: 65dvh; background: #000; } @@ -1798,12 +1645,11 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-replay__player:fullscreen { display: flex; flex-direction: column; justify-content: center; border-radius: 0; } .gx-replay__player:fullscreen .gx-replay__video { max-height: 100dvh; } @media (max-width: 600px) { - .gx-replay__track { --replay-rail: 20px; --replay-tick: 4px; } - .gx-replay__media-controls { position: static; padding-inline: 4px; } - .gx-replay__controls { gap: 4px; } - .gx-replay__player:fullscreen .gx-replay__video { min-height: 0; flex: 1; } +.gx-replay__track { --replay-rail: 20px; --replay-tick: 4px; } +.gx-replay__media-controls { position: static; padding-inline: 4px; } +.gx-replay__controls { gap: 4px; } +.gx-replay__player:fullscreen .gx-replay__video { min-height: 0; flex: 1; } } - .gx-spotify-now { display: grid; gap: var(--sp-3); } .gx-spotify > .gx-starport-step > h3 { margin: 0; font-size: var(--fs-lg); } .gx-spotify-now__main { display: flex; gap: var(--sp-3); align-items: center; justify-content: center; min-width: 0; } @@ -1821,20 +1667,19 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-flm__table td:nth-child(2), .gx-flm__table td:nth-child(3), .gx-flm__table td:nth-child(4) { font-variant-numeric: tabular-nums; white-space: nowrap; } .gx-flm__table tbody th { font-weight: 500; min-width: 12rem; } @media (max-width: 700px) { - .gx-flm__table-wrap { overflow: visible; } - .gx-flm__table, .gx-flm__table tbody, .gx-flm__table tr { display: block; } - .gx-flm__table thead { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } - .gx-flm__table tbody tr { padding: var(--sp-3) 0; border-bottom: 1px solid var(--outline-variant); } - .gx-flm__table tbody th { display: block; min-width: 0; padding: 0 0 var(--sp-2); border: 0; font-weight: 600; } - .gx-flm__table td { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-2); padding: var(--sp-1) 0; border: 0; white-space: normal; overflow-wrap: anywhere; } - .gx-flm__table td:nth-child(n) { white-space: normal; } - .gx-flm__table td::before { content: attr(data-label); color: var(--text-muted); } - .gx-monitor table { min-width: 42rem; } +.gx-flm__table-wrap { overflow: visible; } +.gx-flm__table, .gx-flm__table tbody, .gx-flm__table tr { display: block; } +.gx-flm__table thead { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +.gx-flm__table tbody tr { padding: var(--sp-3) 0; border-bottom: 1px solid var(--outline-variant); } +.gx-flm__table tbody th { display: block; min-width: 0; padding: 0 0 var(--sp-2); border: 0; font-weight: 600; } +.gx-flm__table td { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-2); padding: var(--sp-1) 0; border: 0; white-space: normal; overflow-wrap: anywhere; } +.gx-flm__table td:nth-child(n) { white-space: normal; } +.gx-flm__table td::before { content: attr(data-label); color: var(--text-muted); } +.gx-monitor table { min-width: 42rem; } } @media (max-width: 380px) { - .gx-flm__table td { grid-template-columns: minmax(0, 1fr); gap: 2px; } +.gx-flm__table td { grid-template-columns: minmax(0, 1fr); gap: 2px; } } - .gx-starport-progress { display: grid; gap: var(--sp-3); padding: var(--sp-4); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--outline)); border-radius: var(--radius-md); background: linear-gradient(120deg, color-mix(in srgb, var(--primary) 12%, var(--surface-container)), var(--surface-container)); min-width: 0; } .gx-starport-progress__heading { display: flex; align-items: center; gap: var(--sp-3); } .gx-starport-progress__heading > div { display: grid; gap: var(--sp-1); min-width: 0; } @@ -1860,12 +1705,11 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-starport-guide ol { display: grid; gap: var(--sp-3); padding-left: 1.5em; margin-bottom: 0; } .gx-starport-guide li::marker { color: var(--primary); font-weight: 700; } @media (prefers-reduced-motion: reduce) { - .gx-starport-progress__track > span { animation: none; } - .gx-starport-progress__track > span { width: 100%; opacity: .6; } +.gx-starport-progress__track > span { animation: none; } +.gx-starport-progress__track > span { width: 100%; opacity: .6; } } .gx-starport-checks .gx-starport-check--active { padding: var(--sp-3); border-radius: var(--radius-sm); color: var(--primary); background: color-mix(in srgb, var(--primary) 12%, transparent); font-weight: var(--fw-bold); } .gx-starport-checks .gx-starport-check--active::before { border-right-color: var(--secondary, var(--primary)); } - /* StarPilot Tuner shares Galaxy surfaces, spacing and source-aware state colors. */ .gx-tuner { display: grid; gap: var(--sp-5); min-width: 0; padding-bottom: var(--sp-4); } .gx-tuner h2, .gx-tuner h3, .gx-tuner h4, .gx-tuner p { margin: 0; } @@ -1963,45 +1807,43 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-tuner__diagnostics dt { color: var(--text-muted); } .gx-tuner__diagnostics dd { margin: 0; font-variant-numeric: tabular-nums; } @media (max-width: 900px) { - .gx-card.gx-tuner__hero { grid-template-columns: 62px minmax(0, 1fr); gap: 18px; } - .gx-tuner__hero-icon { width: 62px; height: 62px; border-radius: 20px; font-size: 1.7rem; } - .gx-tuner__hero-meta { grid-column: 2; border: 0; padding: 0; max-width: none; display: flex; flex-wrap: wrap; gap: 8px 16px; } - .gx-tuner__overview-grid { grid-template-columns: 1fr; } - .gx-tuner__tune-layout { grid-template-columns: 1fr; } - .gx-tuner__groups { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .gx-tuner__groups > button { padding: 12px; gap: 9px; } - .gx-tuner__groups > button > i:last-child { display: none; } +.gx-card.gx-tuner__hero { grid-template-columns: 62px minmax(0, 1fr); gap: 18px; } +.gx-tuner__hero-icon { width: 62px; height: 62px; border-radius: 20px; font-size: 1.7rem; } +.gx-tuner__hero-meta { grid-column: 2; border: 0; padding: 0; max-width: none; display: flex; flex-wrap: wrap; gap: 8px 16px; } +.gx-tuner__overview-grid { grid-template-columns: 1fr; } +.gx-tuner__tune-layout { grid-template-columns: 1fr; } +.gx-tuner__groups { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.gx-tuner__groups > button { padding: 12px; gap: 9px; } +.gx-tuner__groups > button > i:last-child { display: none; } } @media (max-width: 560px) { - .gx-tuner { gap: 18px; } - .gx-tuner__heading { gap: 12px; } - .gx-tuner__heading > button { padding: 9px 12px; font-size: .72rem; min-width: 44px; } - .gx-tuner__heading > button > i { display: none; } - .gx-card.gx-tuner__hero { grid-template-columns: minmax(0, 1fr); } - .gx-tuner__hero-icon { display: none; } - .gx-tuner__hero-meta { grid-column: 1; font-size: .64rem; } - .gx-tuner__tabs > button { padding: 0 11px 12px; font-size: .76rem; } - .gx-tuner__count { font-size: .56rem; margin-left: 4px; padding: 1px 4px; } - .gx-tuner__metrics { gap: 8px; } - .gx-card.gx-tuner__metric { padding: 15px 11px; gap: 8px; } - .gx-tuner__metric > span { font-size: .75rem; } - .gx-tuner__metric > strong { font-size: 1.5rem; } - .gx-tuner__metric > strong small { font-size: .6rem; } - .gx-tuner__metric p { font-size: .7rem; } - .gx-tuner__pill { font-size: .54rem; padding: 4px 6px; } - .gx-tuner__groups { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .gx-tuner__parameter-head { display: none; } - .gx-tuner__parameter { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 8px; padding: 15px 0; } - .gx-tuner__parameter > div:first-child { grid-column: 1 / -1; } - .gx-tuner__parameter > div:not(:first-child) { text-align: left; } - .gx-tuner__mobile-label { display: block; color: var(--text-muted); font-size: .59rem; margin-bottom: 6px; } - .gx-tuner__parameter strong { font-size: .72rem; } - .gx-tuner__diagnostics { grid-template-columns: 1fr; gap: 5px; } - .gx-tuner__diagnostics dd { margin-bottom: 12px; } +.gx-tuner { gap: 18px; } +.gx-tuner__heading { gap: 12px; } +.gx-tuner__heading > button { padding: 9px 12px; font-size: .72rem; min-width: 44px; } +.gx-tuner__heading > button > i { display: none; } +.gx-card.gx-tuner__hero { grid-template-columns: minmax(0, 1fr); } +.gx-tuner__hero-icon { display: none; } +.gx-tuner__hero-meta { grid-column: 1; font-size: .64rem; } +.gx-tuner__tabs > button { padding: 0 11px 12px; font-size: .76rem; } +.gx-tuner__count { font-size: .56rem; margin-left: 4px; padding: 1px 4px; } +.gx-tuner__metrics { gap: 8px; } +.gx-card.gx-tuner__metric { padding: 15px 11px; gap: 8px; } +.gx-tuner__metric > span { font-size: .75rem; } +.gx-tuner__metric > strong { font-size: 1.5rem; } +.gx-tuner__metric > strong small { font-size: .6rem; } +.gx-tuner__metric p { font-size: .7rem; } +.gx-tuner__pill { font-size: .54rem; padding: 4px 6px; } +.gx-tuner__groups { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.gx-tuner__parameter-head { display: none; } +.gx-tuner__parameter { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 8px; padding: 15px 0; } +.gx-tuner__parameter > div:first-child { grid-column: 1 / -1; } +.gx-tuner__parameter > div:not(:first-child) { text-align: left; } +.gx-tuner__mobile-label { display: block; color: var(--text-muted); font-size: .59rem; margin-bottom: 6px; } +.gx-tuner__parameter strong { font-size: .72rem; } +.gx-tuner__diagnostics { grid-template-columns: 1fr; gap: 5px; } +.gx-tuner__diagnostics dd { margin-bottom: 12px; } } - .gx-tuner__manual .gx-row { padding-inline: 0; } - /* Guided setup layout and emphasis; shared rows, buttons, fields and notices keep Galaxy defaults. */ .gx-card.gx-guided { display: grid; gap: var(--sp-5); padding: var(--panel-padding); background-image: radial-gradient(120% 38% at 50% 0, var(--primary-container), transparent 72%); } .gx-guided__steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); } diff --git a/openpilot/starpilot/galaxy/web/js/flm.js b/openpilot/starpilot/galaxy/web/js/flm.js index 520ec429c2..1759e34af1 100644 --- a/openpilot/starpilot/galaxy/web/js/flm.js +++ b/openpilot/starpilot/galaxy/web/js/flm.js @@ -1,5 +1,6 @@ import { GxState } from "./state.js" import { GxNotice } from "./notice.js" +import { GxDialog } from "./dialog.js" import { SettingsFeed, settingsBusy } from "./settings.js" import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js" import { PollTimer, connectionError } from "./polling.js" @@ -29,18 +30,17 @@ const PARAMETER_LABELS = { export const FlmPage = { name: "FlmPage", - components: { GxState, GxNotice, PlotGraph, PlotsPage, GalaxySettingRow }, + components: { GxState, GxNotice, GxDialog, PlotGraph, PlotsPage, GalaxySettingRow }, props: { initialView: { type: String, default: "overview" }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, default: path => { window.location.hash = path } } }, data: () => ({ report: null, error: "", loading: false, request: null, receivedAt: 0, now: 0, - manualState: { status: "idle", data: null, pending: null, error: "" }, + manualState: { status: "idle", data: null, pending: null, error: "" }, manualResetRequested: false, view: "overview", group: "balance", showAllHistory: false, groups: SURFACE_GROUPS }), watch: { initialView(view) { this.selectView(view) }, mode() { this.syncManualControls() } }, mounted() { this.activePage = true this.manualFeed = new SettingsFeed({ publish: update => Object.assign(this.manualState, update), unauthorized: this.unauthorized }) this.selectView(this.initialView) - this.syncManualControls() this.poller = new PollTimer({ read: () => this.load(), interval: 1000 }) this.visibility = () => { this.now = Date.now() @@ -55,7 +55,8 @@ export const FlmPage = { document.removeEventListener("visibilitychange", this.visibility) }, computed: { - manualRows() { return (this.manualState.data?.rows || []).map((row, index) => ({ row, index })).filter(({ row }) => settingControl(row) === "slider") }, + manualRows() { return (this.manualState.data?.rows || []).map((row, index) => ({ row, index })).filter(({ row }) => this.selectedGroup.names.includes(row.coordinate)) }, + manualActions() { return (this.manualState.data?.rows || []).map((row, index) => ({ row, index })).filter(({ row }) => row.confirm) }, manualBusy() { return settingsBusy(this.manualState) }, current() { return this.report?.current }, preference() { return validLearningPreference(this.report?.preference) ? this.report.preference : null }, @@ -108,13 +109,22 @@ export const FlmPage = { syncManualControls() { if (!this.manualFeed) return if (this.activePage && this.mode === "local" && this.view === "tune") { - if (!this.manualFeed.active) return this.manualFeed.start("torque") - } else this.manualFeed.stop() + if (!this.manualFeed.active) return this.manualFeed.start("tuning") + } else { this.manualResetRequested = false; this.manualFeed.stop() } }, + async reviewManualReset(index, direction) { + const row = this.manualState.data?.rows?.[index] + if (this.view !== "tune" || this.manualBusy || this.manualState.error || !row?.confirm || !row.available) return + this.manualResetRequested = true + await this.manualFeed.preview(index, direction) + if (!this.manualState.pending) this.manualResetRequested = false + }, + cancelManualReset() { this.manualResetRequested = false; this.manualFeed.cancel() }, + async confirmManualReset() { await this.manualFeed.confirm(); this.manualResetRequested = false }, async saveManual(index, value) { const row = this.manualState.data?.rows?.[index] if (this.view !== "tune" || !this.manualFeed?.active || this.manualBusy || this.manualState.error || - !row?.available || !row.action || settingControl(row) !== "slider") return false + !this.selectedGroup.names.includes(row?.coordinate) || !row.available || !row.action || settingControl(row) !== "slider") return false await this.manualFeed.previewValue(index, value) return !this.manualState.error && this.manualState.status === "ready" }, @@ -124,7 +134,7 @@ export const FlmPage = { event.preventDefault() const index = VIEWS.indexOf(this.view) const next = event.key === "Home" ? 0 : event.key === "End" ? VIEWS.length - 1 : (index + delta + VIEWS.length) % VIEWS.length - this.view = VIEWS[next] + this.selectView(VIEWS[next]) event.currentTarget.parentElement.querySelectorAll('[role="tab"]')[next]?.focus() }, async load() { @@ -153,8 +163,8 @@ export const FlmPage = {

STEERING · AUTO LEARN

Tuning and Analysis

{{ vehicle }}

Connect to your device to see its steering tune. diff --git a/openpilot/starpilot/lateral/torque_settings.py b/openpilot/starpilot/lateral/torque_settings.py index 95f2932cc0..3314c37d03 100644 --- a/openpilot/starpilot/lateral/torque_settings.py +++ b/openpilot/starpilot/lateral/torque_settings.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, replace from enum import StrEnum import json import math -from typing import cast +from typing import TYPE_CHECKING, cast from openpilot.starpilot.lateral.torque_supported import BOLT_VEHICLES, GAIN_VEHICLES, GM_VEHICLES @@ -17,6 +17,9 @@ MAX_DOCUMENT_BYTES = 16_384 from openpilot.starpilot.flm.torque_surface import GmFlmBinding from opendbc.car.values import PLATFORMS +if TYPE_CHECKING: + from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates + @dataclass(frozen=True) class FieldChoice: @@ -49,6 +52,7 @@ class PlatformProfile: gain_basis: GainBasis | None = None geometry: GeometryProfile | None = None flm: GmFlmBinding | None = None + manual_coordinates: ManualCoordinates | None = None class LegacyMode(StrEnum): @@ -163,7 +167,7 @@ def parse_document(raw: bytes) -> dict[str, PlatformProfile]: except (RecursionError, OverflowError) as exc: raise ValueError("Malformed torque document") from exc if not isinstance(document, dict) or set(document) != {"schemaVersion", "vehicles"} or \ - type(document["schemaVersion"]) is not int or document["schemaVersion"] not in (1, 2, 3, 4) or \ + type(document["schemaVersion"]) is not int or document["schemaVersion"] not in (1, 2, 3, 4, 5) or \ not isinstance(document["vehicles"], dict) or len(document["vehicles"]) > len(PLATFORMS): raise ValueError("Malformed torque document") profiles: dict[str, PlatformProfile] = {} @@ -173,8 +177,10 @@ def parse_document(raw: bytes) -> dict[str, PlatformProfile]: expected |= {"proportionalGain", "gainBasis"} if document["schemaVersion"] >= 3 and isinstance(value, dict) and "geometry" in value: expected |= {"geometry"} - if document["schemaVersion"] == 4 and isinstance(value, dict) and "flm" in value: + if document["schemaVersion"] >= 4 and isinstance(value, dict) and "flm" in value: expected |= {"flm"} + if document["schemaVersion"] == 5 and isinstance(value, dict) and "manualCoordinates" in value: + expected |= {"manualCoordinates"} if fingerprint not in PLATFORMS or not isinstance(value, dict) or set(value) != expected: raise ValueError("Malformed torque profile") basis_obj = value["basis"] @@ -204,13 +210,21 @@ def parse_document(raw: bytes) -> dict[str, PlatformProfile]: raise ValueError("Unsupported GM FLM profile") flm = GmFlmBinding.from_document(value["flm"]) flm.validate_manual(fingerprint) - profiles[fingerprint] = PlatformProfile(basis, _choice(value["factor"]), _choice(value["friction"]), gain, gain_basis, geometry, flm) + manual = None + if "manualCoordinates" in value: + from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates + manual = ManualCoordinates.from_document(value["manualCoordinates"]) + if flm is not None: + raise ValueError("Only one manual surface may be selected") + profiles[fingerprint] = PlatformProfile(basis, _choice(value["factor"]), _choice(value["friction"]), gain, gain_basis, geometry, flm, manual) return profiles def serialize_document(profiles: dict[str, PlatformProfile]) -> bytes: vehicles: dict[str, dict] = {} - version = (4 if any(p.flm is not None for p in profiles.values()) else 3 if any(p.geometry is not None for p in profiles.values()) + version = (5 if any(p.manual_coordinates is not None for p in profiles.values()) + else 4 if any(p.flm is not None for p in profiles.values()) + else 3 if any(p.geometry is not None for p in profiles.values()) else 2 if any(p.gain_basis is not None for p in profiles.values()) else 1) for fingerprint, profile in profiles.items(): vehicles[fingerprint] = { @@ -235,6 +249,11 @@ def serialize_document(profiles: dict[str, PlatformProfile]) -> bytes: } if profile.flm is not None: vehicles[fingerprint]["flm"] = profile.flm.document() + if profile.manual_coordinates is not None: + from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates + if type(profile.manual_coordinates) is not ManualCoordinates: + raise ValueError("Typed manual coordinates required") + vehicles[fingerprint]["manualCoordinates"] = profile.manual_coordinates.document() raw = json.dumps({"schemaVersion": version, "vehicles": vehicles}, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() parse_document(raw) return raw diff --git a/openpilot/starpilot/longitudinal/tests/test_gm_euv_long_policy.py b/openpilot/starpilot/longitudinal/tests/test_gm_euv_long_policy.py index 8031d91a86..3845c44136 100644 --- a/openpilot/starpilot/longitudinal/tests/test_gm_euv_long_policy.py +++ b/openpilot/starpilot/longitudinal/tests/test_gm_euv_long_policy.py @@ -2,6 +2,7 @@ from unittest.mock import Mock from openpilot.cereal.messaging import PubMaster +from openpilot.starpilot.lateral.controller_selection import default_selection from openpilot.starpilot.longitudinal.extension import LongitudinalContext from openpilot.starpilot.longitudinal.tests.extension_helpers import extension_state import os @@ -159,7 +160,7 @@ class TestEuvLongitudinal(unittest.TestCase): with patch('openpilot.selfdrive.controls.controlsd.Params'), \ patch('openpilot.selfdrive.controls.controlsd.messaging.log_from_bytes', return_value=cp), \ - patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None)), \ + patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None, mode=default_selection(cp).mode)), \ patch('openpilot.selfdrive.controls.controlsd.learning_allowed', return_value=False), \ patch('openpilot.selfdrive.controls.controlsd.feature_enabled', return_value=False), \ patch('openpilot.starpilot.longitudinal.inputs.toyota_development_enabled', return_value=False), \ diff --git a/openpilot/starpilot/longitudinal/tests/test_gm_volt_cc_policy.py b/openpilot/starpilot/longitudinal/tests/test_gm_volt_cc_policy.py index 62c5640ed8..b7865d7000 100644 --- a/openpilot/starpilot/longitudinal/tests/test_gm_volt_cc_policy.py +++ b/openpilot/starpilot/longitudinal/tests/test_gm_volt_cc_policy.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from unittest.mock import patch +from openpilot.starpilot.lateral.controller_selection import default_selection from openpilot.starpilot.longitudinal.extension import LongitudinalContext from openpilot.starpilot.longitudinal.tests.extension_helpers import extension_state import os @@ -150,7 +151,7 @@ class TestVoltCc(unittest.TestCase): for alpha in (False, True): with patch('openpilot.selfdrive.controls.controlsd.Params'), \ patch('openpilot.selfdrive.controls.controlsd.messaging.log_from_bytes', return_value=params(alpha)), \ - patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None)), \ + patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None, mode=default_selection(params(alpha)).mode)), \ patch('openpilot.selfdrive.controls.controlsd.learning_allowed', return_value=False), \ patch('openpilot.selfdrive.controls.controlsd.feature_enabled', return_value=False), \ patch('openpilot.starpilot.longitudinal.inputs.toyota_development_enabled', return_value=False), \ diff --git a/openpilot/starpilot/longitudinal/tests/test_gm_volt_stop.py b/openpilot/starpilot/longitudinal/tests/test_gm_volt_stop.py index f1096f96b5..b815a5adf8 100644 --- a/openpilot/starpilot/longitudinal/tests/test_gm_volt_stop.py +++ b/openpilot/starpilot/longitudinal/tests/test_gm_volt_stop.py @@ -1,5 +1,6 @@ """Default Volt STOP ownership, qualified release, and parsed Controls-to-CAN recurrence.""" +from openpilot.starpilot.lateral.controller_selection import default_selection from openpilot.starpilot.longitudinal.extension import LongitudinalContext from types import SimpleNamespace as NS import unittest @@ -101,7 +102,7 @@ class TestVoltStop(unittest.TestCase): raise Registered with patch('openpilot.selfdrive.controls.controlsd.Params'), \ patch('openpilot.selfdrive.controls.controlsd.messaging.log_from_bytes', return_value=cp), \ - patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None)), \ + patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None, mode=default_selection(cp).mode)), \ patch('openpilot.selfdrive.controls.controlsd.learning_allowed', return_value=False), \ patch('openpilot.selfdrive.controls.controlsd.feature_enabled', return_value=False), \ patch('openpilot.starpilot.longitudinal.inputs.toyota_development_enabled', return_value=False), \ diff --git a/openpilot/starpilot/ui/feature_settings_owner.py b/openpilot/starpilot/ui/feature_settings_owner.py index 0361b77453..ed2f67989f 100644 --- a/openpilot/starpilot/ui/feature_settings_owner.py +++ b/openpilot/starpilot/ui/feature_settings_owner.py @@ -1029,6 +1029,11 @@ class FeatureSettingsOwner: title = "BluePilot Controller Options" rows.extend(FordBluePilotFeature(self).rows(parked, options=True)) rows.extend(FordClusterFeature(self).rows(parked)) + elif page == FeaturePage.TUNING: + title = "Manual steering tune" + capability = self._capability("torque") + rows.extend(self.torque.manual_rows(capability, parked and self.authority("torque") and + self.authority("parked_preferences") and capability is not None)) elif page == FeaturePage.TORQUE: title = "Steering and Torque" rows.extend(FordBluePilotFeature(self).rows(parked)) @@ -1319,6 +1324,8 @@ class FeatureSettingsOwner: row.key.startswith('conditional:') and not row.key.startswith(BUTTON_PREFIX) else fingerprint) for row in rows] subtitle = ( + "Parked changes apply after the next drive starts. These are explicit manual overrides, separate from Auto Learn." + if page == FeaturePage.TUNING else "Configure features supported by this vehicle. Restart requirements are shown with each setting." if page == FeaturePage.VEHICLE else "Configure automatic Chill and Experimental switching." diff --git a/openpilot/starpilot/ui/feature_settings_state.py b/openpilot/starpilot/ui/feature_settings_state.py index 1b41a8a36a..11764d5441 100644 --- a/openpilot/starpilot/ui/feature_settings_state.py +++ b/openpilot/starpilot/ui/feature_settings_state.py @@ -57,6 +57,7 @@ class FeaturePage(StrEnum): TRAFFIC = "traffic" CURVE = "curve" TORQUE = "torque" + TUNING = "tuning" BLUEPILOT = "torque/bluepilot" AOL = "aol" WHEEL = "wheel" @@ -65,7 +66,8 @@ class FeaturePage(StrEnum): CONDITIONAL_CCM = "conditional/ccm" -TORQUE_CONFIRM_ACTIONS = frozenset(("torque_adopt", "torque_reset", "torque_reset_profile", "torque_rebase", "torque_prepare_firestar", "torque_gain_rebase")) +TORQUE_CONFIRM_ACTIONS = frozenset(("torque_adopt", "torque_reset", "torque_reset_profile", "torque_rebase", + "torque_prepare_firestar", "torque_gain_rebase", "torque:surface:reset")) SLC_CONFIRM_ACTIONS = frozenset(("slc_reset",)) SLC_OFFSET_KEYS = tuple(f"slc:offset:{index}" for index in range(1, 8)) LANE_CHANGE_CONFIRM_ACTIONS = frozenset(("lane_change:reset",)) diff --git a/openpilot/starpilot/ui/tests/test_home_stats_source.py b/openpilot/starpilot/ui/tests/test_home_stats_source.py index 85cee56745..1b855cf8fa 100644 --- a/openpilot/starpilot/ui/tests/test_home_stats_source.py +++ b/openpilot/starpilot/ui/tests/test_home_stats_source.py @@ -7,7 +7,7 @@ from pathlib import Path from tempfile import TemporaryDirectory import threading import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from openpilot.starpilot.ui.home_stats_source import HomeStatsSource, read_home_stats @@ -55,7 +55,7 @@ class TestHomeStatsSource(unittest.TestCase): self.assertIsNone(read_home_stats(False, store=path)) def test_future_read_never_blocks_render_and_changes_units_without_mixing_results(self): - pool = unittest.mock.Mock() + pool = Mock() first, second = Future(), Future() pool.submit.side_effect = [first, second] with patch('openpilot.starpilot.ui.home_stats_source.ThreadPoolExecutor', return_value=pool): @@ -76,7 +76,7 @@ class TestHomeStatsSource(unittest.TestCase): def test_failed_read_retries_and_recovers_instead_of_sticking_unavailable(self): now = [10.] - pool = unittest.mock.Mock() + pool = Mock() failed, recovered = Future(), Future() failed.set_exception(OSError('cache temporarily unavailable')) recovered.set_result('recovered') diff --git a/openpilot/starpilot/ui/torque_feature.py b/openpilot/starpilot/ui/torque_feature.py index c90868b39e..d5f3c54375 100644 --- a/openpilot/starpilot/ui/torque_feature.py +++ b/openpilot/starpilot/ui/torque_feature.py @@ -28,6 +28,74 @@ class TorqueFeature: self._readable = owner._readable self._dependents = owner._dependents + + def _manual_context(self): + from openpilot.starpilot.flm.manual_coordinates import context + cp = self.owner.vehicle_params() + if cp is None or not self._readable(SELECTION_KEY): + raise ValueError("Current controller unavailable") + selection = selection_from_bytes(cp, self._raw(SELECTION_KEY)) + if selection.source == "invalid": + raise ValueError("Saved controller selection unavailable") + return context(cp, selection.mode) + + def _manual_token(self): + return json.dumps(self._manual_context(), separators=(",", ":")).encode() + + def manual_rows(self, capability, allowed): + from openpilot.starpilot.flm.canonical_surface_adapter import NAMES, coordinate_bounds + raw = self._raw(DOCUMENT_KEY) + if capability is None or not self._readable(DOCUMENT_KEY): + return (FeatureRow("", "Manual tune", "Unavailable", reason="Current vehicle and readable saved settings are required"),) + try: + profiles = {} if raw is None else parse_document(raw) + profile = profiles.get(capability[0]) + basis = tuple(capability[5:8]) + token = self._manual_token() + current = self._manual_context() + saved = None if profile is None else profile.manual_coordinates + matches = profile is None or (profile.basis == basis and profile.flm is None + and (saved is None or saved.context == current)) + values = dict(saved.values) if saved is not None else dict.fromkeys(NAMES, 0.) + common = {"source": raw, "capability": capability, "related_source": token, + "dependencies": self._dependents(SELECTION_KEY, *TORQUE_NUMBERS, "ForceAutoTuneOff")} + reason = ("Parked only. Saved for the next drive; zero restores this stage's original baseline. " + + "Manual overrides pause Auto Learn and are not learned results.") if matches else ( + "Vehicle tune, controller or effect definition changed. Reset this manual tune before editing.") + rows = [] + for name in NAMES: + low, high = coordinate_bounds(name) + rows.append(FeatureRow("torque:surface:" + name, name.replace("_", " ").capitalize(), + f"{100 * values[name]:.4f}", step=.25, minimum=100 * low, maximum=100 * high, unit="%", + available=allowed and matches, reason=reason, default_value="0", **common)) + if saved is not None: + rows.append(FeatureRow("torque:surface:reset", "Reset manual tune", "Reset", choices=("Reset",), + available=allowed, reason="Remove only the thirty manual overrides. Accepted Auto Learn history is preserved.", **common)) + return tuple(rows) + except (AttributeError, KeyError, TypeError, ValueError, OverflowError, UnicodeError): + return (FeatureRow("", "Manual tune", "Unavailable", reason="Saved tuning or controller context is invalid; repair it in Toggles"),) + + def _edit_manual(self, profiles, request, fingerprint, basis): + from openpilot.starpilot.flm.canonical_surface_adapter import NAMES + from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates + if not self.authority("parked_preferences") or request.related_source != self._manual_token(): + raise ValueError("Manual tuning requires the same parked vehicle and controller") + prior = profiles.get(fingerprint, PlatformProfile(basis, FieldChoice(), FieldChoice())) + name = request.key.removeprefix("torque:surface:") + if name == "reset": + if not request.confirmation or request.value != "Reset": + raise ValueError("Explicit reset confirmation required") + return {**profiles, fingerprint: replace(prior, manual_coordinates=None)} + current = self._manual_context() + saved = prior.manual_coordinates + if (name not in NAMES or prior.basis != basis or prior.flm is not None + or saved is not None and saved.context != current): + raise ValueError("Manual tune context changed") + values = dict(saved.values) if saved is not None else dict.fromkeys(NAMES, 0.) + values[name] = float(request.value) / 100 + manual = ManualCoordinates(current, tuple((n, values[n]) for n in NAMES)).validate() + return {**profiles, fingerprint: replace(prior, manual_coordinates=manual if any(values.values()) else None)} + def _factor_editable(self, fingerprint: str) -> bool: if fingerprint not in GM_VEHICLES: return True @@ -289,6 +357,8 @@ class TorqueFeature: amended[fingerprint] = updated resolve_document(amended, fingerprint, basis) profiles = amended + elif request.key.startswith("torque:surface:"): + profiles = self._edit_manual(profiles, request, fingerprint, basis) elif request.key.startswith(("torque:ratio:", "torque:full_delay:", "torque:automatic_delay:", "torque:learning:", "torque:geometry_reset:")): profiles = self._edit_geometry(profiles, request, fingerprint, basis) elif request.key.startswith("torque:"): @@ -330,6 +400,7 @@ class TorqueFeature: else: return False encoded = serialize_document(profiles) + manual_request = request.key.startswith("torque:surface:") geometry_request = request.key.startswith(("torque:ratio:", "torque:full_delay:", "torque:automatic_delay:", "torque:learning:", "torque:geometry_reset:")) # Fresh authority and source evidence immediately before the one Params replacement. @@ -337,9 +408,21 @@ class TorqueFeature: self._capability("torque") != capability or not self._readable(DOCUMENT_KEY) or self._raw(DOCUMENT_KEY) != request.expected or geometry_request and request.related_source != self._geometry_token(self.owner.vehicle_params()) or + manual_request and (not self.authority("parked_preferences") or request.related_source != self._manual_token()) or request.key in TORQUE_CONFIRM_ACTIONS and not self.authority("parked_preferences") or any(not self._readable(name) or self._raw(name) != raw for name, raw in request.dependencies)): return False + if manual_request: + from openpilot.starpilot.saved_document import commit_exact + from openpilot.starpilot.lateral.torque_settings import MAX_DOCUMENT_BYTES + def authorized(): + return (self.authority("torque") and self.authority("parked_preferences") and + self.vehicle_fingerprint() == request.vehicle_fingerprint and self._capability("torque") == capability and + request.related_source == self._manual_token() and + all(self._readable(name) and self._raw(name) == raw for name, raw in request.dependencies)) + result = commit_exact(self.params, key=DOCUMENT_KEY, max_bytes=MAX_DOCUMENT_BYTES, raw=encoded, + expected=request.expected, authorized=authorized, temp_prefix=".manual-steering-") + return result.committed and result.verified self.params.put(DOCUMENT_KEY, json.loads(encoded), block=True) saved = self._raw(DOCUMENT_KEY) return saved is not None and parse_document(saved) == profiles diff --git a/openpilot/tools/arabesque_firefox/test_setup.py b/openpilot/tools/arabesque_firefox/test_setup.py index 0571d3ecc7..9cf6c4c61a 100644 --- a/openpilot/tools/arabesque_firefox/test_setup.py +++ b/openpilot/tools/arabesque_firefox/test_setup.py @@ -29,7 +29,9 @@ def test_corrupt_cached_package_is_rejected(tmp_path): def test_shipped_offline_payload_matches_the_pinned_bundle(): - offline = Path(setup.__file__).with_name('offline') + setup_file = setup.__file__ + assert setup_file is not None + offline = Path(setup_file).with_name('offline') expected = set() for package in setup.approved()['packages']: digest = package.get('sha512', package.get('sha256')) diff --git a/prebuilt.json b/prebuilt.json index 4d679ef75d..d4fd58bbcb 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "803e3f48381fb605404075f5d247a5830c23bd3ccf6abf12ec3aadd420608976", "schema_version": 1} +{"protected_inventory_sha256": "dc0fc34a78b6ecec51ba5a4ecfd601088ed237535b321ef878019980d3b8b465", "schema_version": 1}