From a1c268c7ae44ef2ef56d1b1181356e996c7727db Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 15 Jan 2026 06:22:19 +0000 Subject: [PATCH] Improve mobile UI: compact bottom nav and sticky action bar - Enable daisyUI and use btm-nav component for compact bottom navigation - Add sticky ActionBar component for form submit buttons on mobile - Form buttons now float above the bottom nav, preventing obscuring - Update all form templates (actions, eggs, feed, move) to use ActionBar - Menu drawer header now shows AnimalTrack + version like desktop sidebar Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/web/app.py | 2 +- src/animaltrack/web/templates/action_bar.py | 64 +++++++++++++ src/animaltrack/web/templates/actions.py | 71 +++++++++----- src/animaltrack/web/templates/base.py | 6 +- src/animaltrack/web/templates/eggs.py | 13 ++- src/animaltrack/web/templates/feed.py | 13 ++- src/animaltrack/web/templates/move.py | 7 +- src/animaltrack/web/templates/nav.py | 100 +++++++++----------- src/animaltrack/web/templates/sidebar.py | 7 +- 9 files changed, 186 insertions(+), 97 deletions(-) create mode 100644 src/animaltrack/web/templates/action_bar.py diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 74e8b49..37523af 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -206,7 +206,7 @@ def create_app( # because Starlette applies middleware in reverse order (last in list wraps first) app, rt = fast_app( before=beforeware, - hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config + hdrs=(*Theme.slate.headers(daisy=True), htmx_config), # Dark theme + daisyUI exts=["head-support", "preload"], static_path=static_path_for_fasthtml, middleware=[ diff --git a/src/animaltrack/web/templates/action_bar.py b/src/animaltrack/web/templates/action_bar.py new file mode 100644 index 0000000..b902667 --- /dev/null +++ b/src/animaltrack/web/templates/action_bar.py @@ -0,0 +1,64 @@ +# ABOUTME: Sticky action bar for mobile form submission. +# ABOUTME: Fixed above dock on mobile, inline on desktop. + +from fasthtml.common import Div, Style + + +def ActionBarStyles(): # noqa: N802 + """CSS styles for sticky action bar - include in page head.""" + return Style(""" + /* Action bar sticks above btm-nav on mobile */ + .action-bar { + position: fixed; + /* btm-nav-sm height ~4rem + safe-area-inset-bottom */ + bottom: calc(4rem + env(safe-area-inset-bottom, 0)); + left: 0; + right: 0; + z-index: 45; /* Below btm-nav */ + padding: 0.75rem 1rem; + background-color: rgba(20, 20, 19, 0.95); + backdrop-filter: blur(8px); + border-top: 1px solid #404040; + display: flex; + gap: 0.5rem; + justify-content: flex-end; + } + + /* Desktop: inline, no fixed positioning */ + @media (min-width: 768px) { + .action-bar { + position: static; + padding: 0; + background-color: transparent; + backdrop-filter: none; + border-top: none; + margin-top: 1rem; + } + } + """) + + +def ActionBar(*buttons): # noqa: N802 + """ + Sticky action bar for mobile forms. + + On mobile: Fixed position above the dock (bottom nav). + On desktop: Inline at end of form. + + Usage: + # Buttons should have form="form-id" attribute to submit external forms + ActionBar( + Button("Cancel", cls=ButtonT.ghost, onclick="history.back()"), + Button("Save", type="submit", form="my-form", cls=ButtonT.primary), + ) + + Args: + *buttons: Button components to render in the action bar + + Returns: + FT component with the action bar + """ + return Div( + *buttons, + cls="action-bar", + ) diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py index c5e79dd..cf840f8 100644 --- a/src/animaltrack/web/templates/actions.py +++ b/src/animaltrack/web/templates/actions.py @@ -19,6 +19,7 @@ from ulid import ULID from animaltrack.models.animals import Animal from animaltrack.models.reference import Location, Species from animaltrack.selection.validation import SelectionDiff +from animaltrack.web.templates.action_bar import ActionBar # ============================================================================= # Selection Diff Confirmation Panel @@ -334,8 +335,10 @@ def cohort_form( event_datetime_field("cohort_datetime", datetime_value, datetime_ts), # Hidden nonce for idempotency Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Create Cohort", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Create Cohort", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", @@ -463,8 +466,10 @@ def hatch_form( event_datetime_field("hatch_datetime"), # Hidden nonce for idempotency Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Record Hatch", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Record Hatch", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", @@ -586,12 +591,14 @@ def promote_form( # Hidden fields Hidden(name="animal_id", value=animal.animal_id), Hidden(name="nonce", value=str(ULID())), - # Submit button - text changes for rename vs promote - Button( - "Save Changes" if is_rename else "Promote to Identified", - type="submit", - cls=ButtonT.primary, - hx_disabled_elt="this", + # Submit button in sticky action bar for mobile + ActionBar( + Button( + "Save Changes" if is_rename else "Promote to Identified", + type="submit", + cls=ButtonT.primary, + hx_disabled_elt="this", + ), ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, @@ -717,8 +724,10 @@ def tag_add_form( Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Add Tag", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Add Tag", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", @@ -893,13 +902,15 @@ def tag_end_form( Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), - # Submit button - Button( - "End Tag", - type="submit", - cls=ButtonT.primary, - disabled=not active_tags, - hx_disabled_elt="this", + # Submit button in sticky action bar for mobile + ActionBar( + Button( + "End Tag", + type="submit", + cls=ButtonT.primary, + disabled=not active_tags, + hx_disabled_elt="this", + ), ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, @@ -1099,8 +1110,10 @@ def attrs_form( Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Update Attributes", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Update Attributes", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", @@ -1353,8 +1366,12 @@ def outcome_form( Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Record Outcome", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button( + "Record Outcome", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this" + ), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", @@ -1534,8 +1551,12 @@ def status_correct_form( Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Correct Status", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button( + "Correct Status", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this" + ), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index 74df939..2b93917 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -5,6 +5,7 @@ from fasthtml.common import Container, Div, HttpHeader, Script, Style, Title from starlette.requests import Request from animaltrack.models.reference import UserRole +from animaltrack.web.templates.action_bar import ActionBarStyles from animaltrack.web.templates.nav import BottomNav, BottomNavStyles from animaltrack.web.templates.shared_scripts import slide_over_script from animaltrack.web.templates.sidebar import ( @@ -187,6 +188,7 @@ def page( return ( Title(title), BottomNavStyles(), + ActionBarStyles(), SidebarStyles(), TabStyles(), SelectStyles(), @@ -201,14 +203,14 @@ def page( # Event detail slide-over panel EventSlideOver(), # Main content with responsive padding/margin - # pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav) + # pb-28 for mobile (dock ~56px + action bar ~56px), md:pb-4 for desktop # md:ml-60 to offset for desktop sidebar # hx-boost enables AJAX for all descendant links/forms Div( Container(content), hx_boost="true", hx_target="body", - cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100", + cls="pb-28 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100", ), # Toast container with hx-preserve to survive body swaps for OOB toast injection Div(id="fh-toast-container", hx_preserve=True, aria_live="polite"), diff --git a/src/animaltrack/web/templates/eggs.py b/src/animaltrack/web/templates/eggs.py index 7227fdd..0545a85 100644 --- a/src/animaltrack/web/templates/eggs.py +++ b/src/animaltrack/web/templates/eggs.py @@ -17,6 +17,7 @@ from ulid import ULID from animaltrack.models.events import Event from animaltrack.models.reference import Location, Product +from animaltrack.web.templates.action_bar import ActionBar from animaltrack.web.templates.actions import event_datetime_field from animaltrack.web.templates.recent_events import recent_events_section @@ -240,8 +241,10 @@ def harvest_form( event_datetime_field("harvest_datetime"), # Hidden nonce for idempotency Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Record Harvest", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Record Harvest", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", @@ -393,8 +396,10 @@ def sell_form( event_datetime_field("sell_datetime"), # Hidden nonce for idempotency Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", diff --git a/src/animaltrack/web/templates/feed.py b/src/animaltrack/web/templates/feed.py index a54a4df..1cab58a 100644 --- a/src/animaltrack/web/templates/feed.py +++ b/src/animaltrack/web/templates/feed.py @@ -17,6 +17,7 @@ from ulid import ULID from animaltrack.models.events import Event from animaltrack.models.reference import FeedType, Location +from animaltrack.web.templates.action_bar import ActionBar from animaltrack.web.templates.actions import event_datetime_field from animaltrack.web.templates.recent_events import recent_events_section @@ -264,8 +265,10 @@ def give_feed_form( event_datetime_field("feed_given_datetime"), # Hidden nonce Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Record Feed Given", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Record Feed Given", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), action=action, method="post", cls="space-y-4", @@ -408,8 +411,10 @@ def purchase_feed_form( event_datetime_field("feed_purchase_datetime"), # Hidden nonce Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Record Purchase", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Record Purchase", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), action=action, method="post", cls="space-y-4", diff --git a/src/animaltrack/web/templates/move.py b/src/animaltrack/web/templates/move.py index 2df919d..e8911a6 100644 --- a/src/animaltrack/web/templates/move.py +++ b/src/animaltrack/web/templates/move.py @@ -11,6 +11,7 @@ from ulid import ULID from animaltrack.models.events import Event from animaltrack.models.reference import Location from animaltrack.selection.validation import SelectionDiff +from animaltrack.web.templates.action_bar import ActionBar from animaltrack.web.templates.actions import event_datetime_field from animaltrack.web.templates.recent_events import recent_events_section @@ -173,8 +174,10 @@ def move_form( Hidden(name="resolver_version", value="v1"), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), - # Submit button - Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + # Submit button in sticky action bar for mobile + ActionBar( + Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), + ), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", diff --git a/src/animaltrack/web/templates/nav.py b/src/animaltrack/web/templates/nav.py index 6ab0823..3c992d9 100644 --- a/src/animaltrack/web/templates/nav.py +++ b/src/animaltrack/web/templates/nav.py @@ -1,11 +1,11 @@ # ABOUTME: Bottom navigation component for AnimalTrack mobile UI. -# ABOUTME: Industrial farm aesthetic with large touch targets and high contrast. +# ABOUTME: Uses daisyUI btm-nav component for compact, mobile-friendly navigation. from fasthtml.common import A, Button, Div, Span, Style from animaltrack.web.templates.icons import NAV_ICONS -# Navigation items configuration (simplified to 4 items) +# Navigation items configuration NAV_ITEMS = [ {"id": "eggs", "label": "Eggs", "href": "/"}, {"id": "feed", "label": "Feed", "href": "/feed"}, @@ -15,53 +15,56 @@ NAV_ITEMS = [ def BottomNavStyles(): # noqa: N802 - """CSS styles for bottom navigation - include in page head.""" + """CSS styles for bottom navigation - supplement daisyUI btm-nav.""" return Style(""" - /* Bottom nav industrial styling */ - #bottom-nav { + /* Industrial styling overrides for btm-nav */ + #bottom-nav.btm-nav { + background-color: #1a1a18; + border-top: 1px solid #404040; box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5); } - /* Safe area for iOS notch devices */ - .safe-area-pb { - padding-bottom: env(safe-area-inset-bottom, 0); + /* Active item golden accent */ + #bottom-nav .active, + #bottom-nav .active:hover { + color: #d97706; + border-top-color: #d97706; + background-color: rgba(217, 119, 6, 0.1); } - /* Active item subtle glow effect */ - .nav-item-active::after { - content: ''; - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 40%; - height: 2px; - background: linear-gradient(90deg, transparent, #b8860b, transparent); + /* Inactive items muted */ + #bottom-nav > *:not(.active) { + color: #78716c; } /* Hover state for non-touch devices */ @media (hover: hover) { - #bottom-nav a:hover { + #bottom-nav > *:not(.active):hover { background-color: rgba(184, 134, 11, 0.1); } } - /* Ensure consistent icon rendering */ - #bottom-nav svg { - flex-shrink: 0; + /* Hide on desktop */ + @media (min-width: 768px) { + #bottom-nav.btm-nav { + display: none; + } } - /* Typography for labels */ - #bottom-nav span { - font-family: system-ui, -apple-system, sans-serif; - letter-spacing: 0.05em; + /* Normalize button to match anchor styling in btm-nav */ + #bottom-nav button { + border: none; + background: transparent; + font: inherit; + padding: 0; + margin: 0; } """) def BottomNav(active_id: str = "eggs"): # noqa: N802 """ - Fixed bottom navigation bar for AnimalTrack (mobile only). + Fixed bottom navigation bar using daisyUI btm-nav (mobile only). Args: active_id: Currently active nav item ('eggs', 'feed', 'move') @@ -74,52 +77,35 @@ def BottomNav(active_id: str = "eggs"): # noqa: N802 is_active = item["id"] == active_id icon_fn = NAV_ICONS[item["id"]] - # Active: golden highlight, inactive: muted stone gray - label_cls = "text-xs font-semibold tracking-wide uppercase mt-1 " - label_cls += "text-amber-600" if is_active else "text-stone-500" + # daisyUI v4 uses 'active' class for active state + cls = "active" if is_active else "" - item_cls = "flex flex-col items-center justify-center py-2 px-4 " - if is_active: - item_cls += "bg-stone-900/50 rounded-lg" - - wrapper_cls = ( - "relative flex-1 flex items-center justify-center min-h-[64px] " - "transition-all duration-150 active:scale-95 " - ) - if is_active: - wrapper_cls += "nav-item-active" - - inner = Div( + # Content: icon + label + content = [ icon_fn(active=is_active), - Span(item["label"], cls=label_cls), - cls=item_cls, - ) + Span(item["label"], cls="btm-nav-label"), + ] # Menu item is a button that opens the drawer if item["id"] == "menu": return Button( - inner, + *content, onclick="openMenuDrawer()", - cls=wrapper_cls, + cls=cls, type="button", aria_label="Open navigation menu", ) # Regular nav items are links return A( - inner, + *content, href=item["href"], - cls=wrapper_cls, + cls=cls, ) + # daisyUI btm-nav: fixed at bottom, flex layout for children return Div( - # Top border with subtle texture effect - Div(cls="h-px bg-gradient-to-r from-transparent via-stone-700 to-transparent"), - # Nav container - Div( - *[nav_item(item) for item in NAV_ITEMS], - cls="flex items-stretch bg-[#1a1a18] safe-area-pb", - ), - cls="fixed bottom-0 left-0 right-0 z-50 md:hidden", + *[nav_item(item) for item in NAV_ITEMS], + cls="btm-nav btm-nav-sm", id="bottom-nav", ) diff --git a/src/animaltrack/web/templates/sidebar.py b/src/animaltrack/web/templates/sidebar.py index 78aadf6..90ddcc5 100644 --- a/src/animaltrack/web/templates/sidebar.py +++ b/src/animaltrack/web/templates/sidebar.py @@ -248,9 +248,12 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802 ), # Drawer panel Div( - # Header with close button + # Header with logo and close button Div( - Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"), + Div( + Div("ANIMALTRACK", cls="text-amber-600 font-bold tracking-wider text-sm"), + Div(get_build_info(), cls="text-stone-600 text-[10px] tracking-wide"), + ), Button( _close_icon(), hx_on_click="closeMenuDrawer()",