Rework flashcards library to match the rest of the app's visual language

Compared side-by-side against Dashboard, the library screen used
entirely different visual primitives: a gradient primary button
Dashboard doesn't use anywhere, per-row bordered cards with low
contrast against the dark background, and a translucent pill badge
instead of the icon-circle pattern used everywhere else (stat tiles,
review queue, recent activity).

- Study/Save/Commit import now use Dashboard's actual primary button
  style (flat ROYAL_BLUE, not the login/session-dialog gradient)
- Added a stat-tile row (Due today / New available / Decks) using
  Dashboard's exact icon-circle + value + label construction, replacing
  the due/new counts previously crammed into the deck dropdown's text
- The note list now lives in one bordered card with a section title
  (matching Dashboard's chart/list panels) instead of each row being
  its own separate low-contrast card
- Per-row card-type indicator is now an icon circle (clipboard-list /
  notebook-pen) matching the Recent Activity list pattern, instead of
  a translucent color-coded pill badge
This commit is contained in:
hz
2026-07-21 22:47:21 -05:00
parent 109581874d
commit 72fb6e4776
2 changed files with 189 additions and 65 deletions
+171 -52
View File
@@ -6,6 +6,13 @@ const Cloze := preload("res://scripts/ui/flashcard_cloze.gd")
var main var main
var stats_row: HFlowContainer
var stat_due_value: Label
var stat_new_value: Label
var stat_decks_value: Label
var list_card: PanelContainer
var list_section_title: Label
var deck_selector: OptionButton var deck_selector: OptionButton
var deck_ids: Array = [] var deck_ids: Array = []
var new_deck_button: Button var new_deck_button: Button
@@ -56,17 +63,28 @@ func _ready() -> void:
size_flags_vertical = Control.SIZE_EXPAND_FILL size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 14) add_theme_constant_override("separation", 14)
_build_dropdown_backdrop() _build_dropdown_backdrop()
_build_stats()
_build_toolbar() _build_toolbar()
_build_filters() _build_filters()
status_label = Label.new() status_label = Label.new()
status_label.add_theme_font_override("font", Brand.font) status_label.add_theme_font_override("font", Brand.font)
add_child(status_label) add_child(status_label)
list_card = PanelContainer.new()
list_card.size_flags_vertical = Control.SIZE_EXPAND_FILL
list_card.add_theme_stylebox_override("panel", _list_card_box(UIThemeService.is_dark_mode()))
add_child(list_card)
var list_card_content := VBoxContainer.new()
list_card_content.add_theme_constant_override("separation", 10)
list_card.add_child(list_card_content)
list_section_title = _section_title("Your flashcards")
list_card_content.add_child(list_section_title)
list_scroll = ScrollContainer.new() list_scroll = ScrollContainer.new()
list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(list_scroll) list_card_content.add_child(list_scroll)
list_container = VBoxContainer.new() list_container = VBoxContainer.new()
list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
list_container.add_theme_constant_override("separation", 8) list_container.add_theme_constant_override("separation", 14)
list_scroll.add_child(list_container) list_scroll.add_child(list_container)
debounce_timer = Timer.new() debounce_timer = Timer.new()
debounce_timer.one_shot = true debounce_timer.one_shot = true
@@ -93,6 +111,79 @@ func on_show(_payload: Variant = null) -> void:
_reload_notes() _reload_notes()
func _build_stats() -> void:
var previous_due := stat_due_value.text if stat_due_value != null else "--"
var previous_new := stat_new_value.text if stat_new_value != null else "--"
var previous_decks := stat_decks_value.text if stat_decks_value != null else "--"
if stats_row == null:
stats_row = HFlowContainer.new()
stats_row.add_theme_constant_override("separation", 24)
add_child(stats_row)
move_child(stats_row, 0)
for child in stats_row.get_children():
child.queue_free()
stat_due_value = _add_stat(stats_row, "Due today", "clock-3", Brand.AMBER)
stat_new_value = _add_stat(stats_row, "New available", "clipboard-plus", Brand.ROYAL_BLUE)
stat_decks_value = _add_stat(stats_row, "Decks", "clipboard-list", Brand.PURPLE)
stat_due_value.text = previous_due
stat_new_value.text = previous_new
stat_decks_value.text = previous_decks
func _add_stat(row: HFlowContainer, title: String, icon_name: String, accent: Color) -> Label:
var dark := UIThemeService.is_dark_mode()
var cell := HBoxContainer.new()
cell.custom_minimum_size = Vector2(200, 56)
cell.add_theme_constant_override("separation", 12)
row.add_child(cell)
var icon_panel := PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(48, 48)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24))
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
icon.custom_minimum_size = Vector2(22, 22)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_panel.add_child(icon)
cell.add_child(icon_panel)
var copy := VBoxContainer.new()
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
var caption := Label.new()
caption.text = title
caption.add_theme_font_size_override("font_size", 13)
caption.add_theme_color_override("font_color", Brand.muted_color(dark))
copy.add_child(caption)
var value := Label.new()
value.text = "--"
value.add_theme_font_size_override("font_size", 24)
value.add_theme_color_override("font_color", Brand.text_color(dark))
copy.add_child(value)
cell.add_child(copy)
return value
func _list_card_box(dark: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("081426") if dark else Color.WHITE
box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER
box.set_border_width_all(1)
box.set_corner_radius_all(8)
box.content_margin_left = 18
box.content_margin_right = 18
box.content_margin_top = 16
box.content_margin_bottom = 16
return box
func _section_title(text: String) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 16)
label.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode()))
return label
func _build_toolbar() -> void: func _build_toolbar() -> void:
var row := HFlowContainer.new() var row := HFlowContainer.new()
row.add_theme_constant_override("separation", 10) row.add_theme_constant_override("separation", 10)
@@ -113,11 +204,7 @@ func _build_toolbar() -> void:
study_button = Button.new() study_button = Button.new()
study_button.text = "Study" study_button.text = "Study"
study_button.custom_minimum_size = Vector2(110, 44) study_button.custom_minimum_size = Vector2(110, 44)
study_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) _style_primary_button(study_button)
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) study_button.pressed.connect(_start_study)
row.add_child(study_button) row.add_child(study_button)
@@ -156,6 +243,19 @@ func _build_toolbar() -> void:
]) ])
func _style_primary_button(button: Button) -> void:
var normal := StyleBoxFlat.new()
normal.bg_color = Brand.ROYAL_BLUE
normal.set_corner_radius_all(6)
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color("3474ee")
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", normal)
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
func _build_dropdown_backdrop() -> void: func _build_dropdown_backdrop() -> void:
dropdown_backdrop = Control.new() dropdown_backdrop = Control.new()
dropdown_backdrop.visible = false dropdown_backdrop.visible = false
@@ -250,15 +350,22 @@ func _on_decks_changed(loaded_decks: Array) -> void:
deck_selector.clear() deck_selector.clear()
deck_selector.add_item("All decks") deck_selector.add_item("All decks")
deck_ids = [""] deck_ids = [""]
var total_due := 0
var total_new := 0
for summary in decks: for summary in decks:
var deck: Dictionary = summary.get("deck", {}) var deck: Dictionary = summary.get("deck", {})
var label := "%s (%d due, %d new)" % [str(deck.get("name", "Deck")), int(summary.get("due", 0)), int(summary.get("new", 0))] deck_selector.add_item(str(deck.get("name", "Deck")))
deck_selector.add_item(label)
deck_ids.append(str(deck.get("id", ""))) deck_ids.append(str(deck.get("id", "")))
total_due += int(summary.get("due", 0))
total_new += int(summary.get("new", 0))
if previous_selection != null: if previous_selection != null:
var restored := deck_ids.find(previous_selection) var restored := deck_ids.find(previous_selection)
if restored >= 0: if restored >= 0:
deck_selector.select(restored) deck_selector.select(restored)
if stat_due_value != null:
stat_due_value.text = str(total_due)
stat_new_value.text = str(total_new)
stat_decks_value.text = str(decks.size())
func _selected_deck_id() -> String: func _selected_deck_id() -> String:
@@ -288,35 +395,56 @@ func _on_notes_loaded(response: Dictionary) -> void:
func _render_notes() -> void: func _render_notes() -> void:
for child in list_container.get_children(): for child in list_container.get_children():
child.queue_free() child.queue_free()
status_label.text = "" if notes.size() > 0 else "No flashcards match these filters yet." var dark := UIThemeService.is_dark_mode()
for note in notes: if list_section_title != null:
list_container.add_child(_note_row(note)) list_section_title.text = "Your flashcards (%d)" % notes.size()
status_label.text = ""
if notes.is_empty():
var empty_label := Label.new()
empty_label.text = "No flashcards match these filters yet."
empty_label.add_theme_color_override("font_color", Brand.muted_color(dark))
list_container.add_child(empty_label)
return
for index in notes.size():
if index > 0:
list_container.add_child(_row_separator(dark))
list_container.add_child(_note_row(notes[index]))
func _note_row(note: Dictionary) -> Control: func _note_row(note: Dictionary) -> Control:
var dark := UIThemeService.is_dark_mode() var dark := UIThemeService.is_dark_mode()
var card := PanelContainer.new() var type_str := str(note.get("type", "basic"))
var card_box := Brand.content_card_box(dark, false, Brand.ROYAL_BLUE) var accent := Brand.TEAL if type_str == "cloze" else 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() var row := BoxContainer.new()
row.vertical = compact_layout row.vertical = compact_layout
row.add_theme_constant_override("separation", 12) row.add_theme_constant_override("separation", 14)
card.add_child(row)
var icon_panel := PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(40, 40)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20))
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % ("notebook-pen" if type_str == "cloze" else "clipboard-list"))
icon.custom_minimum_size = Vector2(18, 18)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_panel.add_child(icon)
row.add_child(icon_panel)
var content := VBoxContainer.new() var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 6) content.add_theme_constant_override("separation", 4)
row.add_child(content) row.add_child(content)
var type_str := str(note.get("type", "basic"))
var header_row := HBoxContainer.new() var header_row := HBoxContainer.new()
header_row.add_theme_constant_override("separation", 8) header_row.add_theme_constant_override("separation", 8)
content.add_child(header_row) content.add_child(header_row)
header_row.add_child(_type_badge(type_str, dark)) var type_label := Label.new()
type_label.text = type_str.capitalize()
type_label.add_theme_font_size_override("font_size", 12)
type_label.add_theme_color_override("font_color", accent)
header_row.add_child(type_label)
var tags: Array = note.get("tags", []) var tags: Array = note.get("tags", [])
if tags.size() > 0: if tags.size() > 0:
var tags_label := Label.new() var tags_label := Label.new()
@@ -333,13 +461,17 @@ func _note_row(note: Dictionary) -> Control:
prompt_label.add_theme_font_override("font", Brand.font) prompt_label.add_theme_font_override("font", Brand.font)
content.add_child(prompt_label) content.add_child(prompt_label)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
row.add_child(actions)
var edit_button := Button.new() var edit_button := Button.new()
edit_button.text = "Edit" edit_button.text = "Edit"
edit_button.custom_minimum_size = Vector2(70, 40) 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("normal", Brand.secondary_button_box(dark))
edit_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) 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)) edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note))
row.add_child(edit_button) actions.add_child(edit_button)
var archive_button := Button.new() var archive_button := Button.new()
archive_button.text = "Archive" archive_button.text = "Archive"
@@ -351,30 +483,16 @@ func _note_row(note: Dictionary) -> Control:
FlashcardService.archive_notes([str(note.get("id", ""))], true) FlashcardService.archive_notes([str(note.get("id", ""))], true)
await get_tree().create_timer(0.3).timeout await get_tree().create_timer(0.3).timeout
_reload_notes()) _reload_notes())
row.add_child(archive_button) actions.add_child(archive_button)
return card return row
func _type_badge(type_str: String, dark: bool) -> PanelContainer: func _row_separator(dark: bool) -> ColorRect:
var accent := Brand.TEAL if type_str == "cloze" else Brand.ROYAL_BLUE var line := ColorRect.new()
var badge := PanelContainer.new() line.custom_minimum_size = Vector2(0, 1)
var box := StyleBoxFlat.new() line.color = Color("1c2b49") if dark else Brand.LIGHT_BORDER
box.bg_color = Color(accent, 0.16 if dark else 0.12) return line
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 ---------- # ---------- Note editor ----------
@@ -464,11 +582,7 @@ func _build_editor_overlay() -> void:
var save_button := Button.new() var save_button := Button.new()
save_button.text = "Save" save_button.text = "Save"
save_button.custom_minimum_size = Vector2(0, 46) save_button.custom_minimum_size = Vector2(0, 46)
save_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) _style_primary_button(save_button)
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) save_button.pressed.connect(_save_editor)
content.add_child(save_button) content.add_child(save_button)
@@ -635,7 +749,7 @@ func _build_import_overlay() -> void:
import_commit_button.text = "Commit import" import_commit_button.text = "Commit import"
import_commit_button.custom_minimum_size = Vector2(0, 46) import_commit_button.custom_minimum_size = Vector2(0, 46)
import_commit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL import_commit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(import_commit_button) _style_primary_button(import_commit_button)
import_commit_button.pressed.connect(_commit_pending_import) import_commit_button.pressed.connect(_commit_pending_import)
actions.add_child(import_commit_button) actions.add_child(import_commit_button)
@@ -703,6 +817,11 @@ func _refresh_status() -> void:
export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if import_menu != null: if import_menu != null:
import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if list_card != null:
list_card.add_theme_stylebox_override("panel", _list_card_box(dark))
if list_section_title != null:
list_section_title.add_theme_color_override("font_color", Brand.text_color(dark))
_build_stats()
_render_notes() _render_notes()
+18 -13
View File
@@ -34,17 +34,23 @@ func _run() -> void:
theme_service.set_dark_mode(true) theme_service.set_dark_mode(true)
hub._render_notes() hub._render_notes()
await process_frame await process_frame
var dark_card := hub.list_container.get_child(0) as PanelContainer var dark_list_box := hub.list_card.get_theme_stylebox("panel") as StyleBoxFlat
var dark_box := dark_card.get_theme_stylebox("panel") as StyleBoxFlat _assert(dark_list_box.bg_color.is_equal_approx(Color("081426")),
_assert(dark_box.bg_color.is_equal_approx(Brand.content_card_box(true, false, Brand.ROYAL_BLUE).bg_color), "the flashcards list card should use the dark surface while dark mode is active")
"flashcard note cards should use the dark surface while dark mode is active") var dark_row := hub.list_container.get_child(0) as BoxContainer
var dark_icon_box := (dark_row.get_child(0) as PanelContainer).get_theme_stylebox("panel") as StyleBoxFlat
_assert(dark_icon_box.bg_color.is_equal_approx(Brand.icon_circle_box(true, Brand.ROYAL_BLUE, 20).bg_color),
"a basic card's type icon should use its dark accent styling")
theme_service.set_dark_mode(false) theme_service.set_dark_mode(false)
await process_frame await process_frame
var light_card := hub.list_container.get_child(0) as PanelContainer var light_list_box := hub.list_card.get_theme_stylebox("panel") as StyleBoxFlat
var light_box := light_card.get_theme_stylebox("panel") as StyleBoxFlat _assert(light_list_box.bg_color.is_equal_approx(Color.WHITE),
_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 the flashcards list card with the light surface (the library screen's theme_changed handler was previously a no-op)")
"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_row := hub.list_container.get_child(0) as BoxContainer
var light_icon_box := (light_row.get_child(0) as PanelContainer).get_theme_stylebox("panel") as StyleBoxFlat
_assert(light_icon_box.bg_color.is_equal_approx(Brand.icon_circle_box(false, Brand.ROYAL_BLUE, 20).bg_color),
"a basic card's type icon should use its light accent styling")
var light_menu_box := hub.export_menu.get_theme_stylebox("panel") as StyleBoxFlat 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), _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") "the export menu panel should also restyle to the light surface after a theme toggle")
@@ -55,17 +61,16 @@ func _run() -> void:
hub._update_responsive_layout() hub._update_responsive_layout()
await process_frame await process_frame
_assert(hub.compact_layout, "the flashcards library should detect a phone-width viewport") _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 := hub.list_container.get_child(0) as BoxContainer
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_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, _assert(compact_row.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) "a phone-width flashcard row should fit inside the viewport (minimum %.1f px)" % compact_row.get_combined_minimum_size().x)
hub.size = Vector2(1280, 800) hub.size = Vector2(1280, 800)
hub._update_responsive_layout() hub._update_responsive_layout()
await process_frame await process_frame
_assert(not hub.compact_layout, "a wide viewport should restore the flashcards library's desktop row layout") _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 var wide_row := hub.list_container.get_child(0) as BoxContainer
_assert(not wide_row.vertical, "a wide viewport should lay flashcard row content out horizontally again") _assert(not wide_row.vertical, "a wide viewport should lay flashcard row content out horizontally again")
hub.free() hub.free()