Improve mobile UI: compact bottom nav and sticky action bar
All checks were successful
Deploy / deploy (push) Successful in 2m38s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 06:22:19 +00:00
parent e7efcdfd28
commit a1c268c7ae
9 changed files with 186 additions and 97 deletions

View File

@@ -206,7 +206,7 @@ def create_app(
# because Starlette applies middleware in reverse order (last in list wraps first) # because Starlette applies middleware in reverse order (last in list wraps first)
app, rt = fast_app( app, rt = fast_app(
before=beforeware, 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"], exts=["head-support", "preload"],
static_path=static_path_for_fasthtml, static_path=static_path_for_fasthtml,
middleware=[ middleware=[

View File

@@ -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",
)

View File

@@ -19,6 +19,7 @@ from ulid import ULID
from animaltrack.models.animals import Animal from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species from animaltrack.models.reference import Location, Species
from animaltrack.selection.validation import SelectionDiff from animaltrack.selection.validation import SelectionDiff
from animaltrack.web.templates.action_bar import ActionBar
# ============================================================================= # =============================================================================
# Selection Diff Confirmation Panel # Selection Diff Confirmation Panel
@@ -334,8 +335,10 @@ def cohort_form(
event_datetime_field("cohort_datetime", datetime_value, datetime_ts), event_datetime_field("cohort_datetime", datetime_value, datetime_ts),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Create Cohort", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Create Cohort", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
@@ -463,8 +466,10 @@ def hatch_form(
event_datetime_field("hatch_datetime"), event_datetime_field("hatch_datetime"),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Record Hatch", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Record Hatch", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
@@ -586,12 +591,14 @@ def promote_form(
# Hidden fields # Hidden fields
Hidden(name="animal_id", value=animal.animal_id), Hidden(name="animal_id", value=animal.animal_id),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button - text changes for rename vs promote # Submit button in sticky action bar for mobile
Button( ActionBar(
"Save Changes" if is_rename else "Promote to Identified", Button(
type="submit", "Save Changes" if is_rename else "Promote to Identified",
cls=ButtonT.primary, type="submit",
hx_disabled_elt="this", cls=ButtonT.primary,
hx_disabled_elt="this",
),
), ),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
@@ -717,8 +724,10 @@ def tag_add_form(
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Add Tag", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Add Tag", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
@@ -893,13 +902,15 @@ def tag_end_form(
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button( ActionBar(
"End Tag", Button(
type="submit", "End Tag",
cls=ButtonT.primary, type="submit",
disabled=not active_tags, cls=ButtonT.primary,
hx_disabled_elt="this", disabled=not active_tags,
hx_disabled_elt="this",
),
), ),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
@@ -1099,8 +1110,10 @@ def attrs_form(
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Update Attributes", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Update Attributes", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
@@ -1353,8 +1366,12 @@ def outcome_form(
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Record Outcome", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"), ActionBar(
Button(
"Record Outcome", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"
),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
@@ -1534,8 +1551,12 @@ def status_correct_form(
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Correct Status", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"), ActionBar(
Button(
"Correct Status", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"
),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",

View File

@@ -5,6 +5,7 @@ from fasthtml.common import Container, Div, HttpHeader, Script, Style, Title
from starlette.requests import Request from starlette.requests import Request
from animaltrack.models.reference import UserRole 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.nav import BottomNav, BottomNavStyles
from animaltrack.web.templates.shared_scripts import slide_over_script from animaltrack.web.templates.shared_scripts import slide_over_script
from animaltrack.web.templates.sidebar import ( from animaltrack.web.templates.sidebar import (
@@ -187,6 +188,7 @@ def page(
return ( return (
Title(title), Title(title),
BottomNavStyles(), BottomNavStyles(),
ActionBarStyles(),
SidebarStyles(), SidebarStyles(),
TabStyles(), TabStyles(),
SelectStyles(), SelectStyles(),
@@ -201,14 +203,14 @@ def page(
# Event detail slide-over panel # Event detail slide-over panel
EventSlideOver(), EventSlideOver(),
# Main content with responsive padding/margin # 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 # md:ml-60 to offset for desktop sidebar
# hx-boost enables AJAX for all descendant links/forms # hx-boost enables AJAX for all descendant links/forms
Div( Div(
Container(content), Container(content),
hx_boost="true", hx_boost="true",
hx_target="body", 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 # Toast container with hx-preserve to survive body swaps for OOB toast injection
Div(id="fh-toast-container", hx_preserve=True, aria_live="polite"), Div(id="fh-toast-container", hx_preserve=True, aria_live="polite"),

View File

@@ -17,6 +17,7 @@ from ulid import ULID
from animaltrack.models.events import Event from animaltrack.models.events import Event
from animaltrack.models.reference import Location, Product 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.actions import event_datetime_field
from animaltrack.web.templates.recent_events import recent_events_section from animaltrack.web.templates.recent_events import recent_events_section
@@ -240,8 +241,10 @@ def harvest_form(
event_datetime_field("harvest_datetime"), event_datetime_field("harvest_datetime"),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Record Harvest", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Record Harvest", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
@@ -393,8 +396,10 @@ def sell_form(
event_datetime_field("sell_datetime"), event_datetime_field("sell_datetime"),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",

View File

@@ -17,6 +17,7 @@ from ulid import ULID
from animaltrack.models.events import Event from animaltrack.models.events import Event
from animaltrack.models.reference import FeedType, Location 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.actions import event_datetime_field
from animaltrack.web.templates.recent_events import recent_events_section from animaltrack.web.templates.recent_events import recent_events_section
@@ -264,8 +265,10 @@ def give_feed_form(
event_datetime_field("feed_given_datetime"), event_datetime_field("feed_given_datetime"),
# Hidden nonce # Hidden nonce
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Record Feed Given", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Record Feed Given", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
action=action, action=action,
method="post", method="post",
cls="space-y-4", cls="space-y-4",
@@ -408,8 +411,10 @@ def purchase_feed_form(
event_datetime_field("feed_purchase_datetime"), event_datetime_field("feed_purchase_datetime"),
# Hidden nonce # Hidden nonce
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Record Purchase", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Record Purchase", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
action=action, action=action,
method="post", method="post",
cls="space-y-4", cls="space-y-4",

View File

@@ -11,6 +11,7 @@ from ulid import ULID
from animaltrack.models.events import Event from animaltrack.models.events import Event
from animaltrack.models.reference import Location from animaltrack.models.reference import Location
from animaltrack.selection.validation import SelectionDiff 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.actions import event_datetime_field
from animaltrack.web.templates.recent_events import recent_events_section 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="resolver_version", value="v1"),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button in sticky action bar for mobile
Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"), ActionBar(
Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",

View File

@@ -1,11 +1,11 @@
# ABOUTME: Bottom navigation component for AnimalTrack mobile UI. # 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 fasthtml.common import A, Button, Div, Span, Style
from animaltrack.web.templates.icons import NAV_ICONS from animaltrack.web.templates.icons import NAV_ICONS
# Navigation items configuration (simplified to 4 items) # Navigation items configuration
NAV_ITEMS = [ NAV_ITEMS = [
{"id": "eggs", "label": "Eggs", "href": "/"}, {"id": "eggs", "label": "Eggs", "href": "/"},
{"id": "feed", "label": "Feed", "href": "/feed"}, {"id": "feed", "label": "Feed", "href": "/feed"},
@@ -15,53 +15,56 @@ NAV_ITEMS = [
def BottomNavStyles(): # noqa: N802 def BottomNavStyles(): # noqa: N802
"""CSS styles for bottom navigation - include in page head.""" """CSS styles for bottom navigation - supplement daisyUI btm-nav."""
return Style(""" return Style("""
/* Bottom nav industrial styling */ /* Industrial styling overrides for btm-nav */
#bottom-nav { #bottom-nav.btm-nav {
background-color: #1a1a18;
border-top: 1px solid #404040;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
} }
/* Safe area for iOS notch devices */ /* Active item golden accent */
.safe-area-pb { #bottom-nav .active,
padding-bottom: env(safe-area-inset-bottom, 0); #bottom-nav .active:hover {
color: #d97706;
border-top-color: #d97706;
background-color: rgba(217, 119, 6, 0.1);
} }
/* Active item subtle glow effect */ /* Inactive items muted */
.nav-item-active::after { #bottom-nav > *:not(.active) {
content: ''; color: #78716c;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40%;
height: 2px;
background: linear-gradient(90deg, transparent, #b8860b, transparent);
} }
/* Hover state for non-touch devices */ /* Hover state for non-touch devices */
@media (hover: hover) { @media (hover: hover) {
#bottom-nav a:hover { #bottom-nav > *:not(.active):hover {
background-color: rgba(184, 134, 11, 0.1); background-color: rgba(184, 134, 11, 0.1);
} }
} }
/* Ensure consistent icon rendering */ /* Hide on desktop */
#bottom-nav svg { @media (min-width: 768px) {
flex-shrink: 0; #bottom-nav.btm-nav {
display: none;
}
} }
/* Typography for labels */ /* Normalize button to match anchor styling in btm-nav */
#bottom-nav span { #bottom-nav button {
font-family: system-ui, -apple-system, sans-serif; border: none;
letter-spacing: 0.05em; background: transparent;
font: inherit;
padding: 0;
margin: 0;
} }
""") """)
def BottomNav(active_id: str = "eggs"): # noqa: N802 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: Args:
active_id: Currently active nav item ('eggs', 'feed', 'move') 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 is_active = item["id"] == active_id
icon_fn = NAV_ICONS[item["id"]] icon_fn = NAV_ICONS[item["id"]]
# Active: golden highlight, inactive: muted stone gray # daisyUI v4 uses 'active' class for active state
label_cls = "text-xs font-semibold tracking-wide uppercase mt-1 " cls = "active" if is_active else ""
label_cls += "text-amber-600" if is_active else "text-stone-500"
item_cls = "flex flex-col items-center justify-center py-2 px-4 " # Content: icon + label
if is_active: content = [
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(
icon_fn(active=is_active), icon_fn(active=is_active),
Span(item["label"], cls=label_cls), Span(item["label"], cls="btm-nav-label"),
cls=item_cls, ]
)
# Menu item is a button that opens the drawer # Menu item is a button that opens the drawer
if item["id"] == "menu": if item["id"] == "menu":
return Button( return Button(
inner, *content,
onclick="openMenuDrawer()", onclick="openMenuDrawer()",
cls=wrapper_cls, cls=cls,
type="button", type="button",
aria_label="Open navigation menu", aria_label="Open navigation menu",
) )
# Regular nav items are links # Regular nav items are links
return A( return A(
inner, *content,
href=item["href"], href=item["href"],
cls=wrapper_cls, cls=cls,
) )
# daisyUI btm-nav: fixed at bottom, flex layout for children
return Div( return Div(
# Top border with subtle texture effect *[nav_item(item) for item in NAV_ITEMS],
Div(cls="h-px bg-gradient-to-r from-transparent via-stone-700 to-transparent"), cls="btm-nav btm-nav-sm",
# 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",
id="bottom-nav", id="bottom-nav",
) )

View File

@@ -248,9 +248,12 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
), ),
# Drawer panel # Drawer panel
Div( Div(
# Header with close button # Header with logo and close button
Div( 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( Button(
_close_icon(), _close_icon(),
hx_on_click="closeMenuDrawer()", hx_on_click="closeMenuDrawer()",