grimoireplot 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,772 @@
1
+ # SPDX-FileCopyrightText: Copyright © 2026 Idiap Research Institute <contact@idiap.ch>
2
+ # SPDX-FileContributor: William Droz <william.droz@idiap.ch>
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Styled UI components for GrimoirePlot.
7
+
8
+ This module provides factory functions for creating consistently styled UI elements.
9
+ All appearance customization is centralized here for easy theming.
10
+ """
11
+
12
+ from typing import Callable, Any, Optional
13
+ from nicegui import ui
14
+
15
+ # ============================================================================
16
+ # THEME CONFIGURATION
17
+ # ============================================================================
18
+
19
+ # Color palette - mystical/grimoire inspired
20
+ COLORS = {
21
+ # Primary colors
22
+ "primary": "#8B5CF6", # Violet
23
+ "primary_dark": "#7C3AED",
24
+ "primary_light": "#A78BFA",
25
+ # Secondary colors
26
+ "secondary": "#06B6D4", # Cyan
27
+ "secondary_dark": "#0891B2",
28
+ "secondary_light": "#22D3EE",
29
+ # Accent colors
30
+ "accent": "#F59E0B", # Amber
31
+ "accent_dark": "#D97706",
32
+ "accent_light": "#FBBF24",
33
+ # Semantic colors
34
+ "success": "#10B981", # Emerald
35
+ "warning": "#F59E0B", # Amber
36
+ "error": "#EF4444", # Red
37
+ "info": "#3B82F6", # Blue
38
+ # Background colors (dark theme)
39
+ "bg_dark": "#0F0F1A", # Deep dark
40
+ "bg_card": "#1A1A2E", # Card background
41
+ "bg_elevated": "#252542", # Elevated elements
42
+ "bg_hover": "#2D2D4A", # Hover state
43
+ # Text colors
44
+ "text_primary": "#F8FAFC", # Near white
45
+ "text_secondary": "#94A3B8", # Muted
46
+ "text_muted": "#64748B", # Very muted
47
+ # Border colors
48
+ "border": "#3B3B5C",
49
+ "border_accent": "#8B5CF6",
50
+ }
51
+
52
+ # CSS for global styling
53
+ GLOBAL_CSS = """
54
+ :root {
55
+ --nicegui-default-padding: 0.75rem;
56
+ --nicegui-default-gap: 0.75rem;
57
+ }
58
+
59
+ /* Disable ALL transitions and animations */
60
+ *, *::before, *::after {
61
+ transition: none !important;
62
+ animation: none !important;
63
+ animation-duration: 0s !important;
64
+ }
65
+
66
+ body {
67
+ background: linear-gradient(135deg, #0F0F1A 0%, #1A1A2E 50%, #16213E 100%) !important;
68
+ min-height: 100vh;
69
+ }
70
+
71
+ /* Glassmorphism card effect */
72
+ .glass-card {
73
+ background: rgba(26, 26, 46, 0.7) !important;
74
+ backdrop-filter: blur(12px) !important;
75
+ -webkit-backdrop-filter: blur(12px) !important;
76
+ border: 1px solid rgba(139, 92, 246, 0.2) !important;
77
+ border-radius: 16px !important;
78
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
79
+ }
80
+
81
+ .glass-card:hover {
82
+ border-color: rgba(139, 92, 246, 0.4) !important;
83
+ box-shadow: 0 8px 32px rgba(139, 92, 246, 0.15) !important;
84
+ }
85
+
86
+ /* Gradient text */
87
+ .gradient-text {
88
+ background: linear-gradient(135deg, #8B5CF6 0%, #06B6D4 100%);
89
+ -webkit-background-clip: text;
90
+ -webkit-text-fill-color: transparent;
91
+ background-clip: text;
92
+ }
93
+
94
+ /* Glow effects */
95
+ .glow-primary {
96
+ box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
97
+ }
98
+
99
+ .glow-subtle {
100
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
101
+ }
102
+
103
+ /* Tab styling */
104
+ .q-tab--active {
105
+ background: linear-gradient(135deg, rgba(139, 92, 246, 0.2) 0%, rgba(6, 182, 212, 0.2) 100%) !important;
106
+ }
107
+
108
+ .q-tab__indicator {
109
+ background: linear-gradient(90deg, #8B5CF6 0%, #06B6D4 100%) !important;
110
+ height: 3px !important;
111
+ }
112
+
113
+ /* Delete badge styling */
114
+ .delete-badge {
115
+ cursor: pointer !important;
116
+ transition: all 0.2s ease !important;
117
+ opacity: 0.7;
118
+ }
119
+
120
+ .delete-badge:hover {
121
+ opacity: 1 !important;
122
+ transform: scale(1.1) !important;
123
+ }
124
+
125
+ /* Plot container */
126
+ .plot-container {
127
+ background: rgba(26, 26, 46, 0.5) !important;
128
+ border-radius: 12px !important;
129
+ border: 1px solid rgba(139, 92, 246, 0.15) !important;
130
+ overflow: hidden !important;
131
+ padding: 36px 12px 12px 12px !important;
132
+ position: relative;
133
+ }
134
+
135
+ .plot-container .js-plotly-plot,
136
+ .plot-container .plot-container.plotly,
137
+ .plot-container .svg-container {
138
+ width: 100% !important;
139
+ max-width: 100% !important;
140
+ }
141
+
142
+ .plot-container .q-badge {
143
+ position: absolute !important;
144
+ top: 6px !important;
145
+ right: 6px !important;
146
+ z-index: 10;
147
+ }
148
+
149
+ .plot-container:hover {
150
+ border-color: rgba(139, 92, 246, 0.4) !important;
151
+ box-shadow: 0 12px 40px rgba(139, 92, 246, 0.15) !important;
152
+ }
153
+
154
+ /* Scrollbar styling */
155
+ ::-webkit-scrollbar {
156
+ width: 8px;
157
+ height: 8px;
158
+ }
159
+
160
+ ::-webkit-scrollbar-track {
161
+ background: #1A1A2E;
162
+ }
163
+
164
+ ::-webkit-scrollbar-thumb {
165
+ background: linear-gradient(180deg, #8B5CF6 0%, #06B6D4 100%);
166
+ border-radius: 4px;
167
+ }
168
+
169
+ ::-webkit-scrollbar-thumb:hover {
170
+ background: linear-gradient(180deg, #A78BFA 0%, #22D3EE 100%);
171
+ }
172
+
173
+ /* Animation for new elements */
174
+ @keyframes fadeInUp {
175
+ from {
176
+ opacity: 0;
177
+ transform: translateY(20px);
178
+ }
179
+ to {
180
+ opacity: 1;
181
+ transform: translateY(0);
182
+ }
183
+ }
184
+
185
+ .animate-in {
186
+ animation: fadeInUp 0.4s ease-out;
187
+ }
188
+
189
+ /* Button gradients */
190
+ .btn-gradient-danger {
191
+ background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%) !important;
192
+ border: none !important;
193
+ }
194
+
195
+ .btn-gradient-primary {
196
+ background: linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%) !important;
197
+ border: none !important;
198
+ }
199
+ """
200
+
201
+
202
+ def setup_theme():
203
+ """Initialize the theme with custom colors and CSS.
204
+
205
+ Call this once at the start of your page.
206
+ """
207
+ # Set Quasar colors
208
+ ui.colors(
209
+ primary=COLORS["primary"],
210
+ secondary=COLORS["secondary"],
211
+ accent=COLORS["accent"],
212
+ dark=COLORS["bg_dark"],
213
+ dark_page=COLORS["bg_dark"],
214
+ positive=COLORS["success"],
215
+ negative=COLORS["error"],
216
+ info=COLORS["info"],
217
+ warning=COLORS["warning"],
218
+ )
219
+
220
+ # Add global CSS
221
+ ui.add_css(GLOBAL_CSS)
222
+
223
+ # Enable dark mode
224
+ ui.dark_mode(True)
225
+
226
+
227
+ # ============================================================================
228
+ # BUTTON FACTORIES
229
+ # ============================================================================
230
+
231
+
232
+ def create_btn_delete(on_click: Callable[[], Any]) -> ui.button:
233
+ """Create a styled delete button with icon.
234
+
235
+ Args:
236
+ on_click: Callback function when button is clicked.
237
+
238
+ Returns:
239
+ Styled delete button.
240
+ """
241
+ btn = ui.button(icon="close", on_click=on_click)
242
+ btn.props("round flat size=sm")
243
+ btn.classes(
244
+ "btn-gradient-danger text-white opacity-70 hover:opacity-100 transition-all"
245
+ )
246
+ btn.style("width: 24px; height: 24px; min-height: 24px;")
247
+ return btn
248
+
249
+
250
+ def create_btn_primary(
251
+ text: str, on_click: Optional[Callable[[], Any]] = None, icon: Optional[str] = None
252
+ ) -> ui.button:
253
+ """Create a styled primary action button.
254
+
255
+ Args:
256
+ text: Button label text.
257
+ on_click: Callback function when button is clicked.
258
+ icon: Optional icon name.
259
+
260
+ Returns:
261
+ Styled primary button.
262
+ """
263
+ btn = ui.button(text, on_click=on_click, icon=icon)
264
+ btn.props("rounded unelevated")
265
+ btn.classes("btn-gradient-primary text-white font-medium px-6")
266
+ return btn
267
+
268
+
269
+ def create_btn_secondary(
270
+ text: str, on_click: Optional[Callable[[], Any]] = None, icon: Optional[str] = None
271
+ ) -> ui.button:
272
+ """Create a styled secondary action button.
273
+
274
+ Args:
275
+ text: Button label text.
276
+ on_click: Callback function when button is clicked.
277
+ icon: Optional icon name.
278
+
279
+ Returns:
280
+ Styled secondary button.
281
+ """
282
+ btn = ui.button(text, on_click=on_click, icon=icon)
283
+ btn.props("rounded outline")
284
+ btn.classes("text-white border-violet-500 hover:bg-violet-500/20 transition-all")
285
+ return btn
286
+
287
+
288
+ def create_btn_ghost(
289
+ text: str, on_click: Optional[Callable[[], Any]] = None, icon: Optional[str] = None
290
+ ) -> ui.button:
291
+ """Create a styled ghost/flat button.
292
+
293
+ Args:
294
+ text: Button label text.
295
+ on_click: Callback function when button is clicked.
296
+ icon: Optional icon name.
297
+
298
+ Returns:
299
+ Styled ghost button.
300
+ """
301
+ btn = ui.button(text, on_click=on_click, icon=icon)
302
+ btn.props("flat rounded")
303
+ btn.classes("text-slate-300 hover:text-white hover:bg-white/10 transition-all")
304
+ return btn
305
+
306
+
307
+ def create_btn_danger(
308
+ text: str, on_click: Optional[Callable[[], Any]] = None, icon: Optional[str] = None
309
+ ) -> ui.button:
310
+ """Create a styled danger/destructive button.
311
+
312
+ Args:
313
+ text: Button label text.
314
+ on_click: Callback function when button is clicked.
315
+ icon: Optional icon name.
316
+
317
+ Returns:
318
+ Styled danger button.
319
+ """
320
+ btn = ui.button(text, on_click=on_click, icon=icon)
321
+ btn.props("rounded unelevated")
322
+ btn.classes("btn-gradient-danger text-white font-medium")
323
+ return btn
324
+
325
+
326
+ # ============================================================================
327
+ # BADGE FACTORIES
328
+ # ============================================================================
329
+
330
+
331
+ def create_delete_badge(on_click: Callable[[Any], Any]) -> ui.badge:
332
+ """Create a floating delete badge (x button).
333
+
334
+ Args:
335
+ on_click: Callback function for click event.
336
+
337
+ Returns:
338
+ Styled delete badge.
339
+ """
340
+ badge = ui.badge("x")
341
+ badge.props("floating rounded color=red")
342
+ badge.classes("delete-badge cursor-pointer")
343
+ badge.style("font-size: 10px; padding: 2px 6px;")
344
+ badge.on("click", on_click)
345
+ return badge
346
+
347
+
348
+ def create_count_badge(count: int | str, color: str = "primary") -> ui.badge:
349
+ """Create a count/notification badge.
350
+
351
+ Args:
352
+ count: Number or text to display.
353
+ color: Badge color (Quasar color name).
354
+
355
+ Returns:
356
+ Styled count badge.
357
+ """
358
+ badge = ui.badge(str(count))
359
+ badge.props(f"rounded color={color}")
360
+ badge.classes("text-xs font-bold")
361
+ return badge
362
+
363
+
364
+ # ============================================================================
365
+ # CARD & CONTAINER FACTORIES
366
+ # ============================================================================
367
+
368
+
369
+ def create_glass_card() -> ui.card:
370
+ """Create a glassmorphism styled card container.
371
+
372
+ Returns:
373
+ Styled glass card (use as context manager).
374
+ """
375
+ card = ui.card()
376
+ card.classes("glass-card animate-in")
377
+ return card
378
+
379
+
380
+ def create_elevated_card() -> ui.card:
381
+ """Create an elevated card with subtle shadow.
382
+
383
+ Returns:
384
+ Styled elevated card (use as context manager).
385
+ """
386
+ card = ui.card()
387
+ card.classes("glow-subtle rounded-xl")
388
+ card.style(
389
+ f"background: {COLORS['bg_card']}; border: 1px solid {COLORS['border']};"
390
+ )
391
+ return card
392
+
393
+
394
+ def create_plot_container() -> ui.element:
395
+ """Create a container for plot display.
396
+
397
+ Returns:
398
+ Styled div container for plots.
399
+ """
400
+ container = ui.element("div")
401
+ container.classes("plot-container p-2 relative")
402
+ return container
403
+
404
+
405
+ def create_section(title: Optional[str] = None) -> ui.element:
406
+ """Create a styled section with optional title.
407
+
408
+ Args:
409
+ title: Optional section title.
410
+
411
+ Returns:
412
+ Section element (use as context manager).
413
+ """
414
+ section = ui.element("section")
415
+ section.classes("mb-6")
416
+ if title:
417
+ with section:
418
+ create_heading(title, level=2)
419
+ return section
420
+
421
+
422
+ # ============================================================================
423
+ # TEXT & LABEL FACTORIES
424
+ # ============================================================================
425
+
426
+
427
+ def create_heading(text: str, level: int = 1, gradient: bool = True) -> ui.label:
428
+ """Create a styled heading.
429
+
430
+ Args:
431
+ text: Heading text.
432
+ level: Heading level (1-4).
433
+ gradient: Whether to apply gradient text effect.
434
+
435
+ Returns:
436
+ Styled label as heading.
437
+ """
438
+ sizes = {
439
+ 1: "text-4xl font-bold",
440
+ 2: "text-2xl font-semibold",
441
+ 3: "text-xl font-medium",
442
+ 4: "text-lg font-medium",
443
+ }
444
+
445
+ label = ui.label(text)
446
+ label.classes(sizes.get(level, sizes[1]))
447
+ if gradient:
448
+ label.classes("gradient-text")
449
+ else:
450
+ label.style(f"color: {COLORS['text_primary']};")
451
+ return label
452
+
453
+
454
+ def create_label(text: str, muted: bool = False) -> ui.label:
455
+ """Create a styled text label.
456
+
457
+ Args:
458
+ text: Label text.
459
+ muted: Whether to use muted/secondary styling.
460
+
461
+ Returns:
462
+ Styled label.
463
+ """
464
+ label = ui.label(text)
465
+ if muted:
466
+ label.classes("text-slate-400")
467
+ else:
468
+ label.style(f"color: {COLORS['text_primary']};")
469
+ return label
470
+
471
+
472
+ def create_empty_state(message: str, icon: str = "auto_awesome") -> ui.element:
473
+ """Create a styled empty state message.
474
+
475
+ Args:
476
+ message: Message to display.
477
+ icon: Icon name to show.
478
+
479
+ Returns:
480
+ Container with empty state styling.
481
+ """
482
+ with ui.column().classes(
483
+ "items-center justify-center py-12 opacity-60"
484
+ ) as container:
485
+ ui.icon(icon, size="xl").classes("text-violet-400 mb-4")
486
+ ui.label(message).classes("text-slate-400 text-lg text-center")
487
+ return container
488
+
489
+
490
+ # ============================================================================
491
+ # TAB FACTORIES
492
+ # ============================================================================
493
+
494
+
495
+ def create_tabs() -> ui.tabs:
496
+ """Create styled tabs container.
497
+
498
+ Returns:
499
+ Styled tabs component.
500
+ """
501
+ tabs = ui.tabs()
502
+ tabs.classes("w-full")
503
+ tabs.props("dense indicator-color=transparent active-color=white")
504
+ tabs.style("""
505
+ background: rgba(26, 26, 46, 0.5);
506
+ border-radius: 12px;
507
+ padding: 4px;
508
+ """)
509
+ return tabs
510
+
511
+
512
+ def create_tab(name: str, icon: Optional[str] = None) -> ui.tab:
513
+ """Create a styled tab.
514
+
515
+ Args:
516
+ name: Tab name/label.
517
+ icon: Optional icon name.
518
+
519
+ Returns:
520
+ Styled tab component.
521
+ """
522
+ if icon:
523
+ tab = ui.tab(name, icon=icon)
524
+ else:
525
+ tab = ui.tab(name)
526
+ tab.classes("rounded-lg transition-all")
527
+ tab.style("color: #94A3B8;")
528
+ return tab
529
+
530
+
531
+ def create_tab_with_delete(name: str, on_delete: Callable[[Any], Any]) -> ui.tab:
532
+ """Create a tab with a delete badge.
533
+
534
+ Args:
535
+ name: Tab name/label.
536
+ on_delete: Callback for delete action.
537
+
538
+ Returns:
539
+ Tab with delete badge.
540
+ """
541
+ with ui.tab(name).classes("rounded-lg transition-all") as tab:
542
+ tab.style("color: #94A3B8;")
543
+ create_delete_badge(on_delete)
544
+ return tab
545
+
546
+
547
+ def create_tab_panels(tabs: ui.tabs, value=None) -> ui.tab_panels:
548
+ """Create styled tab panels container.
549
+
550
+ Args:
551
+ tabs: Associated tabs component.
552
+ value: Initial selected tab.
553
+
554
+ Returns:
555
+ Styled tab panels.
556
+ """
557
+ panels = ui.tab_panels(tabs, value=value)
558
+ panels.classes("w-full")
559
+ panels.style("background: transparent;")
560
+ return panels
561
+
562
+
563
+ def create_tab_panel(name: str) -> ui.tab_panel:
564
+ """Create a styled tab panel.
565
+
566
+ Args:
567
+ name: Panel name (must match tab name).
568
+
569
+ Returns:
570
+ Styled tab panel.
571
+ """
572
+ panel = ui.tab_panel(name)
573
+ panel.classes("p-4")
574
+ return panel
575
+
576
+
577
+ # ============================================================================
578
+ # GRID FACTORIES
579
+ # ============================================================================
580
+
581
+
582
+ def create_plot_grid(columns: int = 2) -> ui.grid:
583
+ """Create a responsive grid for plots.
584
+
585
+ Args:
586
+ columns: Number of columns.
587
+
588
+ Returns:
589
+ Styled grid component.
590
+ """
591
+ grid = ui.grid(columns=columns)
592
+ grid.classes("w-full gap-6 p-4")
593
+ return grid
594
+
595
+
596
+ def create_flex_row(gap: str = "4") -> ui.row:
597
+ """Create a styled flex row.
598
+
599
+ Args:
600
+ gap: Tailwind gap value.
601
+
602
+ Returns:
603
+ Styled row component.
604
+ """
605
+ row = ui.row()
606
+ row.classes(f"items-center gap-{gap}")
607
+ return row
608
+
609
+
610
+ # ============================================================================
611
+ # DIALOG FACTORIES
612
+ # ============================================================================
613
+
614
+
615
+ def create_confirm_dialog(
616
+ title: str,
617
+ message: str,
618
+ on_confirm: Callable[[], Any],
619
+ confirm_text: str = "Delete",
620
+ cancel_text: str = "Cancel",
621
+ ) -> ui.dialog:
622
+ """Create a styled confirmation dialog.
623
+
624
+ Args:
625
+ title: Dialog title.
626
+ message: Confirmation message.
627
+ on_confirm: Callback on confirm.
628
+ confirm_text: Confirm button text.
629
+ cancel_text: Cancel button text.
630
+
631
+ Returns:
632
+ Dialog component (call .open() to show).
633
+ """
634
+ with ui.dialog() as dialog, create_glass_card():
635
+ ui.label(title).classes("text-xl font-bold text-white mb-2")
636
+ ui.label(message).classes("text-slate-300 mb-6")
637
+
638
+ with ui.row().classes("justify-end gap-3"):
639
+ create_btn_ghost(cancel_text, on_click=dialog.close)
640
+ create_btn_danger(
641
+ confirm_text, on_click=lambda: (on_confirm(), dialog.close())
642
+ )
643
+
644
+ return dialog
645
+
646
+
647
+ # ============================================================================
648
+ # PLOT DISPLAY
649
+ # ============================================================================
650
+
651
+
652
+ def create_plotly_chart(fig_data: dict) -> ui.plotly:
653
+ """Create a styled Plotly chart.
654
+
655
+ Applies dark theme styling to the chart.
656
+
657
+ Args:
658
+ fig_data: Plotly figure data dict.
659
+
660
+ Returns:
661
+ Styled plotly component.
662
+ """
663
+ # Ensure dark theme layout
664
+ if "layout" not in fig_data:
665
+ fig_data["layout"] = {}
666
+
667
+ layout = fig_data["layout"]
668
+ layout.setdefault("paper_bgcolor", "rgba(0,0,0,0)")
669
+ layout.setdefault("plot_bgcolor", "rgba(26, 26, 46, 0.3)")
670
+ layout.setdefault("font", {"color": "#94A3B8"})
671
+ layout.setdefault("height", 450)
672
+ layout.setdefault("autosize", True)
673
+
674
+ # Disable Plotly animations
675
+ layout.setdefault("transition", {"duration": 0})
676
+
677
+ # Style axes
678
+ for axis in ["xaxis", "yaxis"]:
679
+ if axis not in layout:
680
+ layout[axis] = {}
681
+ layout[axis].setdefault("gridcolor", "rgba(139, 92, 246, 0.1)")
682
+ layout[axis].setdefault("linecolor", "rgba(139, 92, 246, 0.3)")
683
+ layout[axis].setdefault("tickcolor", "#64748B")
684
+
685
+ # Style legend
686
+ layout.setdefault(
687
+ "legend",
688
+ {
689
+ "bgcolor": "rgba(26, 26, 46, 0.8)",
690
+ "bordercolor": "rgba(139, 92, 246, 0.2)",
691
+ "font": {"color": "#94A3B8"},
692
+ },
693
+ )
694
+
695
+ # Enable responsive mode via Plotly config
696
+ if "config" not in fig_data:
697
+ fig_data["config"] = {}
698
+ fig_data["config"].setdefault("responsive", True)
699
+
700
+ chart = ui.plotly(fig_data).classes("w-full").style("height: 450px;")
701
+ return chart
702
+
703
+
704
+ # ============================================================================
705
+ # HEADER / NAVIGATION
706
+ # ============================================================================
707
+
708
+
709
+ def create_header(
710
+ title: str = "GrimoirePlot", subtitle: Optional[str] = None
711
+ ) -> ui.element:
712
+ """Create a styled page header.
713
+
714
+ Args:
715
+ title: Main title text.
716
+ subtitle: Optional subtitle.
717
+
718
+ Returns:
719
+ Header container element.
720
+ """
721
+ with ui.element("header").classes("mb-8 text-center py-6") as header:
722
+ # Logo/icon
723
+ ui.icon("auto_stories", size="xl").classes("text-violet-400 mb-2")
724
+
725
+ # Title with gradient
726
+ create_heading(title, level=1, gradient=True)
727
+
728
+ if subtitle:
729
+ ui.label(subtitle).classes("text-slate-400 mt-2")
730
+
731
+ return header
732
+
733
+
734
+ def create_footer() -> ui.element:
735
+ """Create a styled page footer.
736
+
737
+ Returns:
738
+ Footer container element.
739
+ """
740
+ with ui.element("footer").classes("mt-auto py-4 text-center") as footer:
741
+ ui.label("GrimoirePlot").classes("text-slate-500 text-sm")
742
+ return footer
743
+
744
+
745
+ # ============================================================================
746
+ # LAYOUT HELPERS
747
+ # ============================================================================
748
+
749
+
750
+ def create_page_container() -> ui.element:
751
+ """Create the main page container with proper styling.
752
+
753
+ Returns:
754
+ Container element (use as context manager).
755
+ """
756
+ container = ui.element("div")
757
+ container.classes("container mx-auto px-4 py-6 min-h-screen flex flex-col")
758
+ return container
759
+
760
+
761
+ def create_divider() -> ui.element:
762
+ """Create a styled horizontal divider.
763
+
764
+ Returns:
765
+ Divider element.
766
+ """
767
+ div = ui.element("hr")
768
+ div.classes("border-0 h-px my-6")
769
+ div.style(
770
+ "background: linear-gradient(90deg, transparent, rgba(139, 92, 246, 0.3), transparent);"
771
+ )
772
+ return div