From 7e19366dcb7c474781cdb74f33dcc33b84e0a7de Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 21 Jul 2026 22:05:36 -0500 Subject: [PATCH] Redesign flashcards for visual hierarchy and button consistency Study card: label the answer and "key takeaway" as distinct sections with a real visible divider (previously an all-but-invisible HSeparator) instead of one undifferentiated block of text. Also fixes a real gap where a cloze card's extra field was silently dropped, since only basic notes combined answer+extra into one label. Bigger card, gradient Show-Answer button matching the app's signature CTA style instead of a duller flat teal, and the Undo button now hides itself when there's nothing to undo instead of sitting there disabled looking identical to a real action. Library: colored type badge per card (Basic/Cloze) instead of plain gray text, more breathing room inside each row, Study promoted to the same gradient CTA style, and Edit/Archive given a real secondary button treatment instead of default theme buttons. Note editor fields renamed ("Back / Extra takeaway" + a separate "Extra" field was genuinely confusing) and now show which deck a card is being saved to. --- scripts/ui/flashcards_hub.gd | 89 +++++++++++++++--- scripts/ui/flashcards_study.gd | 111 ++++++++++++++++++----- tests/flashcard_study_render_test.gd | 16 +++- tests/flashcard_theme_responsive_test.gd | 4 +- 4 files changed, 182 insertions(+), 38 deletions(-) diff --git a/scripts/ui/flashcards_hub.gd b/scripts/ui/flashcards_hub.gd index a6ec2f5..048d616 100644 --- a/scripts/ui/flashcards_hub.gd +++ b/scripts/ui/flashcards_hub.gd @@ -23,6 +23,7 @@ var debounce_timer: Timer var editor_overlay: Control var editor_panel: PanelContainer +var editor_deck_label: Label var editor_note_id := "" var editor_expected_revision: Variant = null var editor_type_selector: OptionButton @@ -111,8 +112,12 @@ func _build_toolbar() -> void: study_button = Button.new() study_button.text = "Study" - study_button.custom_minimum_size = Vector2(0, 44) - Style.style_primary_button(study_button) + study_button.custom_minimum_size = Vector2(110, 44) + study_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + study_button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) + study_button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) + study_button.add_theme_color_override("font_color", Color.WHITE) + study_button.add_theme_color_override("font_hover_color", Color.WHITE) study_button.pressed.connect(_start_study) row.add_child(study_button) @@ -291,7 +296,12 @@ func _render_notes() -> void: 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 card_box := Brand.content_card_box(dark, false, Brand.ROYAL_BLUE) + card_box.content_margin_left = 16 + card_box.content_margin_right = 16 + card_box.content_margin_top = 14 + card_box.content_margin_bottom = 14 + card.add_theme_stylebox_override("panel", card_box) var row := BoxContainer.new() row.vertical = compact_layout row.add_theme_constant_override("separation", 12) @@ -299,8 +309,22 @@ func _note_row(note: Dictionary) -> Control: var content := VBoxContainer.new() content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.add_theme_constant_override("separation", 6) row.add_child(content) var type_str := str(note.get("type", "basic")) + + var header_row := HBoxContainer.new() + header_row.add_theme_constant_override("separation", 8) + content.add_child(header_row) + header_row.add_child(_type_badge(type_str, dark)) + var tags: Array = note.get("tags", []) + if tags.size() > 0: + var tags_label := Label.new() + tags_label.text = ", ".join(PackedStringArray(tags)) + tags_label.add_theme_font_size_override("font_size", 12) + tags_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + header_row.add_child(tags_label) + var prompt_text := str(note.get("prompt", "")) var preview := Cloze.render_back(prompt_text) if type_str == "cloze" else prompt_text var prompt_label := Label.new() @@ -308,22 +332,21 @@ func _note_row(note: Dictionary) -> Control: prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART prompt_label.add_theme_font_override("font", Brand.font) content.add_child(prompt_label) - var meta := Label.new() - var tags: Array = note.get("tags", []) - meta.text = "%s%s" % [type_str.capitalize(), (" | " + ", ".join(PackedStringArray(tags))) if tags.size() > 0 else ""] - meta.add_theme_font_size_override("font_size", 12) - meta.add_theme_color_override("font_color", Brand.muted_color(dark)) - content.add_child(meta) var edit_button := Button.new() edit_button.text = "Edit" edit_button.custom_minimum_size = Vector2(70, 40) + edit_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + edit_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note)) row.add_child(edit_button) var archive_button := Button.new() archive_button.text = "Archive" archive_button.custom_minimum_size = Vector2(85, 40) + archive_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + archive_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + archive_button.add_theme_color_override("font_color", Color("ff5f76") if dark else Color("be123c")) archive_button.pressed.connect(func(): FlashcardService.archive_notes([str(note.get("id", ""))], true) await get_tree().create_timer(0.3).timeout @@ -333,6 +356,27 @@ func _note_row(note: Dictionary) -> Control: return card +func _type_badge(type_str: String, dark: bool) -> PanelContainer: + var accent := Brand.TEAL if type_str == "cloze" else Brand.ROYAL_BLUE + var badge := PanelContainer.new() + var box := StyleBoxFlat.new() + box.bg_color = Color(accent, 0.16 if dark else 0.12) + box.border_color = accent + box.set_border_width_all(1) + box.set_corner_radius_all(8) + box.content_margin_left = 8 + box.content_margin_right = 8 + box.content_margin_top = 2 + box.content_margin_bottom = 2 + badge.add_theme_stylebox_override("panel", box) + var label := Label.new() + label.text = type_str.capitalize() + label.add_theme_font_size_override("font_size", 11) + label.add_theme_color_override("font_color", accent) + badge.add_child(label) + return badge + + # ---------- Note editor ---------- func _build_editor_overlay() -> void: @@ -373,6 +417,11 @@ func _build_editor_overlay() -> void: close_button.pressed.connect(_close_editor) header.add_child(close_button) + editor_deck_label = Label.new() + editor_deck_label.add_theme_font_size_override("font_size", 13) + editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + content.add_child(editor_deck_label) + editor_type_selector = OptionButton.new() editor_type_selector.add_item("Basic") editor_type_selector.add_item("Cloze") @@ -391,13 +440,13 @@ func _build_editor_overlay() -> void: editor_front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY content.add_child(editor_front_input) - content.add_child(_field_label("Back / Extra takeaway")) + content.add_child(_field_label("Answer")) editor_back_input = TextEdit.new() editor_back_input.custom_minimum_size = Vector2(0, 80) editor_back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY content.add_child(editor_back_input) - content.add_child(_field_label("Extra")) + content.add_child(_field_label("Extra (optional teaching pearl)")) editor_extra_input = TextEdit.new() editor_extra_input.custom_minimum_size = Vector2(0, 60) editor_extra_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY @@ -415,7 +464,11 @@ func _build_editor_overlay() -> void: var save_button := Button.new() save_button.text = "Save" save_button.custom_minimum_size = Vector2(0, 46) - Style.style_primary_button(save_button) + save_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + save_button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) + save_button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) + save_button.add_theme_color_override("font_color", Color.WHITE) + save_button.add_theme_color_override("font_hover_color", Color.WHITE) save_button.pressed.connect(_save_editor) content.add_child(save_button) @@ -446,10 +499,20 @@ func _open_editor(note_id: String, note: Dictionary) -> void: editor_extra_input.text = str(note.get("extra", "")) editor_tags_input.text = ", ".join(PackedStringArray(note.get("tags", []))) editor_error_label.visible = false + editor_deck_label.text = "Saving to: %s" % _deck_name_for_id(str(note.get("deckId", "")) if not note_id.is_empty() else _selected_deck_id()) _on_editor_type_changed(editor_type_selector.selected) editor_overlay.visible = true +func _deck_name_for_id(id: String) -> String: + if not id.is_empty(): + for summary in decks: + var deck: Dictionary = summary.get("deck", {}) + if str(deck.get("id", "")) == id: + return str(deck.get("name", "Default")) + return "Default" + + func _close_editor() -> void: editor_overlay.visible = false @@ -632,6 +695,8 @@ func _refresh_status() -> void: var dark := UIThemeService.is_dark_mode() if editor_panel != null: editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + if editor_deck_label != null: + editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(dark)) if import_panel != null: import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) if export_menu != null: diff --git a/scripts/ui/flashcards_study.gd b/scripts/ui/flashcards_study.gd index 8922954..1bb575d 100644 --- a/scripts/ui/flashcards_study.gd +++ b/scripts/ui/flashcards_study.gd @@ -26,8 +26,11 @@ var exit_button: Button var paused_banner: Label var card_panel: PanelContainer var front_label: Label -var divider: HSeparator +var divider: ColorRect +var answer_caption: Label var back_label: Label +var extra_caption: Label +var extra_label: Label var reveal_button: Button var grade_row: HBoxContainer var grade_buttons: Dictionary = {} @@ -57,6 +60,9 @@ func on_show(payload: Variant = null) -> void: current_note = {} current_card = {} revealed = false + last_client_review_id = "" + last_card_id = "" + undo_button.visible = false _render_loading() _load_next() @@ -100,11 +106,11 @@ func _build() -> void: root.add_child(center) card_panel = PanelContainer.new() - card_panel.custom_minimum_size = Vector2(640, 360) + card_panel.custom_minimum_size = Vector2(720, 420) 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) + card_content.add_theme_constant_override("separation", 20) card_panel.add_child(card_content) empty_label = Label.new() @@ -116,24 +122,42 @@ func _build() -> void: front_label = Label.new() front_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART front_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - front_label.add_theme_font_size_override("font_size", 22) + front_label.add_theme_font_size_override("font_size", 25) card_content.add_child(front_label) - divider = HSeparator.new() + divider = ColorRect.new() + divider.custom_minimum_size = Vector2(0, 2) divider.visible = false card_content.add_child(divider) + var answer_block := VBoxContainer.new() + answer_block.add_theme_constant_override("separation", 4) + card_content.add_child(answer_block) + answer_caption = _caption_label("ANSWER") + answer_block.add_child(answer_caption) back_label = Label.new() back_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART back_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - back_label.add_theme_font_size_override("font_size", 18) + back_label.add_theme_font_size_override("font_size", 19) back_label.visible = false - card_content.add_child(back_label) + answer_block.add_child(back_label) + + var extra_block := VBoxContainer.new() + extra_block.add_theme_constant_override("separation", 4) + card_content.add_child(extra_block) + extra_caption = _caption_label("KEY TAKEAWAY") + extra_block.add_child(extra_caption) + extra_label = Label.new() + extra_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + extra_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + extra_label.add_theme_font_size_override("font_size", 15) + extra_label.visible = false + extra_block.add_child(extra_label) reveal_button = Button.new() reveal_button.text = "Show Answer (Space)" reveal_button.custom_minimum_size = Vector2(0, 52) - Style.style_primary_button(reveal_button) + _style_gradient_button(reveal_button) reveal_button.pressed.connect(_reveal) root.add_child(reveal_button) @@ -152,10 +176,38 @@ func _build() -> void: undo_button = Button.new() undo_button.text = "Undo last review" - undo_button.custom_minimum_size = Vector2(0, 42) - undo_button.disabled = true + undo_button.custom_minimum_size = Vector2(0, 36) + undo_button.visible = false undo_button.pressed.connect(_undo) root.add_child(undo_button) + _style_ghost_button(undo_button, UIThemeService.is_dark_mode()) + + +func _caption_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 12) + label.visible = false + return label + + +func _style_gradient_button(button: Button) -> void: + button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) + button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) + button.add_theme_color_override("font_color", Color.WHITE) + button.add_theme_color_override("font_hover_color", Color.WHITE) + + +func _style_ghost_button(button: Button, dark: bool) -> void: + var empty := StyleBoxEmpty.new() + button.add_theme_stylebox_override("normal", empty) + button.add_theme_stylebox_override("hover", empty) + button.add_theme_stylebox_override("pressed", empty) + button.add_theme_stylebox_override("focus", empty) + button.add_theme_color_override("font_color", Brand.muted_color(dark)) + button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) func refresh_theme_controls() -> void: @@ -164,6 +216,11 @@ func refresh_theme_controls() -> void: 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)) + divider.color = Brand.ROYAL_BLUE if dark else Brand.PURPLE + answer_caption.add_theme_color_override("font_color", Brand.ROYAL_BLUE if dark else Brand.PURPLE) + extra_caption.add_theme_color_override("font_color", Brand.muted_color(dark)) + extra_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + _style_ghost_button(undo_button, dark) func _update_responsive_layout() -> void: @@ -171,8 +228,8 @@ func _update_responsive_layout() -> void: 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 + card_panel.custom_minimum_size.x = minf(720.0, available) + card_panel.custom_minimum_size.y = 340.0 if compact_layout else 420.0 func _load_next() -> void: @@ -208,8 +265,7 @@ func _render_loading() -> void: empty_label.visible = false front_label.visible = true front_label.text = "Loading..." - back_label.visible = false - divider.visible = false + _hide_answer() reveal_button.visible = false grade_row.visible = false @@ -217,8 +273,7 @@ func _render_loading() -> void: func _render_empty(card: Dictionary) -> void: empty_label.visible = true front_label.visible = false - back_label.visible = false - divider.visible = false + _hide_answer() reveal_button.visible = false grade_row.visible = false progress_label.text = "0 due" @@ -226,6 +281,14 @@ func _render_empty(card: Dictionary) -> void: progress_label.text = "%d due, %d new available" % [int(card.get("dueRemaining", 0)), int(card.get("newRemaining", 0))] +func _hide_answer() -> void: + divider.visible = false + answer_caption.visible = false + back_label.visible = false + extra_caption.visible = false + extra_label.visible = false + + func _render_card() -> void: empty_label.visible = false var note_type := str(current_note.get("type", "basic")) @@ -235,8 +298,7 @@ func _render_card() -> void: else: front_label.text = str(current_note.get("prompt", "")) front_label.visible = true - back_label.visible = false - divider.visible = false + _hide_answer() reveal_button.visible = true grade_row.visible = false progress_label.text = "%d due, %d new available" % [due_remaining, new_remaining] @@ -251,11 +313,14 @@ func _reveal() -> void: if note_type == "cloze": back_label.text = Cloze.render_back(str(current_note.get("prompt", ""))) else: - var answer := str(current_note.get("answer", "")) - var extra := str(current_note.get("extra", "")) - back_label.text = "%s\n\n%s" % [answer, extra] if not extra.is_empty() else answer - back_label.visible = true + back_label.text = str(current_note.get("answer", "")) + var extra := str(current_note.get("extra", "")) divider.visible = true + answer_caption.visible = true + back_label.visible = true + extra_caption.visible = not extra.is_empty() + extra_label.visible = not extra.is_empty() + extra_label.text = extra reveal_button.visible = false grade_row.visible = true for rating in grade_buttons: @@ -278,6 +343,7 @@ func _grade(rating: String) -> void: var duration_seconds := int(float(Time.get_ticks_msec() - loaded_started_ms) / 1000.0) last_client_review_id = FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc) last_card_id = card_id + undo_button.visible = true undo_button.disabled = false if not seen_card_ids.has(card_id): seen_card_ids.append(card_id) @@ -295,6 +361,7 @@ func _undo() -> void: undo_button.disabled = true FlashcardService.undo_last_review(last_client_review_id, func(_restored: Dictionary): last_client_review_id = "" + undo_button.visible = false seen_card_ids.erase(last_card_id) _load_next()) diff --git a/tests/flashcard_study_render_test.gd b/tests/flashcard_study_render_test.gd index 5e12c3f..c574886 100644 --- a/tests/flashcard_study_render_test.gd +++ b/tests/flashcard_study_render_test.gd @@ -34,19 +34,31 @@ func _initialize() -> void: _assert(not screen.back_label.visible, "the back should stay hidden before reveal") screen._reveal() - _assert(screen.back_label.text == "4\n\nBasic arithmetic", "reveal should show the answer plus extra") + _assert(screen.back_label.text == "4", "reveal should show the answer on its own, separate from the extra pearl") + _assert(screen.extra_label.text == "Basic arithmetic" and screen.extra_label.visible, "the extra takeaway should render in its own labeled section") + _assert(screen.answer_caption.visible and screen.extra_caption.visible, "revealing a card with an extra should show both the answer and takeaway captions") _assert(screen.grade_row.visible, "grading buttons should appear after reveal") _assert(not screen.reveal_button.visible, "the reveal button should hide once the card is revealed") _assert(screen.grade_buttons["good"].text.contains("1d"), "each grade button should show its computed next interval") # Cloze card: only the card's own variant is hidden on the front; back reveals everything. - screen.current_note = {"type": "cloze", "prompt": "{{c1::Paris}} is the capital of France", "answer": "", "extra": ""} + screen.current_note = {"type": "cloze", "prompt": "{{c1::Paris}} is the capital of France", "answer": "", "extra": "European capitals are a classic Step 3 target"} screen.current_card = {"id": "card-2", "variant": 1, "revision": 1} screen.revealed = false screen._render_card() _assert(screen.front_label.text == "[...] is the capital of France", "cloze front should hide only its own variant") screen._reveal() _assert(screen.back_label.text == "Paris is the capital of France", "cloze back should reveal every deletion") + _assert(screen.extra_label.text == "European capitals are a classic Step 3 target" and screen.extra_label.visible, + "a cloze note's extra takeaway should also render (previously silently dropped since only basic notes combined answer+extra into one label)") + + # A card with no extra should hide the takeaway section entirely rather than show an empty caption. + screen.current_note = {"type": "basic", "prompt": "What is 1 + 1?", "answer": "2", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.revealed = false + screen._render_card() + screen._reveal() + _assert(not screen.extra_label.visible and not screen.extra_caption.visible, "a card with no extra takeaway should hide that section instead of showing an empty caption") screen.free() quit(1 if failures > 0 else 0) diff --git a/tests/flashcard_theme_responsive_test.gd b/tests/flashcard_theme_responsive_test.gd index 87ccf46..234c25b 100644 --- a/tests/flashcard_theme_responsive_test.gd +++ b/tests/flashcard_theme_responsive_test.gd @@ -99,8 +99,8 @@ func _run() -> void: 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") + _assert(is_equal_approx(study.card_panel.custom_minimum_size.x, 720.0), + "a wide viewport should restore the study card's full 720px width") study.free() host.queue_free()