diff --git a/.gitea/workflows/deploy-questions.yml b/.gitea/workflows/deploy-questions.yml index 92fccc5..b2290a9 100644 --- a/.gitea/workflows/deploy-questions.yml +++ b/.gitea/workflows/deploy-questions.yml @@ -70,6 +70,15 @@ jobs: run: | kubectl create namespace step3-questions --dry-run=client -o yaml | kubectl apply -f - + - name: Configure internal API secret + env: + INTERNAL_PURGE_SECRET: ${{ secrets.INTERNAL_PURGE_SECRET }} + run: | + test -n "$INTERNAL_PURGE_SECRET" + kubectl create secret generic medmind-internal-api \ + --from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \ + --dry-run=client -o yaml | kubectl apply -n step3-questions -f - + - name: Apply Questions deployment & service run: | kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions diff --git a/api/QuestionsApi/Controllers/ProfileController.cs b/api/QuestionsApi/Controllers/ProfileController.cs new file mode 100644 index 0000000..0adef3c --- /dev/null +++ b/api/QuestionsApi/Controllers/ProfileController.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/profile")] +public sealed class ProfileController(UserProfileStore profiles, IConfiguration configuration) : ControllerBase +{ + [HttpGet("me/preferences")] + [Authorize(Roles = "USER,SUPER")] + public async Task GetPreferences() + { + var profile = await profiles.GetAsync(UserId()); + return Ok(profile ?? new UserStudyProfile { UserId = UserId() }); + } + + [HttpPut("me/preferences")] + [Authorize(Roles = "USER,SUPER")] + public async Task UpdatePreferences(UpdateUserStudyProfileRequest request) + { + if (UserStudyProfileValidator.Validate(request) is { } error) return BadRequest(error); + return Ok(await profiles.UpsertAsync(UserId(), request)); + } + + [HttpGet("me/export")] + [Authorize(Roles = "USER,SUPER")] + public async Task Export() => Ok(new + { + schemaVersion = 1, + exportedUtc = DateTime.UtcNow, + userId = UserId(), + records = await profiles.ExportAsync(UserId()) + }); + + [HttpDelete("internal/users/{userId}")] + [AllowAnonymous] + public async Task Purge(string userId) + { + var expected = configuration["InternalPurge:Secret"]; + var supplied = Request.Headers["X-MedMind-Internal-Key"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(expected) || !CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(expected), + System.Text.Encoding.UTF8.GetBytes(supplied ?? ""))) + return Unauthorized(); + await profiles.PurgeAsync(userId); + return NoContent(); + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? throw new UnauthorizedAccessException(); +} diff --git a/api/QuestionsApi/Models/DashboardModels.cs b/api/QuestionsApi/Models/DashboardModels.cs index 1afea94..a6bad14 100644 --- a/api/QuestionsApi/Models/DashboardModels.cs +++ b/api/QuestionsApi/Models/DashboardModels.cs @@ -6,6 +6,8 @@ public sealed class DashboardResponse public int RatedQuestionCount { get; set; } public int CcsRating { get; set; } = 1000; public int RatedCcsCaseCount { get; set; } + public int QuestionsCompleted { get; set; } + public int CcsCasesCompleted { get; set; } public int FirstAttemptAccuracy { get; set; } public int AccuracySampleSize { get; set; } public int QuestionsThisWeek { get; set; } diff --git a/api/QuestionsApi/Models/UserProfileModels.cs b/api/QuestionsApi/Models/UserProfileModels.cs new file mode 100644 index 0000000..aedfb6c --- /dev/null +++ b/api/QuestionsApi/Models/UserProfileModels.cs @@ -0,0 +1,50 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public sealed class UserStudyProfile +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public DateTime? TargetExamDate { get; set; } + public string PreparationStage { get; set; } = "pre_dedicated"; + public int WeeklyGoalHours { get; set; } = 10; + public string PreferredStudyMode { get; set; } = "mixed"; + public int Revision { get; set; } + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class UpdateUserStudyProfileRequest +{ + public DateTime? TargetExamDate { get; set; } + public string PreparationStage { get; set; } = "pre_dedicated"; + public int WeeklyGoalHours { get; set; } = 10; + public string PreferredStudyMode { get; set; } = "mixed"; +} + +public sealed class DeletedUserTombstone +{ + [BsonId] + public string UserId { get; set; } = ""; + public DateTime DeletedUtc { get; set; } = DateTime.UtcNow; +} + +public static class UserStudyProfileValidator +{ + private static readonly HashSet Stages = ["pre_dedicated", "dedicated", "final_review"]; + private static readonly HashSet Goals = [5, 10, 15, 20, 25]; + private static readonly HashSet Modes = ["mixed", "question_bank", "ccs", "exam_simulation"]; + + public static string? Validate(UpdateUserStudyProfileRequest request) + { + if (!Stages.Contains(request.PreparationStage)) return "Invalid preparation stage."; + if (!Goals.Contains(request.WeeklyGoalHours)) return "Weekly goal must be 5, 10, 15, 20, or 25 hours."; + if (!Modes.Contains(request.PreferredStudyMode)) return "Invalid preferred study mode."; + if (request.TargetExamDate?.Kind == DateTimeKind.Local) + request.TargetExamDate = request.TargetExamDate.Value.ToUniversalTime(); + return null; + } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 1f19b7f..7e52ca3 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -71,6 +72,18 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30) }; + o.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrWhiteSpace(userId)) + { + var profiles = context.HttpContext.RequestServices.GetRequiredService(); + if (await profiles.IsDeletedAsync(userId)) context.Fail("Account deleted"); + } + } + }; }); builder.Services.AddAuthorization(); diff --git a/api/QuestionsApi/Services/DashboardStore.cs b/api/QuestionsApi/Services/DashboardStore.cs index 799e3f5..2631977 100644 --- a/api/QuestionsApi/Services/DashboardStore.cs +++ b/api/QuestionsApi/Services/DashboardStore.cs @@ -39,11 +39,13 @@ public sealed class DashboardStore var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync(); + var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId + && value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue); var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync(); var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart); - await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, + await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask, simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask); var attempts = attemptsTask.Result; @@ -62,6 +64,8 @@ public sealed class DashboardStore RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0, CcsRating = ccsRatingTask.Result?.Elo ?? 1000, RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0, + QuestionsCompleted = answeredAttempts.Count, + CcsCasesCompleted = checked((int)completedCcsCountTask.Result), AccuracySampleSize = recentAnswered.Count, FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count), QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart), diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs new file mode 100644 index 0000000..1b79a19 --- /dev/null +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -0,0 +1,99 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class UserProfileStore +{ + private readonly IMongoDatabase _database; + private readonly IMongoCollection _profiles; + private readonly IMongoCollection _deletedUsers; + + private static readonly string[] OwnedCollections = + [ + "QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads", + "ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates", + "CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations", + "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles" + ]; + + public UserProfileStore(MongoDatabaseProvider mongo) + { + _database = mongo.Database; + _profiles = _database.GetCollection("UserStudyProfiles"); + _deletedUsers = _database.GetCollection("DeletedUserTombstones"); + _profiles.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId), + new CreateIndexOptions { Unique = true })); + } + + public async Task GetAsync(string userId) => + await _profiles.Find(value => value.UserId == userId).FirstOrDefaultAsync(); + + public async Task UpsertAsync(string userId, UpdateUserStudyProfileRequest request) + { + var update = Builders.Update + .Set(value => value.TargetExamDate, request.TargetExamDate) + .Set(value => value.PreparationStage, request.PreparationStage) + .Set(value => value.WeeklyGoalHours, request.WeeklyGoalHours) + .Set(value => value.PreferredStudyMode, request.PreferredStudyMode) + .Set(value => value.UpdatedUtc, DateTime.UtcNow) + .Inc(value => value.Revision, 1) + .SetOnInsert(value => value.UserId, userId); + return await _profiles.FindOneAndUpdateAsync( + value => value.UserId == userId, + update, + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); + } + + public Task IsDeletedAsync(string userId) => + _deletedUsers.Find(value => value.UserId == userId).AnyAsync(); + + public async Task>> ExportAsync(string userId) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var collectionName in OwnedCollections) + { + var documents = await _database.GetCollection(collectionName) + .Find(Builders.Filter.Eq("UserId", userId)).ToListAsync(); + result[collectionName] = documents.Select(document => Map(document)).ToList(); + } + return result; + } + + public async Task PurgeAsync(string userId) + { + await _deletedUsers.ReplaceOneAsync( + value => value.UserId == userId, + new DeletedUserTombstone { UserId = userId, DeletedUtc = DateTime.UtcNow }, + new ReplaceOptions { IsUpsert = true }); + + foreach (var collectionName in OwnedCollections) + { + await _database.GetCollection(collectionName) + .DeleteManyAsync(Builders.Filter.Eq("UserId", userId)); + } + + foreach (var collectionName in new[] { "QuestionReports", "CcsCaseReports", "AppFeedback" }) + { + await _database.GetCollection(collectionName).UpdateManyAsync( + Builders.Filter.Eq("ReviewedByUserId", userId), + Builders.Update.Unset("ReviewedByUserId")); + } + } + + private static object? Map(BsonValue value) + { + if (value.IsBsonNull) return null; + if (value.IsBsonDocument) + return value.AsBsonDocument.Elements.ToDictionary(element => element.Name, element => Map(element.Value)); + if (value.IsBsonArray) return value.AsBsonArray.Select(Map).ToList(); + if (value.IsObjectId) return value.AsObjectId.ToString(); + if (value.IsValidDateTime) return value.ToUniversalTime(); + if (value.IsDecimal128) return Decimal128.ToDecimal(value.AsDecimal128); + if (value.IsBsonBinaryData) return Convert.ToBase64String(value.AsBsonBinaryData.Bytes); + if (value.IsBsonRegularExpression) return value.AsBsonRegularExpression.ToString(); + return BsonTypeMapper.MapToDotNetValue(value); + } +} diff --git a/api/QuestionsApi/k8s/deployment.yaml b/api/QuestionsApi/k8s/deployment.yaml index d5543d4..f5d7294 100644 --- a/api/QuestionsApi/k8s/deployment.yaml +++ b/api/QuestionsApi/k8s/deployment.yaml @@ -29,6 +29,12 @@ spec: imagePullPolicy: IfNotPresent ports: - containerPort: 5008 + env: + - name: InternalPurge__Secret + valueFrom: + secretKeyRef: + name: medmind-internal-api + key: purge-secret resources: requests: cpu: 250m diff --git a/docs/PROFILE_ACCOUNT_OPERATIONS.md b/docs/PROFILE_ACCOUNT_OPERATIONS.md new file mode 100644 index 0000000..5c4fc6d --- /dev/null +++ b/docs/PROFILE_ACCOUNT_OPERATIONS.md @@ -0,0 +1,26 @@ +# Profile and account operations + +The profile screen reads account identity from `GET /api/Auth/me`, study preferences from +`GET /api/profile/me/preferences`, and performance metrics from `GET /api/dashboard`. + +## Deployment + +Set the same non-empty `INTERNAL_PURGE_SECRET` Gitea Actions secret in both the Step 3 and +Promiscuity repositories. Their deployment workflows create namespace-local +`medmind-internal-api` Kubernetes secrets from that value. + +Deploy in this order: + +1. Questions API +2. Auth API +3. Godot web client + +The Auth API only removes an account after the Questions API confirms that the user's study +records were purged. The purge endpoint is idempotent and creates a tombstone that prevents an +old access token from recreating study data. + +## Data export + +Exports are versioned JSON files containing account metadata and user-owned study records. +Password hashes, refresh tokens, global question content, and moderation data owned by other +users are excluded. diff --git a/project.godot b/project.godot index 370b875..b1d54b6 100644 --- a/project.godot +++ b/project.godot @@ -32,6 +32,7 @@ NotesService="*res://scripts/autoload/notes_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd" ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd" +AccountProfileService="*res://scripts/autoload/account_profile_service.gd" [display] @@ -58,5 +59,6 @@ question_reports_api_url="https://s3q.ranaze.com/api/question-reports" app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" notes_api_url="https://s3q.ranaze.com/api/notes" dashboard_api_url="https://s3q.ranaze.com/api/dashboard" +profile_api_url="https://s3q.ranaze.com/api/profile" review_queue_api_url="https://s3q.ranaze.com/api/review-queue" build_revision="development" diff --git a/scripts/autoload/account_profile_service.gd b/scripts/autoload/account_profile_service.gd new file mode 100644 index 0000000..6a5f455 --- /dev/null +++ b/scripts/autoload/account_profile_service.gd @@ -0,0 +1,229 @@ +extends Node + +signal profile_changed +signal request_failed(message: String) +signal preferences_saved +signal password_changed +signal account_deleted +signal archive_saved(path: String) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const AUTH_URL := "https://pauth.ranaze.com/api/Auth" +const PROFILE_URL := "https://s3q.ranaze.com/api/profile" + +var account: Dictionary = {} +var preferences: Dictionary = _default_preferences() +var auth_request: HTTPRequest +var profile_request: HTTPRequest +var auth_operation := "" +var profile_operation := "" +var auth_token := "" +var profile_token := "" +var preferences_pending := false + + +func _ready() -> void: + auth_request = HTTPRequest.new() + auth_request.timeout = 25.0 + auth_request.request_completed.connect(_on_auth_completed) + add_child(auth_request) + profile_request = HTTPRequest.new() + profile_request.timeout = 30.0 + profile_request.request_completed.connect(_on_profile_completed) + add_child(profile_request) + AuthState.auth_changed.connect(_on_auth_changed) + + +func refresh() -> void: + if not AuthState.can_send_authenticated_request(): + return + _load_cache() + _request_auth("identity", HTTPClient.METHOD_GET, "/me") + if preferences_pending: + _request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences) + else: + _request_profile("preferences", HTTPClient.METHOD_GET, "/me/preferences") + + +func update_account(display_name: String, email: String) -> void: + _request_auth("update_account", HTTPClient.METHOD_PATCH, "/me", { + "displayName": display_name.strip_edges(), "email": email.strip_edges() + }) + + +func update_preferences(target_exam_date: String, preparation_stage: String, weekly_goal_hours: int, preferred_mode: String) -> void: + var target_date: Variant = null + if not target_exam_date.strip_edges().is_empty(): + target_date = "%sT00:00:00Z" % target_exam_date.strip_edges() + preferences = { + "targetExamDate": target_date, + "preparationStage": preparation_stage, + "weeklyGoalHours": weekly_goal_hours, + "preferredStudyMode": preferred_mode + } + preferences_pending = true + _save_cache(true) + profile_changed.emit() + _request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences) + + +func change_password(current_password: String, new_password: String) -> void: + _request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", { + "currentPassword": current_password, "newPassword": new_password + }) + + +func export_archive() -> void: + _request_profile("export", HTTPClient.METHOD_GET, "/me/export") + + +func delete_account(password: String, confirmation: String) -> void: + _request_auth("delete_account", HTTPClient.METHOD_DELETE, "/me", { + "password": password, "confirmation": confirmation + }) + + +func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void: + if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + request_failed.emit("Another account request is still in progress.") + return + auth_operation = operation + auth_token = AuthState.access_token + var headers := PackedStringArray(["Authorization: Bearer %s" % auth_token, "Content-Type: application/json"]) + var url := "%s%s" % [EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/"), path] + var error := auth_request.request(url, headers, method, "" if payload == null else JSON.stringify(payload)) + if error != OK: + auth_operation = "" + request_failed.emit("The account service could not be reached.") + + +func _request_profile(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void: + if profile_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + request_failed.emit("Another profile request is still in progress.") + return + profile_operation = operation + profile_token = AuthState.access_token + var headers := PackedStringArray(["Authorization: Bearer %s" % profile_token, "Content-Type: application/json"]) + var base := EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/profile_api_url", PROFILE_URL)), "/api/profile") + var error := profile_request.request("%s%s" % [base.rstrip("/"), path], headers, method, "" if payload == null else JSON.stringify(payload)) + if error != OK: + profile_operation = "" + request_failed.emit("The profile service could not be reached.") + + +func _on_auth_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var operation := auth_operation + auth_operation = "" + if response_code == 401: + AuthState.handle_unauthorized(auth_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit(_error_message(body, "The account request failed.")) + return + if operation in ["identity", "update_account"]: + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + request_failed.emit("The account service returned invalid data.") + return + account = parsed + profile_changed.emit() + elif operation == "change_password": + password_changed.emit() + AuthState.clear_session() + elif operation == "delete_account": + _clear_cache() + account_deleted.emit() + AuthState.clear_session() + + +func _on_profile_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var operation := profile_operation + profile_operation = "" + if response_code == 401: + AuthState.handle_unauthorized(profile_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit(_error_message(body, "The profile request failed.")) + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + request_failed.emit("The profile service returned invalid data.") + return + if operation in ["preferences", "update_preferences"]: + preferences = parsed + preferences_pending = false + _save_cache(false) + profile_changed.emit() + if operation == "update_preferences": + preferences_saved.emit() + elif operation == "export": + var archive: Dictionary = parsed + archive["account"] = account.duplicate(true) + _save_archive(JSON.stringify(archive, " ")) + + +func _save_archive(content: String) -> void: + var filename := "medmind-account-export-%s.json" % Time.get_date_string_from_system() + if OS.has_feature("web"): + var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer()) + JavaScriptBridge.eval("(function(){const a=document.createElement('a');a.href='data:application/json;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [encoded, JSON.stringify(filename)], true) + archive_saved.emit(filename) + return + var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS) + var path := directory.path_join(filename) + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + request_failed.emit("The archive could not be saved.") + return + file.store_string(content) + archive_saved.emit(path) + + +func _on_auth_changed() -> void: + if AuthState.is_logged_in: + _load_cache() + else: + account = {} + preferences = _default_preferences() + + +func _cache_path() -> String: + return "user://profile-%s.json" % AuthState.username.sha256_text().left(16) + + +func _load_cache() -> void: + if AuthState.username.is_empty() or not FileAccess.file_exists(_cache_path()): + return + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(_cache_path())) + if typeof(parsed) == TYPE_DICTIONARY: + preferences = parsed.get("preferences", _default_preferences()) + preferences_pending = bool(parsed.get("pending", false)) + + +func _save_cache(pending: bool) -> void: + if AuthState.username.is_empty(): + return + var file := FileAccess.open(_cache_path(), FileAccess.WRITE) + if file != null: + preferences_pending = pending + file.store_string(JSON.stringify({"preferences": preferences, "pending": pending})) + + +func _clear_cache() -> void: + if not AuthState.username.is_empty() and FileAccess.file_exists(_cache_path()): + DirAccess.remove_absolute(ProjectSettings.globalize_path(_cache_path())) + + +func _error_message(body: PackedByteArray, fallback: String) -> String: + var raw := body.get_string_from_utf8().strip_edges() + var parsed: Variant = JSON.parse_string(raw) + if typeof(parsed) == TYPE_DICTIONARY: + var detail := str(parsed.get("detail", parsed.get("title", ""))).strip_edges() + if not detail.is_empty(): + return detail + var text := raw.trim_prefix("\"").trim_suffix("\"") + return fallback if text.is_empty() else text + + +static func _default_preferences() -> Dictionary: + return {"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed"} diff --git a/scripts/autoload/account_profile_service.gd.uid b/scripts/autoload/account_profile_service.gd.uid new file mode 100644 index 0000000..1c75a47 --- /dev/null +++ b/scripts/autoload/account_profile_service.gd.uid @@ -0,0 +1 @@ +uid://dbgdrurlh4btb diff --git a/scripts/ui/bookmarks_screen.gd b/scripts/ui/bookmarks_screen.gd index 3b6cd42..ba0fc41 100644 --- a/scripts/ui/bookmarks_screen.gd +++ b/scripts/ui/bookmarks_screen.gd @@ -21,7 +21,6 @@ func _ready() -> void: size_flags_horizontal = Control.SIZE_EXPAND_FILL size_flags_vertical = Control.SIZE_EXPAND_FILL add_theme_constant_override("separation", 16) - _build_header() _build_filters() status_label = Label.new() status_label.add_theme_font_override("font", Brand.font) @@ -58,25 +57,6 @@ func _ready() -> void: UIThemeService.theme_changed.connect(func(_dark: bool): _render()) -func _build_header() -> void: - var eyebrow := Label.new() - eyebrow.text = "STUDY TOOLS" - eyebrow.add_theme_font_override("font", Brand.font) - eyebrow.add_theme_font_size_override("font_size", 13) - eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE) - add_child(eyebrow) - var title := Label.new() - title.text = "Bookmarks" - title.add_theme_font_override("font", Brand.font) - title.add_theme_font_size_override("font_size", 32) - add_child(title) - var subtitle := Label.new() - subtitle.text = "Review saved questions without changing your rating or progression." - subtitle.add_theme_font_override("font", Brand.font) - subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) - add_child(subtitle) - - func _build_filters() -> void: var row := HFlowContainer.new() row.add_theme_constant_override("separation", 10) diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index d1c1f1f..56d7c8b 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -21,15 +21,17 @@ var weekly_summary: VBoxContainer var metric_values: Dictionary = {} var metric_details: Dictionary = {} var pending_review_launch := false +var review_queue_service: Variant func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + review_queue_service = get_node("/root/ReviewQueueService") DashboardService.dashboard_changed.connect(_render) DashboardService.dashboard_failed.connect(_show_error) - ReviewQueueService.question_loaded.connect(_on_review_question_loaded) - ReviewQueueService.question_failed.connect(_on_review_question_failed) - ReviewQueueService.pass_completed.connect(_on_review_pass_completed) + review_queue_service.question_loaded.connect(_on_review_question_loaded) + review_queue_service.question_failed.connect(_on_review_question_failed) + review_queue_service.pass_completed.connect(_on_review_pass_completed) UIThemeService.theme_changed.connect(_rebuild) resized.connect(_update_responsive) _build() @@ -225,7 +227,7 @@ func _render_focus(systems: Array) -> void: progress.custom_minimum_size.y = 8 _style_progress(progress, Brand.PURPLE) focus_content.add_child(progress) - var practice := _button("Practice this system", "chevron-right") + var practice := _button("Practice this system") practice.pressed.connect(func(): main.show_screen("selection", "question_bank")) focus_content.add_child(practice) @@ -333,7 +335,7 @@ func _start_review_queue() -> void: return pending_review_launch = true _show_loading_message("Preparing your review queue...") - ReviewQueueService.start_pass() + review_queue_service.start_pass() _render_review(DashboardService.data) diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index efac229..bb3da6b 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -471,11 +471,7 @@ func _apply_theme() -> void: else: label.add_theme_color_override("font_color", Brand.text_color(dark)) for input in fields: - input.add_theme_color_override("font_color", Brand.text_color(dark)) - input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) - input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE) - input.add_theme_stylebox_override("normal", Brand.field_box(dark)) - input.add_theme_stylebox_override("focus", Brand.field_box(dark, true)) + _style_auth_field(input, dark) for button in find_children("*", "Button", true, false): var typed := button as Button typed.add_theme_font_override("font", Brand.font) @@ -503,6 +499,16 @@ func _apply_theme() -> void: brand_tagline.add_theme_color_override("default_color", Brand.muted_color(dark)) +func _style_auth_field(input: LineEdit, dark: bool) -> void: + input.add_theme_color_override("font_color", Brand.text_color(dark)) + input.add_theme_color_override("font_uneditable_color", Brand.text_color(dark)) + input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) + input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE) + input.add_theme_stylebox_override("normal", Brand.field_box(dark)) + input.add_theme_stylebox_override("focus", Brand.field_box(dark, true)) + input.add_theme_stylebox_override("read_only", Brand.field_box(dark)) + + func _on_theme_changed(_is_dark: bool) -> void: _apply_theme() diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index b5cdadc..625c385 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -381,12 +381,12 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void _add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true) - _add_nav_button(container, "performance", "Profile", "user-round", true) var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL container.add_child(spacer) container.add_child(HSeparator.new()) + _add_nav_button(container, "performance", "Profile", "user-round", true) _add_nav_button(container, "settings", "Settings", "settings", true) _add_nav_button(container, "logout", "Log out", "log-out", true) diff --git a/scripts/ui/notes_screen.gd b/scripts/ui/notes_screen.gd index 4f28335..4a9c27d 100644 --- a/scripts/ui/notes_screen.gd +++ b/scripts/ui/notes_screen.gd @@ -1,6 +1,5 @@ extends VBoxContainer -const Brand := preload("res://scripts/ui/medmind_style.gd") const NotesEditor := preload("res://scripts/ui/notes_editor.gd") var main @@ -11,17 +10,6 @@ func _ready() -> void: size_flags_horizontal = Control.SIZE_EXPAND_FILL size_flags_vertical = Control.SIZE_EXPAND_FILL add_theme_constant_override("separation", 16) - var eyebrow := Label.new() - eyebrow.text = "STUDY TOOLS" - eyebrow.add_theme_font_override("font", Brand.font) - eyebrow.add_theme_font_size_override("font_size", 13) - eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE) - add_child(eyebrow) - var title := Label.new() - title.text = "Notes" - title.add_theme_font_override("font", Brand.font) - title.add_theme_font_size_override("font_size", 32) - add_child(title) notes_editor = NotesEditor.new() add_child(notes_editor) diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index 1d7835c..5ad3dd2 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -1,266 +1,569 @@ -extends VBoxContainer +extends Control -const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") var main: Control -var profile_text: RichTextLabel -var reset_qbank_button: Button -var reset_qbank_pending := false +var scroll: ScrollContainer +var page: VBoxContainer +var columns: GridContainer +var profile_name: Label +var profile_identity: Label +var profile_meta: Label +var identity_values: Dictionary = {} +var metric_values: Dictionary = {} +var systems_list: VBoxContainer +var status_label: Label var outcome_score: SpinBox var outcome_date: LineEdit var outcome_consent: CheckBox var outcome_status: Label -var outcome_delete_button: Button -var outcome_grid: GridContainer -var ccs_rating_label: Label +var outcome_delete: Button + func _ready() -> void: - set_anchors_preset(Control.PRESET_FULL_RECT) - add_theme_constant_override("separation", 14) - - var title := Label.new() - title.text = "Progress Profile" - Style.set_label(title, 34, Style.CYAN) - add_child(title) - - var scroll := ScrollContainer.new() - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - add_child(scroll) - var content := VBoxContainer.new() - content.size_flags_horizontal = Control.SIZE_EXPAND_FILL - content.add_theme_constant_override("separation", 14) - scroll.add_child(content) - ccs_rating_label = Label.new() - Style.set_label(ccs_rating_label, 20, Style.CYAN) - content.add_child(ccs_rating_label) - - var panel := PanelContainer.new() - panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475"))) - panel.custom_minimum_size.y = 300 - content.add_child(panel) - - profile_text = RichTextLabel.new() - profile_text.bbcode_enabled = true - profile_text.size_flags_vertical = Control.SIZE_EXPAND_FILL - panel.add_child(profile_text) - - _build_outcome_panel(content) - - var row := HBoxContainer.new() - var back := Button.new() - back.text = "Back" - back.pressed.connect(func(): main.show_screen("selection", "step3")) - row.add_child(back) - - var reset := Button.new() - reset.text = "Reset Progress" - Style.style_chip(reset, Style.RED) - reset.pressed.connect(func(): - ProfileService.reset_profile() - on_show() - ) - row.add_child(reset) - - var simulations := Button.new() - simulations.text = "Simulation History" - Style.style_chip(simulations, Style.CYAN) - simulations.pressed.connect(func(): main.show_screen("simulation_history")) - row.add_child(simulations) - - var ccs_history := Button.new() - ccs_history.text = "CCS History" - Style.style_chip(ccs_history, Style.CYAN) - ccs_history.pressed.connect(func(): main.show_screen("ccs_history")) - row.add_child(ccs_history) - - reset_qbank_button = Button.new() - reset_qbank_button.text = "Reset QBank" - Style.style_chip(reset_qbank_button, Style.AMBER) - reset_qbank_button.pressed.connect(_on_reset_qbank_pressed) - row.add_child(reset_qbank_button) - add_child(row) - - if not ExamSimulationService.outcome_changed.is_connected(_refresh_outcome): - ExamSimulationService.outcome_changed.connect(_refresh_outcome) - if not ExamSimulationService.request_failed.is_connected(_on_outcome_request_failed): - ExamSimulationService.request_failed.connect(_on_outcome_request_failed) - if not CCSService.rating_changed.is_connected(_refresh_ccs_rating): - CCSService.rating_changed.connect(_refresh_ccs_rating) - resized.connect(_refresh_outcome_layout) - _refresh_outcome_layout() + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + AccountProfileService.profile_changed.connect(_render) + AccountProfileService.request_failed.connect(_show_error) + AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved.")) + AccountProfileService.archive_saved.connect(func(path: String): _show_saved("Archive saved: %s" % path)) + DashboardService.dashboard_changed.connect(_render) + DashboardService.dashboard_failed.connect(_show_error) + ExamSimulationService.outcome_changed.connect(_render_outcome) + ExamSimulationService.request_failed.connect(_show_error) + UIThemeService.theme_changed.connect(_rebuild) + resized.connect(_update_responsive) + _build() func on_show(_payload: Variant = null) -> void: - _reset_qbank_confirmation() - _refresh_ccs_rating() - var profile := ProfileService.get_profile() - var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [ - QuestionBankService.overall_elo, - QuestionBankService.rated_question_count, - profile["current_streak"], - profile["best_streak"], - profile.get("daily_questions_answered", 0), - profile.get("daily_goal_questions", 10) - ] - if QuestionBankService.category_elos.is_empty(): - text += "No rated categories yet.\n" - else: - var categories := QuestionBankService.category_elos.keys() - categories.sort() - for category in categories: - text += "- %s: %d\n" % [category, int(QuestionBankService.category_elos[category])] - - text += "\n[b]System Accuracy[/b]\n" - if profile["system_stats"].is_empty(): - text += "No completed questions yet.\n" - else: - for system_name in profile["system_stats"].keys(): - var stats: Dictionary = profile["system_stats"][system_name] - var seen := int(stats.get("seen", 0)) - var correct := int(stats.get("correct", 0)) - var accuracy := int(round(float(correct) / float(max(seen, 1)) * 100.0)) - text += "- %s: %d/%d (%d%%)\n" % [system_name, correct, seen, accuracy] - - text += "\n[b]Recent Blocks[/b]\n" - for block in profile["block_history"]: - text += "- %s: %s %d/%d\n" % [ - block.get("timestamp", ""), - block.get("system", ""), - block.get("correct", 0), - block.get("total", 0) - ] - profile_text.text = text + _render() + AccountProfileService.refresh() + DashboardService.refresh() CCSService.load_rating() - outcome_status.text = "Loading official outcome..." ExamSimulationService.load_outcome() -func _refresh_ccs_rating() -> void: - ccs_rating_label.text = "CCS ELO %d | %d rated cases" % [CCSService.rating.get("elo", 1000), CCSService.rating.get("ratedCaseCount", 0)] +func _build() -> void: + for child in get_children(): + remove_child(child) + child.free() + identity_values.clear() + metric_values.clear() + scroll = ScrollContainer.new() + scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(scroll) + page = VBoxContainer.new() + page.size_flags_horizontal = Control.SIZE_EXPAND_FILL + page.add_theme_constant_override("separation", 14) + scroll.add_child(page) + status_label = _label("", 13, Brand.muted_color(_dark())) + status_label.visible = false + page.add_child(status_label) + columns = GridContainer.new() + columns.columns = 2 + columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL + columns.add_theme_constant_override("h_separation", 14) + columns.add_theme_constant_override("v_separation", 14) + page.add_child(columns) + + var left := VBoxContainer.new() + left.size_flags_horizontal = Control.SIZE_EXPAND_FILL + left.add_theme_constant_override("separation", 14) + columns.add_child(left) + left.add_child(_profile_summary_card()) + left.add_child(_study_identity_card()) + left.add_child(_performance_card()) + left.add_child(_systems_card()) + + var right := VBoxContainer.new() + right.custom_minimum_size.x = 410 + right.size_flags_horizontal = Control.SIZE_EXPAND_FILL + right.add_theme_constant_override("separation", 14) + columns.add_child(right) + right.add_child(_outcome_card()) + right.add_child(_account_actions_card()) + _update_responsive() + _render() -func _build_outcome_panel(parent: VBoxContainer) -> void: - var panel := PanelContainer.new() - panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475"))) - parent.add_child(panel) - var section := VBoxContainer.new() - section.add_theme_constant_override("separation", 10) - panel.add_child(section) - var heading := Label.new() - heading.text = "Official Step 3 Outcome" - Style.set_label(heading, 22, Style.TEXT) - section.add_child(heading) - var note := Label.new() - note.text = "After receiving your official result, you may voluntarily submit it. You can edit or delete it at any time; calibration uses de-identified aggregates." - note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(note, 15, Style.MUTED) - section.add_child(note) - outcome_grid = GridContainer.new() - outcome_grid.add_theme_constant_override("h_separation", 10) - outcome_grid.add_theme_constant_override("v_separation", 10) - section.add_child(outcome_grid) +func _profile_summary_card() -> PanelContainer: + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 14) + body.add_child(_section_header("Profile Summary", "Your MedMind account")) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 18) + var avatar := PanelContainer.new() + avatar.custom_minimum_size = Vector2(74, 74) + avatar.add_theme_stylebox_override("panel", Brand.icon_circle_box(_dark(), Brand.PURPLE, 37, true)) + var avatar_icon := TextureRect.new() + avatar_icon.texture = load("res://assets/icons/lucide/user-round.svg") + avatar_icon.custom_minimum_size = Vector2(34, 34) + avatar_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + avatar_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + avatar.add_child(avatar_icon) + row.add_child(avatar) + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + profile_name = _label("Loading profile...", 22, Brand.text_color(_dark()), true) + copy.add_child(profile_name) + profile_identity = _label("", 14, Brand.muted_color(_dark())) + copy.add_child(profile_identity) + profile_meta = _label("", 13, Brand.muted_color(_dark())) + copy.add_child(profile_meta) + row.add_child(copy) + var edit := _button("Edit profile", "pencil", false) + edit.size_flags_vertical = Control.SIZE_SHRINK_CENTER + edit.pressed.connect(_open_profile_editor) + row.add_child(edit) + body.add_child(row) + return _card(body) + + +func _study_identity_card() -> PanelContainer: + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 12) + var header := _header_with_action("Study Identity", "Your preparation plan", "Edit", _open_study_editor) + body.add_child(header) + var grid := GridContainer.new() + grid.columns = 2 + grid.add_theme_constant_override("h_separation", 20) + grid.add_theme_constant_override("v_separation", 12) + for item in [ + ["exam", "Target exam date"], ["stage", "Current stage"], + ["goal", "Weekly study goal"], ["mode", "Preferred study mode"] + ]: + var box := VBoxContainer.new() + box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + box.add_child(_label(item[1], 12, Brand.muted_color(_dark()))) + var value := _label("--", 15, Brand.text_color(_dark()), true) + identity_values[item[0]] = value + box.add_child(value) + grid.add_child(box) + body.add_child(grid) + return _card(body) + + +func _performance_card() -> PanelContainer: + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 12) + body.add_child(_section_header("Performance Summary", "Your cumulative MedMind activity")) + var grid := GridContainer.new() + grid.columns = 3 + grid.add_theme_constant_override("h_separation", 10) + grid.add_theme_constant_override("v_separation", 10) + for item in [ + ["qbank", "Question Bank rating", Brand.ROYAL_BLUE], ["ccs", "CCS rating", Brand.PURPLE], + ["accuracy", "First-attempt accuracy", Brand.TEAL], ["questions", "Questions completed", Brand.ROYAL_BLUE], + ["cases", "CCS cases completed", Brand.PURPLE], ["streak", "Current streak", Brand.AMBER] + ]: + var metric := VBoxContainer.new() + metric.size_flags_horizontal = Control.SIZE_EXPAND_FILL + metric.add_child(_label(item[1], 11, Brand.muted_color(_dark()))) + var value := _label("--", 23, item[2], true) + metric_values[item[0]] = value + metric.add_child(value) + grid.add_child(metric) + body.add_child(grid) + return _card(body) + + +func _systems_card() -> PanelContainer: + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 10) + var header := _header_with_action("System Performance", "Accuracy by Step 3 system", "View analytics", func(): main.show_screen("dashboard")) + body.add_child(header) + systems_list = VBoxContainer.new() + systems_list.add_theme_constant_override("separation", 8) + body.add_child(systems_list) + return _card(body) + + +func _outcome_card() -> PanelContainer: + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 10) + body.add_child(_section_header("Official Step 3 Outcome", "Optionally help calibrate future predictions")) outcome_score = SpinBox.new() outcome_score.min_value = 1 outcome_score.max_value = 300 outcome_score.value = 200 - outcome_score.custom_minimum_size = Vector2(120, 44) - outcome_score.size_flags_horizontal = Control.SIZE_EXPAND_FILL - outcome_grid.add_child(outcome_score) + outcome_score.custom_minimum_size.y = 44 + body.add_child(_field("Official score", outcome_score)) outcome_date = LineEdit.new() - outcome_date.placeholder_text = "Exam date: YYYY-MM-DD" - outcome_date.custom_minimum_size = Vector2(220, 44) - outcome_date.size_flags_horizontal = Control.SIZE_EXPAND_FILL - outcome_grid.add_child(outcome_date) + outcome_date.placeholder_text = "YYYY-MM-DD" + _style_field(outcome_date) + body.add_child(_field("Exam date", outcome_date)) outcome_consent = CheckBox.new() - outcome_consent.text = "I consent to calibration use" - outcome_consent.size_flags_horizontal = Control.SIZE_EXPAND_FILL - outcome_grid.add_child(outcome_consent) - var actions := HBoxContainer.new() - actions.add_theme_constant_override("separation", 8) - outcome_grid.add_child(actions) - var save_button := Button.new() - save_button.text = "Save outcome" - Style.style_chip(save_button, Style.CYAN) - save_button.pressed.connect(_save_outcome) - actions.add_child(save_button) - outcome_delete_button = Button.new() - outcome_delete_button.text = "Delete outcome" - Style.style_chip(outcome_delete_button, Style.RED) - outcome_delete_button.pressed.connect(_delete_outcome) - actions.add_child(outcome_delete_button) - outcome_status = Label.new() + outcome_consent.text = "I consent to de-identified calibration use" + body.add_child(outcome_consent) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + var save := _button("Save outcome", "circle-check", true) + save.size_flags_horizontal = Control.SIZE_EXPAND_FILL + save.pressed.connect(_save_outcome) + row.add_child(save) + outcome_delete = _button("Delete", "x", false) + outcome_delete.pressed.connect(ExamSimulationService.delete_outcome) + row.add_child(outcome_delete) + body.add_child(row) + outcome_status = _label("No official outcome is stored.", 12, Brand.muted_color(_dark())) outcome_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(outcome_status, 15, Style.MUTED) - section.add_child(outcome_status) + body.add_child(outcome_status) + return _card(body) -func _refresh_outcome_layout() -> void: - if outcome_grid != null: - outcome_grid.columns = 1 if size.x < 900.0 else 4 +func _account_actions_card() -> PanelContainer: + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 9) + body.add_child(_section_header("Account Actions", "Security and data controls")) + var password := _action_button("Change password", "settings") + password.pressed.connect(_open_password_dialog) + body.add_child(password) + var export := _action_button("Export my data", "copy") + export.pressed.connect(func(): AccountProfileService.export_archive()) + body.add_child(export) + var delete := _action_button("Delete account permanently", "x", Brand.MAGENTA) + delete.pressed.connect(_open_delete_dialog) + body.add_child(delete) + return _card(body) + + +func _render() -> void: + if profile_name == null: + return + var account: Dictionary = AccountProfileService.account + var prefs: Dictionary = AccountProfileService.preferences + var username := str(account.get("username", AuthState.username)) + var display_name := str(account.get("displayName", "")).strip_edges() + profile_name.text = username if display_name.is_empty() else display_name + var email := str(account.get("email", "")).strip_edges() + profile_identity.text = "@%s" % username if email.is_empty() else "@%s | %s" % [username, email] + var role := str(account.get("role", AuthState.role)).capitalize() + var created := _date_only(str(account.get("createdUtc", ""))) + profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created] + (identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled") + (identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated"))) + (identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10)) + (identity_values["mode"] as Label).text = _friendly(str(prefs.get("preferredStudyMode", "mixed"))) + var data: Dictionary = DashboardService.data + _set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo)))) + _set_metric("ccs", str(int(data.get("ccsRating", CCSService.rating.get("elo", 1000))))) + _set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0))) + _set_metric("questions", str(int(data.get("questionsCompleted", 0)))) + _set_metric("cases", str(int(data.get("ccsCasesCompleted", 0)))) + _set_metric("streak", "%d days" % int(data.get("currentStreak", 0))) + _render_systems(data.get("systems", [])) + _render_outcome() + + +func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void: + if systems_list == null: + return + _clear(systems_list) + if systems.is_empty(): + systems_list.add_child(_label("Complete questions to build your system breakdown.", 13, Brand.muted_color(_dark()))) + return + var sorted := systems.duplicate(true) + sorted.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("total", 0)) > int(b.get("total", 0))) + for item in sorted.slice(0, mini(8, sorted.size())): + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + var title := _label(str(item.get("system", "General")), 13, Brand.text_color(_dark())) + title.custom_minimum_size.x = 210 + title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + row.add_child(title) + var bar := ProgressBar.new() + bar.show_percentage = false + bar.value = int(item.get("accuracy", 0)) + bar.custom_minimum_size = Vector2(160, 7) + bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _style_progress(bar, Brand.ROYAL_BLUE) + row.add_child(bar) + var detail := _label("%d%% %d / %d" % [int(item.get("accuracy", 0)), int(item.get("correct", 0)), int(item.get("total", 0))], 12, Brand.muted_color(_dark())) + detail.custom_minimum_size.x = 110 + row.add_child(detail) + systems_list.add_child(row) + + +func _render_outcome() -> void: + if outcome_status == null: + return + var outcome: Dictionary = ExamSimulationService.outcome + if outcome.is_empty(): + outcome_status.text = "No official outcome is stored." + outcome_delete.visible = false + return + outcome_score.value = int(outcome.get("officialScore", 200)) + outcome_date.text = _date_only(str(outcome.get("examDate", ""))) + outcome_consent.button_pressed = true + outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text] + outcome_delete.visible = true + + +func _open_profile_editor() -> void: + var dialog := _dialog("Edit profile") + var display := LineEdit.new() + display.placeholder_text = "Display name" + display.text = str(AccountProfileService.account.get("displayName", "")) + _style_field(display) + dialog.add_field(display) + var email := LineEdit.new() + email.placeholder_text = "Email address" + email.text = str(AccountProfileService.account.get("email", "")) + _style_field(email) + dialog.add_field(email) + dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text)) + add_child(dialog) + dialog.popup_centered(Vector2i(520, 260)) + + +func _open_study_editor() -> void: + var prefs: Dictionary = AccountProfileService.preferences + var dialog := _dialog("Edit study identity") + var exam := LineEdit.new() + exam.placeholder_text = "Target exam date: YYYY-MM-DD" + exam.text = _date_only(str(prefs.get("targetExamDate", ""))) + _style_field(exam) + dialog.add_field(exam) + var stage := OptionButton.new() + _add_options(stage, [["Pre-dedicated", "pre_dedicated"], ["Dedicated", "dedicated"], ["Final review", "final_review"]], str(prefs.get("preparationStage", "pre_dedicated"))) + dialog.add_field(stage) + var goal := OptionButton.new() + for hours in [5, 10, 15, 20, 25]: goal.add_item("%d hours per week" % hours, hours) + _select_id(goal, int(prefs.get("weeklyGoalHours", 10))) + dialog.add_field(goal) + var mode := OptionButton.new() + _add_options(mode, [["Mixed", "mixed"], ["Question Bank", "question_bank"], ["CCS", "ccs"], ["Exam simulation", "exam_simulation"]], str(prefs.get("preferredStudyMode", "mixed"))) + dialog.add_field(mode) + dialog.confirmed.connect(func(): + AccountProfileService.update_preferences(exam.text, str(stage.get_item_metadata(stage.selected)), goal.get_selected_id(), str(mode.get_item_metadata(mode.selected)))) + add_child(dialog) + dialog.popup_centered(Vector2i(540, 390)) + + +func _open_password_dialog() -> void: + var dialog := _dialog("Change password") + var current := _password_field("Current password") + var replacement := _password_field("New password (8+ characters)") + var confirmation := _password_field("Confirm new password") + dialog.add_field(current) + dialog.add_field(replacement) + dialog.add_field(confirmation) + dialog.confirmed.connect(func(): + if replacement.text != confirmation.text or replacement.text.length() < 8: + _show_error("New passwords must match and contain at least 8 characters.") + return + AccountProfileService.change_password(current.text, replacement.text)) + add_child(dialog) + dialog.popup_centered(Vector2i(520, 330)) + + +func _open_delete_dialog() -> void: + var dialog := _dialog("Permanently delete account") + var warning := _label("This permanently removes your account and all MedMind study data. This cannot be undone.", 13, Brand.MAGENTA) + warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + dialog.add_field(warning) + var password := _password_field("Current password") + dialog.add_field(password) + var confirmation := LineEdit.new() + confirmation.placeholder_text = "Type DELETE" + _style_field(confirmation) + dialog.add_field(confirmation) + dialog.confirmed.connect(func(): AccountProfileService.delete_account(password.text, confirmation.text)) + add_child(dialog) + dialog.popup_centered(Vector2i(540, 330)) + + +func _save_outcome() -> void: + var error := validate_outcome(int(outcome_score.value), outcome_date.text, outcome_consent.button_pressed) + if not error.is_empty(): + _show_error(error) + return + ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges()) static func validate_outcome(score: int, exam_date: String, consent: bool) -> String: if score < 1 or score > 300: return "Enter an official score from 1 through 300." + if RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}$").search(exam_date.strip_edges()) == null: + return "Enter the exam date as YYYY-MM-DD." if not consent: return "Consent is required before saving an outcome." - var date_pattern := RegEx.new() - date_pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") - if date_pattern.search(exam_date) == null: - return "Enter the exam date as YYYY-MM-DD." return "" -func _save_outcome() -> void: - var validation_error := validate_outcome(int(outcome_score.value), outcome_date.text.strip_edges(), outcome_consent.button_pressed) - if not validation_error.is_empty(): - outcome_status.text = validation_error - return - outcome_status.text = "Saving..." - ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges()) +func _update_responsive() -> void: + if columns != null: + columns.columns = 1 if size.x < 1050.0 else 2 -func _delete_outcome() -> void: - outcome_status.text = "Deleting..." - ExamSimulationService.delete_outcome() +func _rebuild(_is_dark: bool) -> void: + _build() -func _refresh_outcome() -> void: - if ExamSimulationService.outcome.is_empty(): - outcome_score.value = 200 - outcome_date.text = "" - outcome_consent.button_pressed = false - outcome_delete_button.visible = false - outcome_status.text = "No official outcome is stored." - return - outcome_score.value = int(ExamSimulationService.outcome.get("officialScore", 200)) - outcome_date.text = str(ExamSimulationService.outcome.get("examDate", "")).left(10) - outcome_consent.button_pressed = true - outcome_delete_button.visible = true - outcome_status.text = "Official outcome saved." +func _show_error(message: String) -> void: + status_label.text = message + status_label.add_theme_color_override("font_color", Brand.MAGENTA) + status_label.visible = true -func _on_outcome_request_failed(message: String) -> void: - if visible: - outcome_status.text = message +func _show_saved(message: String) -> void: + status_label.text = message + status_label.add_theme_color_override("font_color", Brand.SUCCESS) + status_label.visible = true + get_tree().create_timer(3.0).timeout.connect(func(): if is_instance_valid(status_label): status_label.visible = false) -func _on_reset_qbank_pressed() -> void: - if not reset_qbank_pending: - reset_qbank_pending = true - reset_qbank_button.text = "Confirm Reset QBank" - Style.style_chip(reset_qbank_button, Style.RED) - return - - ProfileService.reset_qbank_memory() - if BlockSessionService.core_status_message == "All available questions cleared.": - BlockSessionService.core_status_message = "" - _reset_qbank_confirmation() - on_show() +func _card(child: Control) -> PanelContainer: + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var box := Brand.content_card_box(_dark(), false) + box.content_margin_left = 20 + box.content_margin_right = 20 + box.content_margin_top = 18 + box.content_margin_bottom = 18 + panel.add_theme_stylebox_override("panel", box) + panel.add_child(child) + return panel -func _reset_qbank_confirmation() -> void: - reset_qbank_pending = false - if reset_qbank_button != null: - reset_qbank_button.text = "Reset QBank" - Style.style_chip(reset_qbank_button, Style.AMBER) +func _section_header(title: String, subtitle: String) -> VBoxContainer: + var box := VBoxContainer.new() + box.add_child(_label(title, 18, Brand.text_color(_dark()), true)) + box.add_child(_label(subtitle, 12, Brand.muted_color(_dark()))) + return box + + +func _header_with_action(title: String, subtitle: String, action: String, callback: Callable) -> HBoxContainer: + var row := HBoxContainer.new() + var heading := _section_header(title, subtitle) + heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(heading) + var button := _button(action, "pencil", false) + button.pressed.connect(callback) + row.add_child(button) + return row + + +func _button(text: String, icon_name: String = "", primary: bool = false) -> Button: + var button := Button.new() + button.text = text + button.custom_minimum_size = Vector2(0, 42) + button.add_theme_font_override("font", Brand.font) + button.add_theme_font_size_override("font_size", 13) + if not icon_name.is_empty(): + button.icon = load("res://assets/icons/lucide/%s.svg" % icon_name) + button.add_theme_constant_override("icon_max_width", 17) + if primary: + button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + button.add_theme_color_override("font_color", Color.WHITE) + else: + button.add_theme_stylebox_override("normal", Brand.secondary_button_box(_dark())) + return button + + +func _action_button(text: String, icon_name: String, color: Color = Color.TRANSPARENT) -> Button: + var button := _button(text, icon_name, false) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + if color != Color.TRANSPARENT: + button.add_theme_color_override("font_color", color) + button.add_theme_color_override("icon_normal_color", color) + return button + + +func _label(text: String, font_size: int, color: Color, _bold: bool = false) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_override("font", Brand.font) + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", color) + return label + + +func _field(caption: String, control: Control) -> VBoxContainer: + var box := VBoxContainer.new() + box.add_child(_label(caption, 12, Brand.muted_color(_dark()))) + box.add_child(control) + return box + + +func _dialog(title: String) -> ProfileDialog: + var dialog := ProfileDialog.new() + dialog.title = title + dialog.ok_button_text = "Save" + dialog.add_theme_font_override("font", Brand.font) + dialog.fields.custom_minimum_size.x = 450 + return dialog + + +func _password_field(placeholder: String) -> LineEdit: + var field := LineEdit.new() + field.placeholder_text = placeholder + field.secret = true + field.secret_character = "•" + _style_field(field) + return field + + +func _style_field(field: LineEdit) -> void: + field.custom_minimum_size.y = 46 + field.add_theme_font_override("font", Brand.font) + field.add_theme_stylebox_override("normal", Brand.field_box(_dark())) + field.add_theme_stylebox_override("focus", Brand.field_box(_dark(), true)) + + +func _style_progress(bar: ProgressBar, accent: Color) -> void: + var background := StyleBoxFlat.new() + background.bg_color = Color("26324a") if _dark() else Color("e2e8f0") + background.set_corner_radius_all(4) + var fill := StyleBoxFlat.new() + fill.bg_color = accent + fill.set_corner_radius_all(4) + bar.add_theme_stylebox_override("background", background) + bar.add_theme_stylebox_override("fill", fill) + + +func _set_metric(key: String, value: String) -> void: + if metric_values.has(key): (metric_values[key] as Label).text = value + + +func _clear(container: Container) -> void: + for child in container.get_children(): + container.remove_child(child) + child.free() + + +func _date_only(value: String, fallback: String = "") -> String: + if value.is_empty() or value == "": return fallback + return value.left(10) + + +func _friendly(value: String) -> String: + return value.replace("_", " ").capitalize() + + +func _dark() -> bool: + return UIThemeService.is_dark_mode() + + +func _add_options(control: OptionButton, options: Array, selected_value: String) -> void: + for option in options: + control.add_item(str(option[0])) + control.set_item_metadata(control.item_count - 1, option[1]) + if str(option[1]) == selected_value: control.select(control.item_count - 1) + + +func _select_id(control: OptionButton, id: int) -> void: + for index in control.item_count: + if control.get_item_id(index) == id: control.select(index) + + +class ProfileDialog extends AcceptDialog: + var fields: VBoxContainer + func _init() -> void: + fields = VBoxContainer.new() + fields.add_theme_constant_override("separation", 10) + add_child(fields) + func add_field(node: Control) -> void: + fields.add_child(node) diff --git a/scripts/ui/simulation_history.gd b/scripts/ui/simulation_history.gd index 6b857dd..48a733a 100644 --- a/scripts/ui/simulation_history.gd +++ b/scripts/ui/simulation_history.gd @@ -7,7 +7,6 @@ const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-inc var main: Control var page: VBoxContainer -var title_label: Label var active_section: PanelContainer var active_content: VBoxContainer var history_section: PanelContainer @@ -51,12 +50,6 @@ func _build_ui() -> void: page.add_theme_constant_override("separation", 18) scroll.add_child(page) - title_label = Label.new() - title_label.text = "Exams" - Brand.apply_font(title_label) - title_label.add_theme_font_size_override("font_size", 27) - page.add_child(title_label) - active_section = PanelContainer.new() active_section.name = "ActiveSimulationSection" page.add_child(active_section) @@ -161,7 +154,6 @@ func refresh_theme_controls() -> void: if page == null: return var dark := UIThemeService.is_dark_mode() - title_label.add_theme_color_override("font_color", Brand.text_color(dark)) active_section.add_theme_stylebox_override("panel", _section_box(dark)) history_section.add_theme_stylebox_override("panel", _section_box(dark)) status_label.add_theme_color_override("font_color", Brand.muted_color(dark)) diff --git a/scripts/ui/ui_style.gd b/scripts/ui/ui_style.gd index 4ca2033..fe45ceb 100644 --- a/scripts/ui/ui_style.gd +++ b/scripts/ui/ui_style.gd @@ -148,46 +148,53 @@ static func style_chip(button: Button, color: Color) -> void: static func refresh_tree(root: Control, was_dark: bool) -> void: apply_app_theme(root) - _translate_control(root, DARK_PALETTE if was_dark else LIGHT_PALETTE, LIGHT_PALETTE if was_dark else DARK_PALETTE) + _translate_control( + root, + DARK_PALETTE if was_dark else LIGHT_PALETTE, + LIGHT_PALETTE if was_dark else DARK_PALETTE, + was_dark + ) -static func _translate_control(control: Control, source: Dictionary, target: Dictionary) -> void: +static func _translate_control(control: Control, source: Dictionary, target: Dictionary, from_dark: bool) -> void: if control is ColorRect: - control.color = _translate_color(control.color, source, target) + control.color = _translate_color(control.color, source, target, from_dark) for color_name in ["font_color", "font_hover_color", "font_pressed_color", "font_disabled_color", "font_selected_color", "default_color", "font_uneditable_color"]: if control.has_theme_color_override(color_name): - control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target)) + control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target, from_dark)) for style_name in ["panel", "normal", "hover", "pressed", "disabled", "focus", "fill", "background", "read_only"]: if control.has_theme_stylebox_override(style_name): var box := control.get_theme_stylebox(style_name) if box is StyleBoxFlat: var translated := box.duplicate() as StyleBoxFlat - translated.bg_color = _translate_color(translated.bg_color, source, target) - translated.border_color = _translate_color(translated.border_color, source, target) + translated.bg_color = _translate_color(translated.bg_color, source, target, from_dark) + translated.border_color = _translate_color(translated.border_color, source, target, from_dark) control.add_theme_stylebox_override(style_name, translated) for child in control.get_children(): if child is Control: - _translate_control(child, source, target) + _translate_control(child, source, target, from_dark) -static func _translate_color(color: Color, source: Dictionary, target: Dictionary) -> Color: +static func _translate_color(color: Color, source: Dictionary, target: Dictionary, from_dark: bool) -> Color: for key in source: var candidate: Color = source[key] if color.is_equal_approx(candidate): var replacement: Color = target[key] replacement.a = color.a return replacement - for dark_color in CUSTOM_DARK_TO_LIGHT: - if color.is_equal_approx(dark_color): - var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color] - replacement.a = color.a - return replacement - for dark_color in CUSTOM_DARK_TO_LIGHT: - var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color] - if color.is_equal_approx(light_color): - var replacement: Color = dark_color - replacement.a = color.a - return replacement + if from_dark: + for dark_color in CUSTOM_DARK_TO_LIGHT: + if color.is_equal_approx(dark_color): + var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color] + replacement.a = color.a + return replacement + else: + for dark_color in CUSTOM_DARK_TO_LIGHT: + var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color] + if color.is_equal_approx(light_color): + var replacement: Color = dark_color + replacement.a = color.a + return replacement return color diff --git a/tests/ui_theme_test.gd b/tests/ui_theme_test.gd index defba59..8512237 100644 --- a/tests/ui_theme_test.gd +++ b/tests/ui_theme_test.gd @@ -28,6 +28,12 @@ func _initialize() -> void: _assert(light_card.border_color.is_equal_approx(Brand.ROYAL_BLUE), "active light cards should use the primary blue border") var light_field := Brand.field_box(false) _assert(light_field.border_color.is_equal_approx(Brand.LIGHT_BORDER), "light fields should use the semantic cool-gray border") + var explicitly_light_panel := PanelContainer.new() + explicitly_light_panel.add_theme_stylebox_override("panel", Brand.content_card_box(false, false)) + root.add_child(explicitly_light_panel) + Style.refresh_tree(root, true) + var preserved_light_box := explicitly_light_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(preserved_light_box.bg_color.is_equal_approx(Color.WHITE), "a screen's explicit light styles should not be translated back to dark") var theme_toggle := Button.new() Brand.set_theme_toggle_icon(theme_toggle, true) _assert(theme_toggle.text.is_empty() and theme_toggle.icon == Brand.SUN_ICON, "dark mode should use the bundled sun icon") @@ -50,6 +56,11 @@ func _initialize() -> void: var auth_field: LineEdit = login._add_field(field_parent, "Email", "name@example.com") _assert(auth_field.caret_blink, "authentication fields should display a blinking caret") _assert(is_equal_approx(auth_field.caret_blink_interval, 0.5), "authentication field caret should blink at a visible interval") + login._style_auth_field(auth_field, false) + var editable_box := auth_field.get_theme_stylebox("normal") as StyleBoxFlat + var read_only_box := auth_field.get_theme_stylebox("read_only") as StyleBoxFlat + _assert(read_only_box.bg_color.is_equal_approx(editable_box.bg_color), "authentication fields should retain their surface while a request is pending") + _assert(is_equal_approx(read_only_box.content_margin_left, editable_box.content_margin_left), "authentication fields should retain their text inset while a request is pending") login.free() theme_toggle.free() diff --git a/tests/web_responsive_test.gd b/tests/web_responsive_test.gd index 310e834..40623e2 100644 --- a/tests/web_responsive_test.gd +++ b/tests/web_responsive_test.gd @@ -72,6 +72,27 @@ func _run() -> void: var question_bank_nav := main_screen.sidebar.find_child("NavQuestionBank", true, false) as Button _assert(question_bank_nav != null and question_bank_nav.text == "Question Bank", "the Practice navigation item should be replaced by Question Bank") _assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item") + var profile_nav := main_screen.sidebar.find_child("NavPerformance", true, false) as Button + var settings_nav := main_screen.sidebar.find_child("NavSettings", true, false) as Button + _assert(profile_nav != null and settings_nav != null and profile_nav.get_index() + 1 == settings_nav.get_index(), "Profile should sit directly above Settings in the lower navigation group") + var theme_service = root.get_node("UIThemeService") + var prior_dark_mode: bool = theme_service.is_dark_mode() + theme_service.set_dark_mode(true) + await process_frame + theme_service.set_dark_mode(false) + await process_frame + var dashboard = main_screen.screens.get("dashboard") + var dashboard_card := dashboard.metric_grid.get_parent() as PanelContainer + var dashboard_box := dashboard_card.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dashboard_box.bg_color.is_equal_approx(Color.WHITE), "dashboard cards should remain light after the shell refresh") + var simulations = main_screen.screens.get("simulation_history") + var simulation_box := simulations.active_section.get_theme_stylebox("panel") as StyleBoxFlat + _assert(simulation_box.bg_color.is_equal_approx(simulations._section_box(false).bg_color), "exam simulation panels should remain light after the shell refresh") + var notes_screen = main_screen.screens.get("notes") + var notes_box := notes_screen.notes_editor.editor.get_theme_stylebox("normal") as StyleBoxFlat + _assert(notes_box.bg_color.is_equal_approx(Color.WHITE), "the Notes editor should remain light after the shell refresh") + theme_service.set_dark_mode(prior_dark_mode) + await process_frame host.queue_free() await process_frame