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.
This commit is contained in:
hz
2026-07-21 16:41:26 -05:00
parent 48a8c8361d
commit 4dfbd811b9
5 changed files with 515 additions and 17 deletions
+8
View File
@@ -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
`<input type="file">`/`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
+161
View File
@@ -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()
+213 -16
View File
@@ -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()
+18 -1
View File
@@ -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:
+115
View File
@@ -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)