App Shell

Sidebar, breadcrumbs, command palette, and layout patterns

Vocabulary: UI layers & terms — app shell, page chrome, surface chrome, shell regions, and the built-in HTMX shell-coherence behavior. For dense topbars, object chrome, route tabs, command launchers, and overflow choices, use the repository guidedocs/NAVIGATION.mdas the canonical decision model.

Quick start: Extendchirpui/app_shell_layout.htmland fill the blocks. No manual HTML boilerplate.

{% extends "chirpui/app_shell_layout.html" %}
{% block brand %}My App{% end %}
{% block sidebar %}
  {% from "chirpui/sidebar.html" import sidebar, sidebar_link, sidebar_section %}
  {% call sidebar() %}
    {% call sidebar_section("Main") %}
      {{ sidebar_link("/", "Home", match="exact") }}
      {{ sidebar_link("/items", "Items", match="prefix") }}
    {% end %}
  {% end %}
{% end %}

match="exact" highlights the link only when the URL matches exactly. match="prefix" highlights when the URL starts with the href (e.g. /items/42 highlights the Items link). Chirp auto-injects current_path into template context, so match= works without manual nav=strings. After htmx navigation, a built-in client-side script keeps active states in sync.

Layout overflow

The shell main area clips horizontal overflow, but default app shells scroll with the document. Build pages with grid() + block(), cluster(), and wrapping indicator rows so content stays in column; use overflow-x: auto only on inner wrappers for wide tables or code. See the repo doc docs/LAYOUT-OVERFLOW.md for the full checklist.

Full-height main

For chat, maps, or IDE-style surfaces that should fill the viewport below the topbar (with scroll inside panels), opt in with {% block main_shell_class %} chirpui-app-shell__main--fill{% end %}, put a direct child of #page-content with class chirpui-page-fill, and use chat_layout(..., fill=true) for chat pages. This is the explicit bounded-scroll exception to the document-scroll default. See docs/LAYOUT-VERTICAL.md for the flex chain, min-height: 0, and the chirpui-chat-layout__messages-body wrapper class for SSE/HTMX roots inside the messages column.

Components

  • sidebar — Collapsible navigation with sections
  • breadcrumbs — Path navigation
  • command_palette — Cmd+K search
  • toast_container — Toast notifications
  • shell_actions — Route-scoped topbar actions that update automatically on navigation

Do not model every compact control as a nav item. ChirpUI separates:

  • global shell: app identity, product switch, search/command trigger, utility actions
  • product navigation:sidebar, primary_nav, or nav_tree
  • object context:breadcrumbs, title, metadata, and object actions
  • local route views:render_route_tabs with aria-current="page"
  • commands:command_bar, action_strip, dropdown_menu, and command_palette

Route-backed tabs are navigation links, not ARIA tab widgets. True tabs are for in-place panels with tablist/tab/tabpanel semantics. Ordinary expandable navigation should use disclosure/list semantics rather than ARIA menu roles.

For deep object paths, prefer breadcrumb overflow instead of letting the header widen:

{{ breadcrumbs(items, overflow="collapse", max_items=4) }}

For dense object chrome, reserve count space instead of letting counters shift as they load:

{{ route_tabs(tabs=[
  {"href": "/issues", "label": "Issues", "badge": 12, "badge_label": "12 open issues"},
  {"href": "/runs", "label": "Runs", "badge_loading": true},
  {"href": "/audit", "label": "Audit", "badge_expected": true},
], current_path="/issues") }}

Use a compact command launcher for global search/jump, and keep the full search surface incommand_palette:

{{ command_palette_trigger(
  target="project-palette",
  label="Search project",
  placeholder="Search or jump",
  shortcut="/",
  icon="search",
  density="sm"
) }}

Golden Path

The recommended app path is now:

  1. Calluse_chirp_ui(app)so Chirp registers the canonical shell contract.
  2. In filesystem apps, declare{# preset: chirpui-app-shell #} on the _layout.html that owns the app shell, then add route-specific {# domain: ... #} / {# shell: ... #}metadata.
  3. Keep one section descriptor source in Python for sidebar groups, tab families, and breadcrumb prefixes.
  4. Extendchirpui/tabbed_page_layout.html for route-backed pages and pass tab_items plus current_path.
  5. ReturnPage(..., "page_content", page_block_name="page_root", ...) or PageComposition(..., fragment_block="page_content", page_block="page_root", ...).
  6. Let Chirp validate that your leaf pages actually provide the required shell blocks.

That gives you one shell, one tab model, one set of fragment targets, and predictable OOB updates without app-local wrapper glue.

Minimal filesystem shell:

{# preset: chirpui-app-shell #}
{# target: body #}
{# domain: workspace #}
{# shell: workspace #}
{% extends "chirpui/app_shell_layout.html" %}

Copyable filesystem route shape:

pages/
  _layout.html
  workspace/
    _context.py
    _meta.py
    page.py
    page.html
    runs.py
    runs.html
  • _layout.html extends chirpui/app_shell_layout.html, owns brand/sidebar, and declares the shell target metadata.
  • _context.py contributes route-scoped shell_actions.
  • _meta.py connects the route family to a registered Section.
  • page.pyand sibling route files can return context dictionaries when a matching.htmltemplate exists.
  • Page templates providepage_root, page_root_inner, and page_content blocks so#main, #page-root, and #page-content-innereach receive the right response shape.

The source-tree fixturetests/fixtures/filesystem_chrome/and the canonical checklistdocs/SHELL-TABS-CONTRACT.mdshow the full version.

For nested app shells, keep the preset and override the target:

{# preset: chirpui-app-shell #}
{# target: main #}
{# domain: showcase #}
{# shell: showcase #}
{% from "chirpui/app_shell.html" import app_shell %}

Reference Pattern

This is the smallest durable pattern for a dashboard-style app:

from chirp import App, AppConfig, Page, Request, use_chirp_ui

app = App(AppConfig(template_dir="templates"))
use_chirp_ui(app)


@app.get("/projects")
def projects(request: Request) -> Page:
    tab_items = (
        {"label": "Overview", "href": "/projects", "match": "exact"},
        {"label": "Runs", "href": "/projects/runs", "match": "prefix"},
    )
    return Page(
        "projects/page.html",
        "page_content",
        page_block_name="page_root",
        page_title="Projects",
        current_path=request.path,
        tab_items=tab_items,
        breadcrumb_items=[
            {"label": "Home", "href": "/"},
            {"label": "Projects"},
        ],
    )
{% extends "chirpui/tabbed_page_layout.html" %}
{% from "chirpui/layout.html" import page_header %}

{% block page_header %}{{ page_header("Projects") }}{% end %}
{% block page_content %}
<p>Project content.</p>
{% end %}

If you need persistent sidebar, breadcrumbs, and shell actions, layer this under your app shell layout and keep the page-level template focused on page_header, page_toolbar, and page_content.

shell_actions

Route-scoped topbar actions (buttons, links, menus) that update automatically when navigating via htmx boost (sidebar) or tab clicks (hx-target #main or #page-root).

When extendingchirpui/app_shell_layout.html, shell_actionsis provided by the layout chain from Chirp's merged_context.pyresults. When using the app_shell()macro, pass it explicitly and wrap routed content with shell_outlet() so custom shells get the same #page-contentswap boundary:

{% from "chirpui/shell_frame.html" import shell_outlet %}
{% call app_shell(brand="My App", shell_actions=shell_actions | default(none)) %}
  {% call shell_outlet() %}
    ...
  {% end %}
{% end %}

The rendering macro is shell_actions_bar(shell_actions)from chirpui/shell_actions.html. See Chirp's app-shell guide for the full cascade/override pattern (primary, controls, overflow zones; remove=; mode="replace").

Design: Prefer shell_actions for actions that apply across the whole section (e.g. "New Chain" on Discover). Page-level action strips are better for actions that only apply to the current tab. Avoid duplicating the same action in both.

Tabbed layout: Tabs are topmost. Put title, action strips, and content underneath (inside page_content). Order: Tabs → Title → Actions → Content.

route_tabs and tabbed_page_layout

For route-backed subsection tabs (e.g. Workspace → Analytics, Events, Logs), the canonical macro isrender_route_tabs from chirpui/route_tabs.html:

{% from "chirpui/route_tabs.html" import render_route_tabs %}
{{ render_route_tabs(tab_items, current_path, target="#page-root") }}

Tab items: {label, href, icon?, badge?, badge_label?, badge_expected?, badge_loading?, match?}. match: "exact" or "prefix". ChirpUI registers tab_is_active as a template global via use_chirp_ui(). The older route_tabs(...) name still works as a compatibility alias, but render_route_tabs(...)avoids the common macro/context name collision footgun.

For the full tabbed layout structure, prefer extendingchirpui/tabbed_page_layout.html so the template itself exposes Chirp's page_root, page_root_inner, and page_contentcontract blocks:

{% extends "chirpui/tabbed_page_layout.html" %}

{% block page_header %}{{ page_header("Section Title") }}{% end %}
{% block page_toolbar %}{% end %}
{% block page_content %}...{% end %}

Pass tab_items and current_path in page context. ChirpUI also keeps the older tabbed_page_layout(...)macro for compatibility, but extending the template is the recommended path for apps that use Chirp fragment targets.

See Chirp's chirp-ui guide for full app-shell patterns and htmx integration.

HTMX fragment targets

ChirpUI registers its page shell contract viause_chirp_ui(). That contract maps the built-in fragment targets to explicit template blocks, and Chirp validates those blocks for leaf page templates during app contract checks:

Target Block Use case
#main page_root Sidebar navigation (full content + tabs)
#page-root page_root_inner Tab clicks (tabs + content)
#page-content-inner page_content Narrow content swaps

<main id="main"> carries hx-boost="true", hx-target="#main", hx-swap="innerHTML", hx-select="#page-content", and hx-sync="#main:replace" — all links inside inherit SPA navigation automatically, and rapid navigation clicks resolve as latest intent wins for the shared shell target. The #main element persists in the DOM (never replaced), so its view-transition-name is never duplicated during swaps. Content is wrapped in <div id="page-content"> inside #main. Sidebar links (outside #main) carry their own hx-target="#main" via sidebar_link(). Section tab links use hx-target="#page-root". For custom targets, use app.register_fragment_target("target-id", fragment_block="block_name") before mount_pages(). Set triggers_shell_update=Falsefor narrow content swaps that should not update the topbar (e.g. inline form results).

Chirp UI owns this transport-level shell behavior. Apps still own endpoint cost, authorization, idempotency, and any business rule where repeated actions should queue, retry, or intentionally execute more than once.

When writing custom route handlers, branch onHX-Target, not only HX-Request:

Request target Response shape
main Full page response containing#page-content; include OOB shell regions when route-scoped topbar/sidebar metadata changes.
page-root Page chrome fragment for section tabs: route tabs, page header, toolbar, and inner content.
page-content-inneror another local target Local fragment only; clear inherited shell selection withhx-select="unset" / hx-disinherit="hx-select"on the trigger.

Route-scopedshell_actions live outside #page-content, so a boosted shell-navigation response that changes actions should include:

{% from "chirpui/shell_actions.html" import shell_actions_bar %}

<div id="chirp-shell-actions" hx-swap-oob="innerHTML">
  {{ shell_actions_bar(shell_actions) }}
</div>

The canonical source-tree checklist is docs/SHELL-TABS-CONTRACT.md.

Boosted navigation follows the shell scroll policy:

  • New route: scroll the document to the top.
  • Same-route refresh/update: preserve the current document scroll position.
  • Hash navigation: land the target below the sticky topbar offset.
  • History navigation: preserve browser-native restoration.

Inline scripts in page templates

app_shell_layout.html defines a {% block page_scripts %}{% end %} slot near </body>. This block is only available to templates that {% extends %} the layout directly — typically inner _layout.htmlfiles.

Filesystem page templates (page.html) are composed into the layout via Chirp's render_with_blocks, which injects content into {% block content %}. Page templates cannot override sibling blocks like page_scripts, head_extra, or any other block outside the content region. A {% block page_scripts %}in a page template defines a new local block — it does not fill the layout's version.

If a page needs a<script> tag (e.g. for Alpine.safeDatacomponent registration), put it inside the content region:

{% block page_root %}
<div id="page-root">
{% block page_content %}
  <p>Page content here.</p>
{% end %}
</div>

<script>
Alpine.safeData("myWidget", function() {
  return { count: 0, increment() { this.count++; } };
});
</script>
{% end %}

The inline <script> executes during DOM parsing — before Alpine's deferred CDN script runs. Chirp's safeData helper queues the registration and drains it on alpine:init, so the component is available when Alpine discovers x-dataattributes.

See Chirp's filesystem routing guide for the full explanation of the composition model.

Debugging

When shell navigation behaves strangely, check these first:

  • If sidebar navigation fails, verify the page providespage_root.
  • If tab clicks fail, verify the page providespage_root_inner.
  • If a narrow mutation fails, verify the target maps topage_contentor another registered fragment block.
  • Run Chirp contract checks in tests or startup so missing blocks fail before a user clicks around.
  • Prefertab_items and render_route_tabs(...) over the older route_tabs(...)naming to avoid macro/context collisions.

The best mental model is: target id -> registered fragment block -> block exists on the leaf page template. Once those three line up, HTMX navigation and OOB shell updates become routine.

Polling shell regions

Usepoll_trigger() from chirpui/fragment_island.htmlwhen a shell region needs a delayed or load-triggered refresh without visible UI chrome:

{% from "chirpui/fragment_island.html" import poll_trigger %}

<div id="shell-status"></div>
{{ poll_trigger("/status/summary", "#shell-status") }}
{{ poll_trigger("/status/summary?refresh=1", "#shell-status", delay="2s") }}

This keeps the polling markup consistent with the rest of the app-shell and avoids repeating hiddenhx-getbutton boilerplate across templates.