Compositions
Status: doctrine (2026-05-22). The "X + Y composes well" patterns that emerged across the chart-family, platform-aware, and AI-studio sprints. Tux is composable by design — most consumer surfaces are 5-12 Tux components stitched into a layout. This doc captures the combinations that have proven their weight.
Companion to
components.md(the catalog),chart-foundations.md(chart doctrine), andplatform-awareness.md(chrome doctrine).
Compositions earn a slot in this doc when they meet three tests:
- Two or more components compose more value than they do alone.
TuxBigStat+TuxSparklineis a composition (KPI tile);TuxButtonnext toTuxBadgeisn't (just two atoms in a row). - There's a non-obvious "right" shape. If a contributor would intuit the layout from the JSDoc alone, it's not a composition worth doctrine-level documentation.
- Real consumer surfaces use it. Each entry below cites at least one consumer file that ships it.
Index
- Layout shells
- Headlines + summaries
- Chart surfaces
- Browse + detail surfaces
- Chat surfaces
- Suite chrome
- Cross-app navigation
- Editorial surfaces
Layout shells
sidebar layout + #header + #aside
The full app-shell. Used by data-dense surfaces (Landscape, tti-ai-studio dashboards).
<NuxtLayout name="sidebar">
<template #header>
<UDashboardSidebarToggle />
<TuxBreadcrumbs :trail="trail" />
<!-- right side: actions -->
</template>
<template #rail-header="{ collapsed }">
<!-- brand lockup -->
</template>
<template #rail="{ collapsed }">
<UNavigationMenu orientation="vertical" :items="railItems" :collapsed="collapsed" />
</template>
<template #rail-footer="{ collapsed }">
<!-- user chip -->
</template>
<!-- default slot: main panel -->
<div class="p-6 space-y-10">
<!-- … -->
</div>
<template #aside>
<!-- right-rail: activity, agents, notifications -->
</template>
</NuxtLayout>
Consumers: app/pages/examples/landscape-dashboard.vue (all
five slots used), app/pages/examples/sidebar-shell.vue
(minimal version without #aside).
Notes: the explicit <NuxtLayout name="sidebar"> invocation is
required to access named slots with typed scope —
definePageMeta({ layout: "sidebar" }) routes content into the
default slot only.
TuxAppFrame + TuxMenuBar + content
Tauri desktop shells where TUX draws the titlebar. The Mac path collapses the menu bar (system menu wins); Windows + Linux render the in-window strip.
<TuxAppFrame title="Landscape" :force-chrome="true">
<template #left>
<span class="font-bold text-brand-primary">Landscape</span>
</template>
<template #right>
<TuxAppSwitcher :apps="apps" />
<UButton variant="ghost" icon="lucide:bell" />
</template>
</TuxAppFrame>
<TuxMenuBar :menus="menus" />
<!-- Page content fills below -->
Consumers: the showcase routes at /components/app-frame and
/components/menu-bar. No production Tauri shell yet (deferred
until consumer pull); when one lands it composes these three.
Notes: TuxAppSwitcher belongs in #right of TuxAppFrame for
Tauri shells, OR in the utility row of TuxSiteNav for plain-web
consumers. Don't put it in both — it's a single floating affordance
per app.
Headlines + summaries
TuxBigStat row + chart underneath
The canonical "summary + trend" composition absorbed from the
Charts UI Kit and Snow Dashboard. A row of TuxBigStat tiles
(Total + per-series) above the chart that breaks it down.
<div class="grid grid-cols-4 gap-4 tux-mount-in tux-mount-in--stagger">
<TuxBigStat :value="totalGrand" label="Total" tone="maroon" />
<TuxBigStat :value="seriesA" label="PDF" />
<TuxBigStat :value="seriesB" label="CSV" />
<TuxBigStat :value="seriesC" label="GeoJSON" />
</div>
<TuxChartArea :labels="months" :series="data" variant="stacked" />
Consumers:app/pages/examples/landscape-dashboard.vue § "Corpus composition".
Notes: the totals in the KPI strip should match the right-most endpoint of the area chart. Drift means the chart and tiles are fed by different data — fix the source.
TuxFactoid + TuxStatComparison row
Two-row pattern: headline factoids on top (one-liner takeaways), year-over-year deltas below (how the numbers moved).
<TuxFactoid variant="default" :density="3" :items="factoids" />
<div class="mt-8 grid grid-cols-3 gap-6 tux-mount-in tux-mount-in--stagger">
<TuxStatComparison eyebrow="…" :current="…" :previous="…" />
<TuxStatComparison eyebrow="…" :current="…" :previous="…" polarity="invert" />
<TuxStatComparison eyebrow="…" :current="…" :previous="…" />
</div>
Consumers: app/pages/examples/research-landing.vue.
Notes: when a delta is "down-is-good" (error rate, latency),
use polarity="invert" so the visual cue (color, arrow) matches
the goodness, not the math.
TuxPageHeader + TuxBigStat in #media
Hero pattern for landing pages where one number is the anchor.
<TuxPageHeader
tone="neutral"
rhythm="hero"
eyebrow="indices"
title="/research"
>
Continuously indexed by 4 agent runtimes.
<template #media>
<TuxBigStat :value="47.2" suffix=" TB" label="Indexed across all corpora" tone="maroon" size="lg" />
</template>
</TuxPageHeader>
Consumers:app/pages/examples/landscape-dashboard.vue.
Chart surfaces
TuxFocusView + chart family
"Open this chart in focus mode" pattern. Lets researchers pin a tile-sized chart to the full viewport for analysis. The chart keeps all its interactions (brush, tooltip).
<UButton icon="lucide:maximize" @click="focus = true">Focus</UButton>
<TuxFocusView v-model:open="focus" eyebrow="Exhibit 11.04" title="Monthly ingest rate">
<template #actions>
<UButton variant="ghost" icon="lucide:download" />
</template>
<TuxChartLine :labels="months" :series="data" :width="1100" :height="500" markers brush />
</TuxFocusView>
Consumers: app/pages/visualizations/chart-line.vue showcase;
intended use case: any dashboard tile with a chart that deserves
inspection.
Notes: the focus view's content slot has padding 1.5rem so the
chart needs explicit width/height — width: 1100, height: 500
typically reads well on standard laptop viewports.
TuxChartFrame + chart
Editorial chrome (eyebrow + display-face title + signature rule + source citation) wrapped around any chart. Use for report-level exhibits; skip in dashboard tiles.
<TuxChartFrame
eyebrow="Exhibit 11.04"
title="Monthly ingest rate"
subtitle="Files added to the corpus per month — total across all agents"
source="Source: TTI Landscape index, 2026"
>
<TuxChartLine :labels="months" :series="data" :width="700" :height="300" />
</TuxChartFrame>
Consumers: every chart showcase has a "wrapped · editorial frame" section demonstrating the wrap.
Chart + TuxRichDataGrid (hover sync via emit)
Master-detail with hover-sync. When a researcher hovers a chart data point, the matching row in the adjacent table can light up.
<script setup>
const activeIndex = ref<number | null>(null);
function onChartHover(payload) {
activeIndex.value = payload?.index ?? null;
}
</script>
<template>
<TuxChartLine :labels="months" :series="data" @hover="onChartHover" />
<TuxRichDataGrid
:rows="rows"
:row-class="(row, i) => i === activeIndex ? 'tux-row--highlighted' : ''"
/>
</template>
Consumers: not yet shipped in an example; pattern is captured here for the next Landscape iteration.
Browse + detail surfaces
TuxSplitPane + TuxRichDataGrid + #bottom pane
Master-detail at full surface scale. Left pane = list of records (TuxRichDataGrid or TuxLinkList); right pane = selected record's detail; optional bottom pane = related context (history, comments, linked records).
<TuxSplitPane v-model="selectedId" id="landscape-records" :show-bottom="true">
<template #list>
<TuxRichDataGrid :rows="rows" :selected="selectedId" @row-click="selectedId = $event.id" />
</template>
<template #detail>
<TuxPageHeader :eyebrow="`record · ${record.id}`" :title="record.title" />
<!-- detail body -->
</template>
<template #bottom>
<TuxTabs :tabs="['History', 'Comments', 'Linked']" />
</template>
</TuxSplitPane>
Consumers: /components/split-pane showcase (uses a simpler
button-list instead of TuxRichDataGrid); intended use case is the
upcoming Landscape "browse records" surface.
Notes: URL-bind the selection via Vue Router
(useRoute().query.id ↔ selectedId) so the deep-link case works
without consumer code in the SplitPane.
TuxFilterPanel + TuxResultCount + TuxRichDataGrid + TuxPagination
The "faceted browse" stack. Left rail = filters; main content = result count + table + pagination. Filter changes update the result count; pagination + filter changes are URL-bound.
<div class="grid grid-cols-[18rem_1fr] gap-6">
<TuxFilterPanel v-model="filters" :facets="facets" />
<div class="space-y-3">
<TuxResultCount :showing-from="1" :showing-to="24" :total="412" :page-size="24" />
<TuxRichDataGrid :rows="rows" />
<TuxPagination v-model="page" :total="412" :page-size="24" />
</div>
</div>
Consumers: app/pages/examples/landscape-dashboard.vue (the
files-list section uses this stack).
Chat surfaces
TuxChatMessage + TuxBranchNav (in #header-trailing)
When the assistant produces multiple candidate responses, the
branch navigator lives in TuxChatMessage's #header-trailing
slot.
<TuxChatMessage role="assistant" author="tti-ai-studio" timestamp="12:14:11">
<template #header-trailing>
<TuxBranchNav v-model="currentBranch" :total="3" />
</template>
<!-- assistant body -->
</TuxChatMessage>
Consumers: app/pages/examples/tti-ai-studio-session.vue.
TuxChatMessage + TuxArtifact + TuxFocusView (artifact expand)
When an assistant turn produces a generated artifact (code, doc,
exported data), wrap it in TuxArtifact and add a "focus mode"
affordance so the researcher can pin it full-viewport.
<TuxChatMessage role="assistant">
<p>Here's a script that reproduces the comparison:</p>
<TuxArtifact title="compare.py" icon="lucide:file-code">
<template #actions>
<UButton size="xs" icon="lucide:maximize" @click="focusOpen = true">Focus</UButton>
</template>
<TuxCodeBlock :code="comparePy" lang="python" />
</TuxArtifact>
</TuxChatMessage>
<TuxFocusView v-model:open="focusOpen" title="compare.py">
<template #actions>
<UButton icon="lucide:download">Download</UButton>
<UButton variant="primary" icon="lucide:play">Run</UButton>
</template>
<TuxCodeBlock :code="comparePy" lang="python" />
</TuxFocusView>
Consumers: app/pages/examples/tti-ai-studio-session.vue.
Notes: the #actions slot is the natural home for the focus
button + secondary actions (download, copy). When focus mode opens,
add a "Run" or "Edit" primary action that's awkward in the
inline-tile context.
TuxChatMessage + TuxReactionBar (feedback footer)
Light-touch helpful/question/disagree reaction. Replaces inline thumb buttons; counts display-only (consumer increments).
<TuxChatMessage role="assistant">
<!-- assistant body -->
<template #footer>
<TuxReactionBar
v-model="reaction"
:counts="{ helpful: 7, question: 1, disagree: 0 }"
/>
</template>
</TuxChatMessage>
Consumers: app/pages/examples/tti-ai-studio-session.vue
(refresh 2026-05-22 replaced inline thumbs with this).
TuxComposer + TuxContextMeter (above the input)
Token-utilization meter pinned visually near the composer so the user sees their budget while typing.
<TuxContextMeter :used="used" :max="max" :breakdown="breakdown" />
<TuxComposer @send="onSend" />
Consumers: app/pages/examples/tti-ai-studio-session.vue.
Suite chrome
The composition law behind the family look. Strategy and provenance live in
unification-plan.md; this section is how to build it.TuxUserMenuandTuxUtilityClustercite this section as the source of their anatomy law.
The canonical header
Every portal's chrome is the same composition:
- Site shape (global top header — Landscape, TTI Code, the docs
site):
TuxSiteNavwithTuxUtilityClusterin its trailing slot. - Workbench shape (persistent left rail — AI Studio, Tauri
shells):
TuxAppFramewith the cluster in its#rightslot, before the OS window controls.
<TuxSiteNav :identity="identity" :items="primaryNav">
<template #trailing>
<TuxUtilityCluster
current="landscape"
:signed-in="signedIn"
:user-menu="{ identity: user, items: menuItems }"
>
<template #search><!-- portal's search trigger --></template>
<template #notifications><!-- portal's bell --></template>
</TuxUtilityCluster>
</template>
</TuxSiteNav>
The anatomy law
The cluster's DOM order is fixed, always:
[ #search ] [ #notifications ] [ theme ] [ waffle ] [ identity ]
Optional seats are absent, never reordered. The waffle never folds; identity never folds. One cluster per app shell. In Tauri the cluster is "trailing", not "top-right" — Windows owns the literal corner.
The two-shape identity rule
Identity has exactly two blessed homes, bound to shell shape:
| Shell shape | Mount |
|---|---|
| Site shape | TuxUserMenu placement="cluster" — the cluster's last seat |
| Workbench shape | TuxUserMenu placement="rail-footer" — the rail's footer seat |
Never a third home. Menu content and order are identical in both mounts; only the trigger anatomy differs. All five states (loading / signed-out / signed-in / local-only / error) must render — a portal may not hide the seat because auth is in flight.
Theme: the cluster's toggle flips tti ↔ tti-dark and announces via
role="status". tti-hc stays reachable from the footer (ADR-0006)
and the identity menu's prefs — never from the toggle's cycle.
Don't
- Never a third identity home (a titlebar chip and a rail footer, a bespoke avatar button next to the cluster).
- Never reorder or interleave the cluster's seats.
- Never two waffles, and never a hand-declared app list — the waffle
is registry-fed via
useTuxApps()(see Cross-app navigation). - Never rebuild the cluster per-layout — wrap it once per product (the Landscape pattern below) and mount the wrapper everywhere.
Consumers: app/app.vue (the docs site dogfoods the cluster —
waffle + theme, identity seat deliberately absent because the site is
unauthenticated); Landscape's LandscapeUtilityCluster.vue
(one wrapper wires search / bell / identity once, all layouts mount
it — the reference consumer shape).
Cross-app navigation
The registry
The app list comes from design/apps.json via useTuxApps() —
never hand-declared in a consumer. The registry ships public tile
metadata only (id, name, tagline, icon, url, audience, kind);
entitlement group mappings and non-discoverable apps live server-side
next to each portal's same-origin my-apps resolver, never in the
shipped JSON. The family heading is "TTI Portals".
Audience filtering is navigation, not authentication — every
destination enforces its own gate, so tile visibility may fail open
to the anonymous (public-only) set.
TuxAppSwitcher placement
In Tauri shells, the switcher lives in TuxAppFrame's #right
slot. In plain-web consumers, it lives in TuxSiteNav's utility
row OR in a page-header #actions slot (the AI-studio example
uses the page-header path).
Behavior law
- Registry order, every portal, always. Tiles are never reordered by context — spatial constancy is the switcher's whole value. (The original current-sorts-last behavior was removed for exactly this reason.)
- The current app's tile stays a real, focusable link carrying
aria-current="page"— a self-link is harmless; an unfocusable "current" tile is invisible to keyboard users. - Same-tab navigation is the suite default. The user is going
somewhere, not opening a reference.
target="_blank"is the exception and is appended to the tile's accessible name. kind: "desktop"tiles point at the launcher interstitial, never a raw scheme, and carry a visible "Desktop app" affix — identical-looking tiles must not have categorically different behaviors.- The chrome's tempo is part of the family signature: the switcher
animates on the motion tokens (
--motion-fast/--ease-survey);prefers-reduced-motioncollapses to opacity-only/none. presentation="sheet"is reserved for compact/touch hosts (Tauri Mobile); only"popover"is implemented. Ports of this component must not promise APG-grid keyboard behavior — traversal is Tab-only by design at ≤6 tiles.
Don't:
- Render two switchers on the same page.
- Mark more than one app as
current: true. - Place inside
TuxSlideover/TuxModal(defeats the "always visible" purpose). - Hand-declare an
apps[]array in a consumer.
Research-publishing surfaces
Paper page rhythm
The canonical editorial-research paper composition shipped 2026-05-22. The order matters — each component informs the reader's parse of the next.
<TuxBreadcrumbs :trail="trail" />
<TuxCenterBadge :center="centerKey" />
<TuxPageHeader eyebrow="article · venue" :title="paperTitle">…</TuxPageHeader>
<TuxAuthorByline :authors="authors" :affiliations="affiliations" />
<TuxPaperMeta :type="type" :venue="venue" :published="date" :doi="doi" :license="license" :funders="funders" />
<!-- Sticky actions row: funder badges + Cite + PDF + Share -->
<div class="paper-page__actions">
<TuxFundingSource v-for="f in fundersList" v-bind="f" size="sm" />
<TuxCitationExport :citation="citationData" />
<UButton variant="ghost" icon="lucide:download">PDF</UButton>
</div>
<TuxAbstract :background="…" :methods="…" :results="…" :conclusion="…" :keywords="kw" />
<!-- Body sections — h2 numbering 1., 2., 3., … -->
<section>
<h2>1. Introduction</h2>
<p>… <TuxFootnote :n="1" :text="…" /> …</p>
</section>
<!-- Numbered figures + tables -->
<TuxFigureCaption :number="1" :caption="…" :source="…">
<TuxChartLine :labels="…" :series="…" />
</TuxFigureCaption>
<TuxTableCaption :number="1" :caption="…">
<table>…</table>
</TuxTableCaption>
<TuxAcknowledgments :funding="…" :acknowledgments="…" :conflicts="…" :ethics="…" />
<!-- Footnotes list at document end -->
<section>
<h2>Notes</h2>
<ol><li id="fn-1">… <a href="#fn-ref-1">↩</a></li>…</ol>
</section>
Consumer: app/pages/examples/paper-page.vue (full demo).
Notes: TuxFootnote's hover preview pairs with a footnotes-list
at the document end (consumer's responsibility — the id="fn-N"
on each list item matches the targetId the popover links to).
Sticky-actions Cite sidebar / row
Two arrangements work for the "Cite + funders + PDF + Share" actions: a sticky-row beneath the paper meta (used in the example above), or a sticky right-rail sidebar for long-form papers.
<!-- Sidebar variant -->
<aside class="paper-actions-rail">
<div class="sticky top-6 space-y-3">
<TuxCitationExport :citation="c" />
<UButton block variant="outline" icon="lucide:download">PDF</UButton>
<div class="space-y-1">
<TuxFundingSource v-for="f in funders" v-bind="f" size="sm" layout="stacked" />
</div>
</div>
</aside>
Notes: the stacked-funder variant works well in the narrow rail (80–120 px wide), full-width chips in the row variant.
TTI identity surfaces
Center landing rhythm
The hero + featured-researchers + active-programs + funders composition shipped 2026-05-22.
<TuxBreadcrumbs :trail="['Home', 'Divisions', centerName]" />
<TuxCenterBadge :center="centerKey" />
<TuxPageHeader eyebrow="division · …" :title="centerName" rhythm="hero">
Short summary of the division's research focus.
</TuxPageHeader>
<!-- Lab/division identity card — the marquee identity block. -->
<TuxLab v-bind="lab" />
<!-- Featured-researchers row with stagger entrance -->
<div class="grid grid-cols-3 gap-6 tux-mount-in tux-mount-in--stagger">
<TuxResearcher v-for="(r, i) in featured" v-bind="r" :key="r.name"
:style="{ '--tux-mount-stagger-index': i }" />
</div>
<!-- Active programs grid -->
<div class="grid grid-cols-2 lg:grid-cols-3 gap-6">
<TuxProgram v-for="p in programs" v-bind="p" :key="p.name" />
</div>
<!-- Funding partners strip + cross-division badges -->
<div class="flex flex-wrap gap-3">
<TuxFundingSource v-for="f in fundersList" v-bind="f" />
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink v-for="c in otherCenters" :key="c" :to="c.to">
<TuxCenterBadge :center="c.key" />
</NuxtLink>
</div>
Consumer: app/pages/examples/center-landing.vue.
Notes: TuxCenterBadge wrapped in NuxtLink is the canonical "cross-link to another division" affordance. The badges are distinctly colored so multiple in a row read as a directory rather than a banner.
Cross-division byline
For research that spans multiple TTI divisions, render multiple TuxCenterBadge entries above the byline.
<div class="flex items-center gap-2 flex-wrap">
<TuxCenterBadge center="safety" />
<TuxCenterBadge center="mobility" />
<span class="text-xs text-text-muted">· Cross-division program</span>
</div>
<TuxAuthorByline :authors="authors" :affiliations="affiliations" />
Consumer: app/pages/examples/research-landing.vue § identity row.
Corridor surfaces
Corridor study panel
Pairs TuxCorridorStrip (the linear viz) with a TuxMapMarker
legend below so the strip's event tones read against the marker
shapes used elsewhere in the dashboard.
<TuxSectionHeader>Active corridor study · I-35 mile 174–195</TuxSectionHeader>
<TuxCorridorStrip
name="I-35 corridor — northbound"
direction="Northbound →"
:from-mile="174"
:to-mile="195"
:segments="segments"
:events="events"
/>
<div class="flex flex-wrap items-start gap-4 mt-3">
<div class="flex items-center gap-2">
<TuxMapMarker kind="intersection" size="md" />
<span class="text-xs">Intersection</span>
</div>
<!-- … site, treatment, incident -->
</div>
Consumer: app/pages/examples/landscape-dashboard.vue § Active
corridor study panel; also app/pages/examples/paper-page.vue
§ 2.1 sub-study.
Notes: the same data feeds both the operational dashboard and the published paper — illustrates the "TUX components let the operational and published views share a single data source" affordance.
Chat surfaces (extension)
Markdown composer in chat
TuxMarkdownEditor slotted into the composer position of a chat
surface. Researchers can format their follow-ups with markdown
shortcuts, preview before sending, and the editor handles
char + word counts, min-length checks, and reduced-motion.
<div class="rounded-md border-2 border-brand-primary bg-surface-page p-2 space-y-2">
<TuxMarkdownEditor
v-model="draft"
:rows="5"
:min-length="3"
placeholder="Ask a follow-up — markdown supported · ⌘B / ⌘I / ⌘K · ⌘↵ to send"
/>
<div class="flex items-center justify-between gap-3 px-1">
<div class="flex items-center gap-2 text-xs text-text-muted">
<code>{{ corpus }}</code> · <code>{{ model }}</code>
</div>
<TuxButton intent="primary" icon="lucide:send-horizontal" size="sm">Send</TuxButton>
</div>
</div>
Consumer: app/pages/examples/tti-ai-studio-session.vue § composer
(refreshed 2026-05-22 from plain textarea to TuxMarkdownEditor).
Notes: the editor's built-in preview pairs with the LLM's markdown rendering — what the user sees in the preview is what the model gets, modulo the system prompt.
Editorial surfaces
TuxBlockquote + TuxMediaSlab (publication landing)
The "featured publication" rhythm. A pull quote (drop-cap layout) followed by a full-bleed media moment.
<TuxBlockquote
layout="drop-cap"
quote="…"
attribution="Hassan et al."
role="Transportation Research Record · 2025"
/>
<TuxMediaSlab title="…" eyebrow="…" :media="hero" />
Consumers: app/pages/examples/research-landing.vue.
TuxPageHeader (hero rhythm) + TuxFactoid + TuxIconFeature + TuxCardSlab
The standard research-program landing rhythm — hero, factoids, focus areas (icon grid), program cards (media-led).
Consumers: app/pages/examples/research-landing.vue.
Notes: keep the rhythm; consumers that drop one section break
the visual cadence. If a page legitimately doesn't have factoids,
use TuxBigStat solo with rhythm="hero" on the page header
instead.
When in doubt
- Lean on the example pages under
app/pages/examples/. Each ships a real-shape composition that exercises 10+ Tux components. If you're building a similar surface, start from the closest example and iterate. - Don't reinvent. If you find yourself building a layout that almost matches a composition here, ask whether the differences are essential or stylistic. Stylistic differences belong in tokens / props; essential differences earn a new composition entry below.
Adding to this doc
When you spot a pattern that earns its weight (per the three tests in the intro), add it below with:
- Title — short, evocative ("Master-detail with hover sync", not "Chart + DataGrid linkage").
- Code snippet that's runnable in a consumer.
- Consumer citation — at least one in-repo file that ships the pattern.
- Notes — gotchas, alternatives, when not to use.