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
Navigation Layers
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, ornav_tree - object context:
breadcrumbs, title, metadata, and object actions - local route views:
render_route_tabswitharia-current="page" - commands:
command_bar,action_strip,dropdown_menu, andcommand_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:
- Call
use_chirp_ui(app)so Chirp registers the canonical shell contract. - In filesystem apps, declare
{# preset: chirpui-app-shell #}on the_layout.htmlthat owns the app shell, then add route-specific{# domain: ... #}/{# shell: ... #}metadata. - Keep one section descriptor source in Python for sidebar groups, tab families, and breadcrumb prefixes.
- Extend
chirpui/tabbed_page_layout.htmlfor route-backed pages and passtab_itemspluscurrent_path. - Return
Page(..., "page_content", page_block_name="page_root", ...)orPageComposition(..., fragment_block="page_content", page_block="page_root", ...). - 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.htmlextendschirpui/app_shell_layout.html, owns brand/sidebar, and declares the shell target metadata._context.pycontributes route-scopedshell_actions._meta.pyconnects the route family to a registeredSection.page.pyand sibling route files can return context dictionaries when a matching.htmltemplate exists.- Page templates provide
page_root,page_root_inner, andpage_contentblocks 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 provides
page_root. - If tab clicks fail, verify the page provides
page_root_inner. - If a narrow mutation fails, verify the target maps to
page_contentor another registered fragment block. - Run Chirp contract checks in tests or startup so missing blocks fail before a user clicks around.
- Prefer
tab_itemsandrender_route_tabs(...)over the olderroute_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.