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)
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=[

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.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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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