Spaces:
Running
Running
Sync ShopStack 2026-06-15 round 2: primary-nav More, undo bar, freshness stamps, safe_render_html, home-flow TabContext
af69759 verified | from __future__ import annotations | |
| import os | |
| # HF Spaces: ensure DB_PATH defaults to a writable location before | |
| # shopstack.config instantiates Settings() at module import time. | |
| # Only do this on Spaces (SPACE_ID is set by the HF runtime) β locally | |
| # this must NOT override shopstack.config's data/shopstack.db default, | |
| # or every write silently lands in a stray ./shopstack.db at repo root. | |
| if os.environ.get("SPACE_ID"): | |
| os.environ.setdefault("SHOPSTACK_DB_PATH", "shopstack.db") | |
| import gradio as gr | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| from starlette.requests import Request | |
| from starlette.responses import Response | |
| class PermissionsPolicyMiddleware(BaseHTTPMiddleware): | |
| """Add a restrictive Permissions-Policy header to all responses. | |
| The policy restricts access to sensitive browser APIs by default. | |
| Only features needed by the app should be explicitly allowed. | |
| """ | |
| # Valid Permissions-Policy features (per W3C spec) | |
| # We disable all by default. Enable only what the app actually uses. | |
| PERMISSIONS_POLICY = ( | |
| "accelerometer=(), " | |
| "ambient-light-sensor=(), " | |
| "autoplay=(), " | |
| "battery=(), " | |
| "camera=(), " | |
| "display-capture=(), " | |
| "document-domain=(), " | |
| "encrypted-media=(), " | |
| "fullscreen=(), " | |
| "gamepad=(), " | |
| "geolocation=(), " | |
| "gyroscope=(), " | |
| "hid=(), " | |
| "identity-credentials-get=(), " | |
| "idle-detection=(), " | |
| "keyboard-map=(), " | |
| "local-fonts=(), " | |
| "magnetometer=(), " | |
| "microphone=(), " | |
| "midi=(), " | |
| "otp-credentials=(), " | |
| "payment=(), " | |
| "picture-in-picture=(), " | |
| "publickey-credentials-create=(), " | |
| "publickey-credentials-get=(), " | |
| "screen-wake-lock=(), " | |
| "serial=(), " | |
| "speaker-selection=(), " | |
| "sync-xhr=(), " | |
| "usb=(), " | |
| "web-share=(), " | |
| "window-management=(), " | |
| "xr-spatial-tracking=()" | |
| ) | |
| async def dispatch(self, request: Request, call_next): | |
| response = await call_next(request) | |
| response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY | |
| return response | |
| def _install_permissions_policy_middleware(app: gr.Blocks) -> None: | |
| """Install the Permissions-Policy middleware on the Gradio app's FastAPI instance.""" | |
| fastapi_app = app.app | |
| fastapi_app.add_middleware(PermissionsPolicyMiddleware) | |
| def _install_post_launch_hooks(app: gr.Blocks) -> None: | |
| """Install hooks that re-mount PWA routes and middleware after Gradio's launch() recreates the FastAPI app.""" | |
| original_launch = app.launch | |
| def wrapped_launch(*args, **kwargs): | |
| result = original_launch(*args, **kwargs) | |
| # Re-mount PWA routes and middleware after launch() creates a new FastAPI app | |
| from shopstack.ui.pwa_mount import mount_pwa_static | |
| from shopstack.services.health_mount import mount_health_endpoint | |
| from shopstack.app_context import db | |
| mount_pwa_static(app) | |
| mount_health_endpoint(app, db) | |
| _install_permissions_policy_middleware(app) | |
| return result | |
| app.launch = wrapped_launch | |
| from shopstack.ui.header import header_block, pwa_head_html | |
| from shopstack.ui.theme import CSS | |
| from shopstack.ui.tabs.context import TabContext | |
| from shopstack.ui.tabs.registry import build_all_tabs | |
| from shopstack.ui.household_settings import build_household_settings | |
| from shopstack.ui.tabs.onboarding import build_onboarding_wizard | |
| from shopstack.ui.locale_save import build_locale_save | |
| from shopstack.ui.pwa_mount import mount_pwa_static | |
| from shopstack.ui.runtime_status import build_runtime_status | |
| from shopstack.services.sms_webhook import mount_sms_webhook | |
| from shopstack.services.health_mount import mount_health_endpoint | |
| from shopstack.services.global_search_mount import mount_global_search | |
| from shopstack.services.privacy_mount import mount_privacy_endpoints | |
| from shopstack.services.undo_mount import mount_undo_endpoint | |
| from shopstack.services.whoami_mount import mount_whoami_endpoint | |
| from shopstack.services.data_retention import ( | |
| render_privacy_panel_html, | |
| render_privacy_panel_script, | |
| ) | |
| from shopstack.app_context import ( | |
| APP_DESCRIPTION, | |
| APP_NAME, | |
| current_user_id, | |
| db, | |
| tools, | |
| providers, | |
| planner, | |
| ) | |
| from shopstack.services.i18n import load_locale_preference | |
| def _install_post_launch_hooks(app: gr.Blocks) -> None: | |
| """Ensure root routes are restored after Gradio recreates the FastAPI app. | |
| Gradio's ``launch()`` rebuilds ``app.app``. That means any routes | |
| mounted during ``build_app()`` are lost unless we mount them again | |
| after launch. HF Spaces follows the same launch path, so this wrapper | |
| keeps ``/sw.js``, ``/manifest.json``, and ``/health/ui`` reachable | |
| both locally and on Spaces. | |
| """ | |
| original_launch = app.launch | |
| def _launch_with_post_hooks(*args, **kwargs): | |
| result = original_launch(*args, **kwargs) | |
| mount_pwa_static(app) | |
| mount_health_endpoint(app, db) | |
| mount_whoami_endpoint(app) | |
| return result | |
| app.launch = _launch_with_post_hooks | |
| def build_app() -> gr.Blocks: | |
| """Compose the ShopStack app β pure composition, no business logic. | |
| All tab builders are dispatched via ``shopstack.ui.tabs.registry``, | |
| which iterates ``module_registry.TAB_ORDER`` to determine what tabs | |
| exist and what order they appear in. This makes the module registry | |
| the single source of truth for tab wiring. | |
| Architecture: | |
| * ``gr.Blocks`` is the root container. | |
| * ``mount_pwa_static()`` mounts shopstack/static/ at /static/*. | |
| * ``mount_sms_webhook()`` mounts /api/sms/incoming. | |
| * ``header_block()`` renders the top header. | |
| * ``build_all_tabs()`` iterates TAB_ORDER and calls each builder. | |
| * ``build_household_settings()`` renders the workspace admin panel. | |
| * The tail block wires cross-tab event handlers. | |
| Adding a new tab does NOT require editing this file β register it | |
| in ``module_registry.TAB_ORDER`` and ``shopstack.ui.tabs.registry``. | |
| """ | |
| with gr.Blocks(title=APP_NAME) as app: | |
| mount_sms_webhook(app) | |
| mount_global_search(app) | |
| mount_privacy_endpoints(app) | |
| mount_undo_endpoint(app) | |
| # /api/whoami is a read-only operator introspection endpoint | |
| # (see shopstack.services.whoami_mount). It is also re-mounted | |
| # in the post-launch hooks so it survives the Blocks-context exit | |
| # that recreates app.app. (Same pattern as the other mounts.) | |
| mount_whoami_endpoint(app) | |
| initial_locale = load_locale_preference(current_user_id() or "default_household") | |
| # 2026-06-15 (Home screen review): the brand subtitle now | |
| # answers the user's actual job instead of the abstract | |
| # "platform" copy. The legacy APP_DESCRIPTION is still | |
| # passed as a tooltip / fallback via header attributes. | |
| gr.HTML( | |
| header_block( | |
| APP_NAME, | |
| "Know what is at home, what to buy next, and what to skip.", | |
| current_locale=initial_locale, | |
| ), | |
| padding=True, | |
| ) | |
| build_locale_save() | |
| # Hidden runtime_status API endpoint (external consumers can | |
| # query whether the app is in mock or real mode) | |
| build_runtime_status() | |
| # ββ Onboarding wizard (first-run household setup) ββ | |
| # Rendered above the tab bar so first-time users see setup | |
| # immediately, instead of after scrolling past every tab's | |
| # content. Hidden by default; auto-shown on first load if the | |
| # active household hasn't completed onboarding yet. See | |
| # Docs/HANDOFF_ONBOARDING_WIRING_2026-06-13.md. | |
| onboarding_wizard = build_onboarding_wizard(app) | |
| def _show_onboarding_if_first_run() -> gr.update: | |
| from shopstack.services.onboarding import should_show_onboarding | |
| return gr.update(visible=should_show_onboarding(db)) | |
| app.load( | |
| _show_onboarding_if_first_run, | |
| outputs=[onboarding_wizard], | |
| ) | |
| # ββ Household settings accordion (workspace admin panel) ββ | |
| # Rendered above the tab bar β a workspace switcher belongs at | |
| # the top of the page, accessible from any tab, not buried | |
| # below the last tab's content. | |
| hh = build_household_settings(app) | |
| household_dropdown = hh.household_dropdown | |
| add_hh_btn = hh.add_hh_btn | |
| hh_add_row = hh.hh_add_row | |
| hh_name_input = hh.hh_name_input | |
| hh_create_btn = hh.hh_create_btn | |
| hh_cancel_btn = hh.hh_cancel_btn | |
| # ββ Tab bar β driven by module_registry.TAB_ORDER via the builder registry ββ | |
| # 2026-06-15: switched to the 6-item user-facing primary nav | |
| # (Home / Pantry / Shopping / Recipes / Trips / Memory). The | |
| # old 5-group nested layout is still available via | |
| # ``build_all_tabs(use_primary_nav=False)`` for back-compat. | |
| with gr.Tabs(elem_classes="tabs primary-nav", elem_id="main-content"): | |
| handles = build_all_tabs( | |
| blocks=app, app=app, ctx=TabContext(), | |
| use_primary_nav=True, | |
| ) | |
| # Extract handles from tabs that expose them for cross-tab wiring. | |
| # These are P0 guards: if a builder silently returns None or is | |
| # skipped, we fail fast with a clear error rather than crashing | |
| # with AttributeError deep inside the event wiring below. | |
| today_handles = handles.get("today") | |
| reconcile_handles = handles.get("reconcile") | |
| if today_handles is None: | |
| raise RuntimeError( | |
| "Today tab builder did not return required handles. " | |
| "Check that build_today_tab() returns a TodayTabHandles " | |
| "dataclass and that the builder is registered in " | |
| "shopstack/ui/tabs/registry.py." | |
| ) | |
| if reconcile_handles is None: | |
| raise RuntimeError( | |
| "Reconcile tab builder did not return required handles. " | |
| "Check that build_reconcile_tab() returns a " | |
| "ReconcileTabHandles dataclass and that the builder is " | |
| "registered in shopstack/ui/tabs/registry.py." | |
| ) | |
| # Cross-tab event wiring (household dropdown, add-household | |
| # form, per-render location refresh, post-load JS shims) is | |
| # delegated to a dedicated sub-builder so app.py stays a pure | |
| # composition layer. See shopstack/ui/state/household_wiring.py. | |
| from shopstack.ui.state.household_wiring import wire_household_handlers | |
| wire_household_handlers( | |
| app, | |
| household_dropdown=household_dropdown, | |
| add_hh_btn=add_hh_btn, | |
| hh_add_row=hh_add_row, | |
| hh_name_input=hh_name_input, | |
| hh_create_btn=hh_create_btn, | |
| hh_cancel_btn=hh_cancel_btn, | |
| today_handles=today_handles, | |
| reconcile_handles=reconcile_handles, | |
| ) | |
| # PWA shell (manifest/sw.js/icons) and the /health/ui liveness probe | |
| # (motto_v3 Β§0.10 Observability Is Delivery) must both be mounted AFTER | |
| # the ``with gr.Blocks()`` block exits: exiting the context recreates | |
| # ``app.app`` (a fresh FastAPI instance), discarding any routes added | |
| # while inside the context. | |
| mount_pwa_static(app) | |
| mount_health_endpoint(app, db) | |
| _install_permissions_policy_middleware(app) | |
| _install_post_launch_hooks(app) | |
| return app | |
| if __name__ == "__main__": | |
| import argparse | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--port", type=int, default=7860) | |
| parser.add_argument("--share", action="store_true") | |
| args = parser.parse_args() | |
| app = build_app() | |
| app.launch(server_port=args.port, share=args.share, theme=gr.themes.Base(), head=pwa_head_html(), css=CSS, prevent_thread_lock=True) | |
| app.block_thread() | |