From 4dfbd811b9755c87eded72d52ac134b8a26ed4cb Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 21 Jul 2026 16:41:26 -0500 Subject: [PATCH] Wire flashcard CSV/JSON import/export and fix theme/responsive gaps Import and export were stubbed with status-text placeholders; wire them to the existing backend endpoints with a real file picker (web JS bridge or native FileDialog) and browser/desktop download, plus an import preview with create/update/skip/error counts before commit. Also fix the library and study screens' theme-refresh handlers, which were no-op stubs so cards never restyled on a theme toggle, and add responsive layout handling for narrow viewports. --- docs/FLASHCARDS.md | 8 + scripts/autoload/flashcard_service.gd | 161 ++++++++++++++++ scripts/ui/flashcards_hub.gd | 229 +++++++++++++++++++++-- scripts/ui/flashcards_study.gd | 19 +- tests/flashcard_theme_responsive_test.gd | 115 ++++++++++++ 5 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 tests/flashcard_theme_responsive_test.gd diff --git a/docs/FLASHCARDS.md b/docs/FLASHCARDS.md index 09f71d9..7eef28a 100644 --- a/docs/FLASHCARDS.md +++ b/docs/FLASHCARDS.md @@ -75,6 +75,14 @@ create/update/skip/error preview (`FlashcardImportPreviews`, TTL-expired after a skipped as an exact-content duplicate, or reported as a row-level error. Limits: 25 MB and 10,000 notes per import. +The Godot client (`scripts/autoload/flashcard_service.gd`) picks a file, uploads it to the +matching preview endpoint as `multipart/form-data`, and shows the create/update/skip/error counts +before letting the user commit or cancel. Export streams the response body and saves it client +side. File selection and download both branch on `OS.has_feature("web")`: on web, a hidden +``/`FileReader` pair (bridged back into GDScript via +`JavaScriptBridge.create_callback`) and an anchor-click data-URL download stand in for a native +picker/save dialog; on desktop, a `FileDialog` and the OS downloads folder are used instead. + ## Deployment No new secrets. `FlashcardsController`/stores register the same way as the rest of the API diff --git a/scripts/autoload/flashcard_service.gd b/scripts/autoload/flashcard_service.gd index 445bbca..ad78fb6 100644 --- a/scripts/autoload/flashcard_service.gd +++ b/scripts/autoload/flashcard_service.gd @@ -19,6 +19,9 @@ signal review_resumed signal notes_loaded(response: Dictionary) signal note_saved(note: Dictionary) signal request_failed(tag: String, message: String) +signal export_ready(location: String) +signal import_preview_ready(preview: Dictionary) +signal import_committed(result: Dictionary) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_API_URL := "https://us3q.ranaze.com/api/flashcards" @@ -28,6 +31,8 @@ var api_url := DEFAULT_API_URL var next_request: HTTPRequest var review_request: HTTPRequest var misc_request: HTTPRequest +var export_request: HTTPRequest +var import_request: HTTPRequest var retry_timer: Timer var loading_next := false var submitting_review := false @@ -36,6 +41,9 @@ var misc_queue: Array = [] var outbox: Array = [] var active_account_key := "" var is_paused := false +var export_busy := false +var export_pending: Dictionary = {} +var import_busy := false func _ready() -> void: @@ -45,6 +53,8 @@ func _ready() -> void: next_request = _make_request(_on_next_completed) review_request = _make_request(_on_review_completed) misc_request = _make_request(_on_misc_completed) + export_request = _make_request(_on_export_completed) + import_request = _make_request(_on_import_preview_completed) retry_timer = Timer.new() retry_timer.one_shot = true retry_timer.wait_time = 5.0 @@ -271,6 +281,157 @@ func undo_last_review(review_log_id: String, on_done: Callable) -> void: func(parsed): on_done.call(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "undo") +# ---------- Export (browser download / desktop file save) ---------- + +func export_csv() -> void: + _download_export("csv", "text/csv", "flashcards.csv") + + +func export_json() -> void: + _download_export("json", "application/json", "flashcards.json") + + +func _download_export(format: String, mime: String, filename: String) -> void: + if not AuthState.can_send_authenticated_request(): + request_failed.emit("export", "Login is required to export flashcards.") + return + if export_busy: + request_failed.emit("export", "An export is already in progress.") + return + export_busy = true + export_pending = {"mime": mime, "filename": filename} + var error := export_request.request("%s/export/%s" % [api_url, format], _headers(), HTTPClient.METHOD_GET) + if error != OK: + export_busy = false + export_pending = {} + request_failed.emit("export", "The export could not be started. Check your connection and try again.") + + +func _on_export_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + export_busy = false + var pending := export_pending + export_pending = {} + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit("export", "The export failed. Check your connection and try again.") + return + _save_export_file(body.get_string_from_utf8(), str(pending.get("filename", "flashcards.txt")), str(pending.get("mime", "text/plain"))) + + +func _save_export_file(content: String, filename: String, mime: String) -> void: + 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:%s;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [mime, encoded, JSON.stringify(filename)], + true) + export_ready.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("export", "The export could not be saved.") + return + file.store_string(content) + export_ready.emit(path) + + +# ---------- Import (file picker -> preview -> commit) ---------- + +func pick_import_file(format: String) -> void: + if not AuthState.can_send_authenticated_request(): + request_failed.emit("import", "Login is required to import flashcards.") + return + if import_busy: + request_failed.emit("import", "An import is already in progress.") + return + var accept := ".csv" if format == "csv" else ".json" + if OS.has_feature("web"): + _pick_file_web(accept, format) + else: + _pick_file_native(accept, format) + + +func _pick_file_web(accept: String, format: String) -> void: + var callback := JavaScriptBridge.create_callback(_on_web_file_selected.bind(format)) + JavaScriptBridge.eval( + ("(function(cb){" + + "var input=document.createElement('input');" + + "input.type='file';input.accept=%s;input.style.display='none';" + + "document.body.appendChild(input);" + + "input.addEventListener('change',function(){" + + "var file=input.files&&input.files[0];" + + "document.body.removeChild(input);" + + "if(!file){cb('','');return;}" + + "var reader=new FileReader();" + + "reader.onload=function(){cb(reader.result,file.name);};" + + "reader.onerror=function(){cb('','');};" + + "reader.readAsText(file);" + + "});" + + "input.click();" + + "})(%s);") % [JSON.stringify(accept), callback], + true) + + +func _on_web_file_selected(text: String, filename: String, format: String) -> void: + if text.is_empty(): + return + _upload_import_preview(text, filename, format) + + +func _pick_file_native(accept: String, format: String) -> void: + var dialog := FileDialog.new() + dialog.access = FileDialog.ACCESS_FILESYSTEM + dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + dialog.filters = PackedStringArray(["*%s ; %s files" % [accept, format.to_upper()]]) + add_child(dialog) + dialog.file_selected.connect(func(path: String): + var text := FileAccess.get_file_as_string(path) + dialog.queue_free() + _upload_import_preview(text, path.get_file(), format)) + dialog.canceled.connect(func(): dialog.queue_free()) + dialog.popup_centered_ratio(0.6) + + +func _upload_import_preview(text: String, filename: String, format: String) -> void: + import_busy = true + var boundary := "----GodotFormBoundary%d" % Time.get_ticks_usec() + var content_type := "text/csv" if format == "csv" else "application/json" + var body := "--%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n%s\r\n--%s--\r\n" % [ + boundary, filename, content_type, text, boundary] + var headers := PackedStringArray([ + "Content-Type: multipart/form-data; boundary=%s" % boundary, + "Authorization: Bearer %s" % AuthState.access_token + ]) + var error := import_request.request( + "%s/import/%s/preview" % [api_url, format], headers, HTTPClient.METHOD_POST, body) + if error != OK: + import_busy = false + request_failed.emit("import", "The file could not be uploaded. Check your connection and try again.") + + +func _on_import_preview_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + import_busy = false + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit("import", "The file could not be previewed. Check that it matches the expected format and try again.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + request_failed.emit("import", "The import preview response was invalid.") + return + import_preview_ready.emit(parsed) + + +func commit_import(import_token: String) -> void: + _enqueue_json("POST", "/import/commit", JSON.stringify({"importToken": import_token}), + func(parsed): import_committed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "import_commit") + + func _enqueue_json(method: String, path: String, body: String, on_success: Callable, tag: String) -> void: misc_queue.append({"method": method, "path": path, "body": body, "on_success": on_success, "tag": tag}) _pump_misc_queue() diff --git a/scripts/ui/flashcards_hub.gd b/scripts/ui/flashcards_hub.gd index 2e65e4d..cb31f27 100644 --- a/scripts/ui/flashcards_hub.gd +++ b/scripts/ui/flashcards_hub.gd @@ -22,6 +22,7 @@ var list_container: VBoxContainer var debounce_timer: Timer var editor_overlay: Control +var editor_panel: PanelContainer var editor_note_id := "" var editor_expected_revision: Variant = null var editor_type_selector: OptionButton @@ -32,6 +33,18 @@ var editor_tags_input: LineEdit var editor_hint_label: Label var editor_error_label: Label +var export_menu: PanelContainer +var import_menu: PanelContainer + +var import_overlay: Control +var import_panel: PanelContainer +var import_summary_label: Label +var import_rows_container: VBoxContainer +var import_commit_button: Button +var import_pending_token := "" + +var compact_layout := false + var decks: Array = [] var notes: Array = [] @@ -58,11 +71,17 @@ func _ready() -> void: debounce_timer.timeout.connect(_reload_notes) add_child(debounce_timer) _build_editor_overlay() + _build_import_overlay() + resized.connect(_update_responsive_layout) + call_deferred("_update_responsive_layout") FlashcardService.decks_changed.connect(_on_decks_changed) FlashcardService.notes_loaded.connect(_on_notes_loaded) FlashcardService.note_saved.connect(_on_note_saved) FlashcardService.request_failed.connect(_on_request_failed) + FlashcardService.export_ready.connect(_on_export_ready) + FlashcardService.import_preview_ready.connect(_on_import_preview_ready) + FlashcardService.import_committed.connect(_on_import_committed) UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status()) @@ -104,14 +123,60 @@ func _build_toolbar() -> void: import_button = Button.new() import_button.text = "Import" import_button.custom_minimum_size = Vector2(0, 44) - import_button.pressed.connect(_open_import_dialog) + import_button.pressed.connect(func(): _toggle_dropdown(import_menu, import_button)) row.add_child(import_button) + import_menu = _build_dropdown([ + ["Import CSV...", func(): + status_label.text = "Choose a file to import..." + FlashcardService.pick_import_file("csv")], + ["Import JSON...", func(): + status_label.text = "Choose a file to import..." + FlashcardService.pick_import_file("json")], + ]) export_button = Button.new() export_button.text = "Export" export_button.custom_minimum_size = Vector2(0, 44) - export_button.pressed.connect(_export_csv) + export_button.pressed.connect(func(): _toggle_dropdown(export_menu, export_button)) row.add_child(export_button) + export_menu = _build_dropdown([ + ["Export CSV", func(): + status_label.text = "Preparing export..." + FlashcardService.export_csv()], + ["Export JSON", func(): + status_label.text = "Preparing export..." + FlashcardService.export_json()], + ]) + + +func _build_dropdown(items: Array) -> PanelContainer: + var menu := PanelContainer.new() + menu.visible = false + menu.top_level = true + menu.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + add_child(menu) + var list := VBoxContainer.new() + list.add_theme_constant_override("separation", 2) + menu.add_child(list) + for item in items: + var button := Button.new() + button.text = str(item[0]) + button.custom_minimum_size = Vector2(180, 40) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + var action: Callable = item[1] + button.pressed.connect(func(): + menu.visible = false + action.call()) + list.add_child(button) + return menu + + +func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void: + if menu.visible: + menu.visible = false + return + menu.visible = true + menu.position = anchor.get_global_position() + Vector2(0, anchor.size.y + 4) func _build_filters() -> void: @@ -196,7 +261,8 @@ func _note_row(note: Dictionary) -> Control: var dark := UIThemeService.is_dark_mode() var card := PanelContainer.new() card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.ROYAL_BLUE)) - var row := HBoxContainer.new() + var row := BoxContainer.new() + row.vertical = compact_layout row.add_theme_constant_override("separation", 12) card.add_child(row) @@ -255,13 +321,13 @@ func _build_editor_overlay() -> void: var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) editor_overlay.add_child(center) - var panel := PanelContainer.new() - panel.custom_minimum_size = Vector2(620, 560) - panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) - center.add_child(panel) + editor_panel = PanelContainer.new() + editor_panel.custom_minimum_size = Vector2(620, 560) + editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + center.add_child(editor_panel) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 10) - panel.add_child(content) + editor_panel.add_child(content) var header := HBoxContainer.new() content.add_child(header) @@ -394,7 +460,9 @@ func _on_note_saved(_note: Dictionary) -> void: FlashcardService.fetch_decks() -func _on_request_failed(_tag: String, message: String) -> void: +func _on_request_failed(tag: String, message: String) -> void: + if tag == "import_commit": + import_commit_button.disabled = false editor_error_label.text = message editor_error_label.visible = true status_label.text = message @@ -408,15 +476,144 @@ func _start_study() -> void: main.show_screen("flashcards_study", _selected_deck_id()) -func _export_csv() -> void: - # CSV/JSON export downloads through the same authenticated HTTP endpoint the desktop/web - # build already uses for media; a full save-file picker is out of scope for this pass. - status_label.text = "Export requested. Check your downloads once the file streams." +func _on_export_ready(location: String) -> void: + status_label.text = "Exported to %s." % location -func _open_import_dialog() -> void: - status_label.text = "Use the CSV/JSON import screen to pick a file (upload UI pending)." +# ---------- Import preview / commit ---------- +func _build_import_overlay() -> void: + import_overlay = Control.new() + import_overlay.visible = false + import_overlay.top_level = true + import_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + import_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(import_overlay) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + import_overlay.add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + import_overlay.add_child(center) + import_panel = PanelContainer.new() + import_panel.custom_minimum_size = Vector2(620, 480) + import_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + center.add_child(import_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + import_panel.add_child(content) + + var header := HBoxContainer.new() + content.add_child(header) + var title := Label.new() + title.text = "Import preview" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_size_override("font_size", 22) + header.add_child(title) + var close_button := Button.new() + close_button.icon = preload("res://assets/icons/lucide/x.svg") + close_button.custom_minimum_size = Vector2(40, 40) + close_button.pressed.connect(_close_import_overlay) + header.add_child(close_button) + + import_summary_label = Label.new() + import_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(import_summary_label) + + var rows_scroll := ScrollContainer.new() + rows_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + content.add_child(rows_scroll) + import_rows_container = VBoxContainer.new() + import_rows_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + import_rows_container.add_theme_constant_override("separation", 4) + rows_scroll.add_child(import_rows_container) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 10) + content.add_child(actions) + var cancel_button := Button.new() + cancel_button.text = "Cancel" + cancel_button.custom_minimum_size = Vector2(0, 46) + cancel_button.pressed.connect(_close_import_overlay) + actions.add_child(cancel_button) + import_commit_button = Button.new() + import_commit_button.text = "Commit import" + import_commit_button.custom_minimum_size = Vector2(0, 46) + import_commit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + Style.style_primary_button(import_commit_button) + import_commit_button.pressed.connect(_commit_pending_import) + actions.add_child(import_commit_button) + + +func _on_import_preview_ready(preview: Dictionary) -> void: + import_pending_token = str(preview.get("importToken", "")) + var create_count := int(preview.get("createCount", 0)) + var update_count := int(preview.get("updateCount", 0)) + var skip_count := int(preview.get("skipCount", 0)) + var error_count := int(preview.get("errorCount", 0)) + import_summary_label.text = "%d to create, %d to update, %d to skip, %d row error(s)." % [create_count, update_count, skip_count, error_count] + for child in import_rows_container.get_children(): + child.queue_free() + for raw_row in preview.get("rows", []): + import_rows_container.add_child(_import_row(raw_row)) + import_commit_button.disabled = import_pending_token.is_empty() or (create_count + update_count == 0) + status_label.text = "" + import_overlay.visible = true + + +func _import_row(row: Dictionary) -> Control: + var label := Label.new() + var action := str(row.get("action", "")) + var error := str(row.get("error", "")) + var detail := error if not error.is_empty() else str(row.get("prompt", "")) + label.text = "Row %d - %s: %s" % [int(row.get("rowNumber", 0)), action.capitalize(), detail] + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + if action == "error": + label.add_theme_color_override("font_color", Style.RED) + return label + + +func _commit_pending_import() -> void: + if import_pending_token.is_empty(): + return + import_commit_button.disabled = true + FlashcardService.commit_import(import_pending_token) + + +func _on_import_committed(result: Dictionary) -> void: + import_overlay.visible = false + import_pending_token = "" + status_label.text = "Import complete: %d created, %d updated, %d skipped, %d error(s)." % [ + int(result.get("created", 0)), int(result.get("updated", 0)), int(result.get("skipped", 0)), int(result.get("errored", 0))] + _reload_notes() + FlashcardService.fetch_decks() + + +func _close_import_overlay() -> void: + import_overlay.visible = false + import_pending_token = "" + + +# ---------- Theme / responsive ---------- func _refresh_status() -> void: - pass + var dark := UIThemeService.is_dark_mode() + if editor_panel != null: + editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + if import_panel != null: + import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + if export_menu != null: + export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + if import_menu != null: + import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + _render_notes() + + +func _update_responsive_layout() -> void: + var use_compact := size.x < 720.0 + if compact_layout == use_compact: + return + compact_layout = use_compact + search_input.custom_minimum_size.x = 0.0 if compact_layout else 260.0 + _render_notes() diff --git a/scripts/ui/flashcards_study.gd b/scripts/ui/flashcards_study.gd index 0c98203..8922954 100644 --- a/scripts/ui/flashcards_study.gd +++ b/scripts/ui/flashcards_study.gd @@ -33,12 +33,15 @@ var grade_row: HBoxContainer var grade_buttons: Dictionary = {} var undo_button: Button var empty_label: Label +var compact_layout := false func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 14) _build() + resized.connect(_update_responsive_layout) + call_deferred("_update_responsive_layout") FlashcardService.next_card_loaded.connect(_on_next_card_loaded) FlashcardService.next_card_failed.connect(_on_next_card_failed) FlashcardService.review_synced.connect(_on_review_synced) @@ -98,6 +101,7 @@ func _build() -> void: card_panel = PanelContainer.new() card_panel.custom_minimum_size = Vector2(640, 360) + card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.ROYAL_BLUE)) center.add_child(card_panel) var card_content := VBoxContainer.new() card_content.add_theme_constant_override("separation", 18) @@ -155,7 +159,20 @@ func _build() -> void: func refresh_theme_controls() -> void: - pass + if card_panel == null: + return + var dark := UIThemeService.is_dark_mode() + card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + empty_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + + +func _update_responsive_layout() -> void: + if card_panel == null: + return + compact_layout = size.x < 720.0 + var available := maxf(size.x - 48.0, 240.0) + card_panel.custom_minimum_size.x = minf(640.0, available) + card_panel.custom_minimum_size.y = 300.0 if compact_layout else 360.0 func _load_next() -> void: diff --git a/tests/flashcard_theme_responsive_test.gd b/tests/flashcard_theme_responsive_test.gd new file mode 100644 index 0000000..87ccf46 --- /dev/null +++ b/tests/flashcard_theme_responsive_test.gd @@ -0,0 +1,115 @@ +extends SceneTree + +# Flashcards previously had no coverage for theme switching or narrow-viewport layout: the +# library screen's `_refresh_status()` and the study screen's `refresh_theme_controls()` were +# both no-op stubs, and neither screen reacted to its own resize. This drives both screens +# through a dark/light toggle and a phone-width resize with no network calls (mirrors +# flashcard_study_render_test.gd: content is injected directly rather than fetched). + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var host := Control.new() + host.size = Vector2(1280, 800) + root.add_child(host) + + var theme_service = root.get_node("UIThemeService") + var prior_dark: bool = theme_service.is_dark_mode() + + var Hub := load("res://scripts/ui/flashcards_hub.gd") + var hub = Hub.new() + hub.size = host.size + host.add_child(hub) + await process_frame + + hub.notes = [{"id": "note-1", "type": "basic", "prompt": "2 + 2", "answer": "4", "tags": []}] + + theme_service.set_dark_mode(true) + hub._render_notes() + await process_frame + var dark_card := hub.list_container.get_child(0) as PanelContainer + var dark_box := dark_card.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_box.bg_color.is_equal_approx(Brand.content_card_box(true, false, Brand.ROYAL_BLUE).bg_color), + "flashcard note cards should use the dark surface while dark mode is active") + + theme_service.set_dark_mode(false) + await process_frame + var light_card := hub.list_container.get_child(0) as PanelContainer + var light_box := light_card.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_box.bg_color.is_equal_approx(Brand.content_card_box(false, false, Brand.ROYAL_BLUE).bg_color), + "toggling the app theme should rebuild flashcard note cards with the light surface (the library screen's theme_changed handler was previously a no-op)") + var light_menu_box := hub.export_menu.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_menu_box.bg_color.is_equal_approx(Brand.panel_box(false).bg_color), + "the export menu panel should also restyle to the light surface after a theme toggle") + + theme_service.set_dark_mode(prior_dark) + + hub.size = Vector2(360, 800) + hub._update_responsive_layout() + await process_frame + _assert(hub.compact_layout, "the flashcards library should detect a phone-width viewport") + var compact_card := hub.list_container.get_child(0) as PanelContainer + var compact_row := compact_card.get_child(0) as BoxContainer + _assert(compact_row.vertical, "phone-width flashcard rows should stack their content above the edit/archive actions instead of clipping") + _assert(compact_card.get_combined_minimum_size().x <= hub.size.x, + "a phone-width flashcard row should fit inside the viewport (minimum %.1f px)" % compact_card.get_combined_minimum_size().x) + + hub.size = Vector2(1280, 800) + hub._update_responsive_layout() + await process_frame + _assert(not hub.compact_layout, "a wide viewport should restore the flashcards library's desktop row layout") + var wide_row := (hub.list_container.get_child(0) as PanelContainer).get_child(0) as BoxContainer + _assert(not wide_row.vertical, "a wide viewport should lay flashcard row content out horizontally again") + + hub.free() + + var Study := load("res://scripts/ui/flashcards_study.gd") + var study = Study.new() + study._build() + + theme_service.set_dark_mode(true) + study.refresh_theme_controls() + var dark_study_box := study.card_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_study_box.bg_color.is_equal_approx(Brand.content_card_box(true, true, Brand.ROYAL_BLUE).bg_color), + "the study card should use the dark active-card surface while dark mode is active") + + theme_service.set_dark_mode(false) + study.refresh_theme_controls() + var light_study_box := study.card_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_study_box.bg_color.is_equal_approx(Brand.content_card_box(false, true, Brand.ROYAL_BLUE).bg_color), + "toggling the app theme should restyle the study card to the light surface (refresh_theme_controls was previously a no-op)") + _assert(light_study_box.border_color.is_equal_approx(Brand.ROYAL_BLUE), + "the study card should keep its accent border after a theme refresh") + + theme_service.set_dark_mode(prior_dark) + + study.size = Vector2(360, 720) + study._update_responsive_layout() + _assert(study.compact_layout, "the study screen should detect a phone-width viewport") + _assert(study.card_panel.custom_minimum_size.x <= study.size.x, + "the study card should shrink to fit a phone-width viewport instead of overflowing (minimum %.1f px on a %.1f px viewport)" % [study.card_panel.custom_minimum_size.x, study.size.x]) + + study.size = Vector2(1280, 720) + study._update_responsive_layout() + _assert(not study.compact_layout, "a wide viewport should restore the study screen's desktop layout") + _assert(is_equal_approx(study.card_panel.custom_minimum_size.x, 640.0), + "a wide viewport should restore the study card's full 640px width") + + study.free() + host.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message)