Skip to content

Artifacts pane visual grammar

Every configured Artifacts pane — Agent, Stitches, Patch, Beads, Files, every document provider, and a degraded provider that failed to load — renders through one shared, contract-driven shell. This document is the reference for that grammar: the code lives in src/sase/ace/tui/widgets/artifacts/shell.py.

Layout order

The vertical order is invariant for every pane:

  1. Pane brief — owned by ArtifactsView, not by any individual pane, so every built-in, provider, and degraded pane gets it for free. Sits directly under the Artifacts sub-tab strip and above every pane's own layout. See Pane brief below.
  2. Query bar — always present on every pane whose contract has PaneCapability.FILTER_SESSION. Absent only on degraded panes and on providers that declare no fields. The bar is a permanent layout slot: pressing / never inserts or removes rows. See Query bar states.
  3. Identity/scope header — built from the active ArtifactsPaneContract: the contract's icon/label on the contract's accent and the project scope. Built with shell.build_shell_scope. The committed query is not echoed here; it lives only in the query bar.
  4. State/count lane — a compact line combining the pane's own counts (task/epic/phase totals, file-kind chips, commit position, and so on) with the shared state badge from shell.build_state_badge when the pane is loading or stale.
  5. Content region — the pane's list/detail split, using the shared split-mode classes owned by ArtifactsView and a stable *-detail-scroll id when the contract declares one (contract.detail_scroll_id).
  6. Footer-hint lane — the pane's configured key/label hints, built with shell.build_footer_hints so every pane uses the same separator (·) and accent treatment for enabled keys, with disabled keys dimmed.

Bespoke information (Patch fold levels, Stitch repository presence, Bead triage counts, File origin counts) belongs in the state/count lane or the pane's own rows — never in a second identity header.

Pane brief

shell.build_pane_brief renders the resolved pane description (ArtifactsPaneContract's description / description_body, description_source / description_body_source) as a fixed layout slot owned by ArtifactsView, immediately under the sub-tab strip. It is pure and widget-free: contract-derived values plus the already-resolved cycle-key display name go in, a Text comes out — no pane, provider, or filesystem access.

Three session-scoped modes, cycled forward-only by cycle_artifacts_description (default D), a click on the brief, or the command palette, and seeded from ace.artifacts.description_mode:

Mode Rows shown
off None — the brief renders empty Text and the row collapses to zero.
summary One line: gutter, icon, the summary, ellipsized if too long.
full Summary (wrapped) plus body paragraphs, capped at max_lines (6).

Every row opens with a two-cell "▌ " gutter: bold {accent} on the summary row(s), dim {accent} on body rows. The summary row reads gutter + icon + two spaces + summary — the pane label is deliberately not repeated, since the strip above already shows it in the same accent. A right-aligned disclosure hint ("▸ {key}" in summary mode, "▾ {key}" in full mode) appears on the summary row using the resolved display name of cycle_artifacts_description, and is dropped when the row has no room for it or when there is nothing to disclose (empty body and no unconfigured hint). In full mode, an unconfigured provider pane (description_source == "fallback") gets one more dim italic line naming both config paths (ref.pane.description and ace.artifacts.panes.<pane_id>.description) — the only place this brief renders developer-facing text. The accent color appears only in the gutter and the disclosure hint; every other style (italic summary, dim body) is theme-safe with no hex lookup.

Because pane description resolution is a total function — every pane, degraded ones included, resolves a non-empty summary — the brief is a stable layout slot: switching panes never inserts or removes this row, only off mode does, exactly like the query bar's / never inserting or removing its row.

Query bar states

A persistent FilterBar has two states. The idle presentation is the universal closed rendering (DISPLAY_ID plus PERSISTENT); open() / close() swap between them without changing the bar's height.

State What you see Status lane
idle Highlighted committed query, or the profile's free_text_hint as dim italic placeholder text. Editor hidden, unfocusable, and read-only. No vim chrome. Click posts FilterBar.Clicked, mapped to the same show_filters() / uses. Empty query: blank. Non-empty: {N} matches · {coverage} (exact, preview, or a pane label such as capped or Plan's deep-archive coverage).
editing Vim editor focused in INSERT, plus the completion overlay. A parse error is reachable only here — an idle bar always holds a committed, valid query. Parse error in the existing error style; otherwise the same match/coverage text as idle.

CommitFilterBar.set_status still overrides the count away because the Stitches legend owns that position; callers are uniform even though that renderer stays pane-specific. The saved-slot chip on Patch's idle display is a Patch-only decoration: only Patches applies a loaded slot today.

Relation panel slot

Panes whose contract enables PaneCapability.RELATIONS own one host-rendered relation panel at the bottom of the content region's list column. The panel is fed the snapshot-built RelationIndex; widgets never build relation edges on highlight or keypress paths.

The host assigns relation key roles from declaration order and relation kind. The first declared hierarchy relation is the ancestor mode (<), its declared hierarchy inverse is the descendant mode (>), every family relation participates in sibling/family mode (~), and link relations render as rows without taking a relation key mode. A pane or provider names relation properties; it does not assign keys.

Each visible section uses the declared relation label as its uppercase header, appends (N hidden) when pane-supplied facts hide targets, and renders dangling same-pane targets dimmed with a (missing) marker. Cross-pane link rows show their destination pane id so the target switch is explicit.

. (toggle_relation_panel) collapses that panel into a one-line relations rail and expands it again; the panel starts collapsed by default (ace.artifacts.relations_expanded: false). The collapsed flag is session-scoped and shared by every relations pane, so switching Agent / Patches / Beads / Files / Plans / Stitches keeps the same expanded or collapsed state.

The collapsed rail leads with a control chip — " ▸ {key} " rendered bold on the pane accent, where {key} is the resolved display name of toggle_relation_panel (not a hardcoded .) — followed by the word expand at full accent weight (the one thing on the rail that must be read). A dim · separator, then the unchanged navigation segments follow: one {key} {count} {label} per section that has visible rows or a hidden count, in declared section order, joined by ·. The navigation key uses a second, distinct color register (amber) so the rail never reads as one undifferentiated key soup: chip/dark-on-accent means "acts on this rail," amber means "moves the selection." Counts include nested descendant children; link sections omit the key. A trailing ({N} hidden) preserves hidden counts. Because the affordance is leftmost, an 80-column terminal ellipsizes relation counts before it ever ellipsizes the expand chip. Clicking the collapsed rail expands it; clicking the expanded panel is a no-op. The relation keymap stays live while collapsed: < / > / ~ still navigate. A selection with no relations keeps the panel hidden, collapsed or not.

The expanded panel carries the reverse affordance at zero row cost: border_title = "▾ RELATIONS" and border_subtitle = "{key} collapse" (bottom-right), mirroring the rail's ▸ / expand pairing with the same disclosure vocabulary.

Grouping banner rows

Panes whose contract enables PaneCapability.GROUPING render host-owned banner rows from the active PaneGroupingModeDecl. The pane supplies already-loaded rows plus property values for the mode's keys; the shared grouping model clusters rows, emits one banner target per group key, and records member targets for fold actions and jump hints.

Expanded banners are visible headings and are skipped by row navigation. Collapsed banners are selectable rows: j/k stop on them, ' jump hints can target them, and l expands the selected banner back to its first visible child. Lowercase h collapses the focused row's deepest group, while H and L collapse or expand the visible banner layer without rebuilding provider data.

Banner rendering is shared by widgets/artifacts/group_banner.py: a fold glyph, the mode label, the member count, and a short rule all use the pane contract's accent. Provider panes never render custom banner markup.

State precedence

Visible state is a closed ArtifactsPaneState enum, resolved by shell.resolve_pane_state from an immutable, purely presentational ArtifactsShellState record. Precedence, most to least specific:

State Condition
degraded A contract/discovery failure, or an initial load failure with no usable content.
loading First load for the current scope, with no cached content yet.
stale Usable content from the current scope remains while a refresh is running or a runtime error occurred.
empty The current scope loaded successfully but has no rows.
results Usable rows are present and no refresh is in flight.

ArtifactsShellState fields (is_degraded, is_loading, has_error, has_content, row_count, has_active_filter) must already be computed by the pane; the resolver never touches the filesystem, invokes provider code, or resolves providers.

Required information per state

  • Loading: a compact progress affordance (Loading…) in the state lane. Panes never substitute a blank list — the list keeps whatever rows it already has (there are none on a genuine first load).
  • Stale: the current selection, list, and detail stay visible; the shell overlays a Refreshing badge (or, when the refresh ended in an error but cached content remains, an ⚠ <message> badge) rather than rebuilding from disk or clearing content on the event loop.
  • Empty: an empty-inventory card uses contract.empty_state (title + body). A no-match card — rows exist but the active filter excludes all of them — names the active filter and the key that edits or clears it. shell.build_empty_card picks between the two from has_active_filter.
  • Degraded: the tab stays named and navigable. The card shows provider kind, configuration source, the stable diagnostic code when available, the validation problem, and (when known) a direct recovery hint (error_source). shell.build_degraded_card renders (hero, card) from exactly those fields — never provider code.
  • Results: usable rows are present; no badge is shown (the pane's own count text is the signal).

Accent rules

  • Every renderer consumes contract.accent (or an explicit accent parameter that defaults to the pane's pinned built-in color) — never ARTIFACTS_ACCENTS[<pane id>] inside shell.py. This is what stops a document-provider pane like ref:research from rendering Plans-purple: before this phase, plans_rendering.py hard-coded ARTIFACTS_ACCENTS["plans"] in every builder, so a Research pane using the generic document adapter still painted Plans' pinned purple. The built-in Plan adapter keeps its pinned purple through the same accent-parameter path rather than a special case.
  • The provider accent palette (_PROVIDER_ACCENTS in _artifact_tab_descriptors.py) is nine hex colors chosen in OKLCH (a perceptually uniform color space) at a shared lightness/chroma band, then pinned as plain hex so runtime assignment (_provider_accent_for_kind, unchanged SHA-256-of-ref_kind hashing) stays dependency-free and deterministic. Every entry clears a WCAG contrast ratio of at least 3.3 against the app's dark (#121212/#1E1E1E) and light (#E0E0E0/#D8D8D8) shell surfaces and against the identity chip's #1A1A1A text, and is at least 0.085 OKLab units from every other palette entry and from every reserved ARTIFACTS_ACCENTS / EXTERNAL_ACCENT color. tests/ace/tui/test_artifacts_provider_palette.py pins all of these properties, plus that installing or removing an unrelated ref_kind cannot repaint an existing tab (the hash is a pure function of the kind string alone) and that provider discovery never mutates ARTIFACTS_ACCENTS.
  • Filter bars resolve border, sigil, completion-highlight, and match-count colors from their own ACCENT, which is the pane's ArtifactsPaneContract.accent. FilterBar accepts an optional accent constructor kwarg so a document-provider pane's bar uses that contract accent instead of a pinned default (this is what stops a ref:research pane from drawing Plans-purple).
  • Query highlighting is presentation only: highlight_query in src/sase/ace/query/profile_highlighting.py maps the same lexer the parser uses — tokenize_query_for_display for the boolean dialect, sase.filter_tokens.tokenize for flat dialects — onto QUERY_TOKEN_STYLES. It never re-lexes with a private regex, so the highlight cannot disagree with the parse, and it never raises on malformed input.

Accessibility constraints

  • Text-on-accent chips (the identity header's " Label " chip) always pair #1A1A1A text with the accent as background; the provider palette's chip-contrast check exists specifically to keep that combination legible.
  • Accent-as-foreground text (scope labels, count numbers, footer keys) is checked against both shell surfaces so a color that reads fine in the dark theme cannot go illegible in the light theme, and vice versa.
  • The provider palette deliberately avoids hues too close to EXTERNAL_ACCENT (#FF5F5F, the shared error/warning red) so a provider's identity color is never mistaken for an error state.

Provider-data boundary

No renderer in shell.py may look up a pane id in ARTIFACTS_ACCENTS, invoke provider code, resolve providers, touch the filesystem, or perform data-scaled work. Every shell function takes an already-resolved ArtifactsPaneContract and small, pre-computed presentation values (booleans, counts, strings) — never a provider spec, a callback, or a widget. Composition stays host-owned: ArtifactsView passes the resolved contract and descriptor diagnostics into every pane; no sidecar-provided markup, widget, command string, or color ever reaches the shell.

Extension checklist

Adding a new built-in pane or generalizing an existing one to use more of the shell:

  1. Make sure the pane has a compiled ArtifactsPaneContract (built-in adapter table in _artifact_tab_contract.py, or the schema-v1 provider path for document providers).
  2. Build the identity header with shell.build_shell_scope(label=contract.label, accent=contract.accent, ...) instead of a pane-local accent chip.
  3. Derive ArtifactsShellState from the pane's own lifecycle fields (loading flag, load error, whether cached content matches the current scope, row count, active-filter flag) and resolve it with shell.resolve_pane_state. ArtifactsSnapshotPane subclasses get this for free by overriding _snapshot_matches_scope/_snapshot_row_count and calling self.pane_state(...).
  4. Feed the resolved state into shell.build_state_badge for the state/count lane, and into shell.build_empty_card for the empty/no-match surface.
  5. Build the footer with shell.build_footer_hints(keymap_pairs, accent=contract.accent).
  6. Add the pane's descriptor to the conformance harness coverage in tests/ace/tui/artifacts_contract/harness.py (already automatic for every resolved sub-tab) and, for a genuinely new fixture shape, exercise PANE_CONFORMANCE_CHECKS directly the way test_degraded_descriptor_satisfies_every_conformance_check does.
  7. If the pane earns FILTER_SESSION, declare PERSISTENT = True and a pane-specific DISPLAY_ID on its FilterBar subclass, pass accent=contract.accent, and call _sync_query_bar() from the pane's _refresh_options() funnel so the idle bar's text and status stay truthful when committed filters change without opening a session. The always-on invariant is enforced by tests/ace/tui/test_artifacts_query_bar_invariant.py.
  8. Add or update the PNG snapshot for the new surface, inspecting .pytest_cache/sase-visual/ actual/expected/diff artifacts before accepting a golden.

Patch's contract-in/spec-out asymmetry

Patch is contract-in like every other pane: ArtifactsPatchesPane receives a compiled ArtifactsPaneContract and its accent, label, and capabilities come from that contract exactly like Stitches, Beads, or Files. But Patch is not spec-out — its query, grouping, detail rendering, and action surface remain the pre-existing, heavily specialized Patch implementation (PatchInfoPanel, PatchList, PatchDetail, and its persistent inline PatchFilterBar), rather than the generic document/snapshot pipeline. Typing in that bar filters the loaded Patch snapshot live; commit, rollback, completion, history, and saved-slot behavior remain Patch-owned. Patch's existing empty state already routes through TabQuickStart (_actions/patch/_onboarding.py shows/hides #patch-quickstart-panel); this phase treats that established mechanism as Patch's canonical empty surface rather than introducing a second one.

Patch's query bar now occupies the same pane-top slot as every other FILTER_SESSION pane. The list/detail split stays specialized below that slot: Patch still owns PatchInfoPanel, PatchList, PatchDetail, grouping, saved query slots, and live query filtering rather than routing through the generic document/snapshot pipeline.

A literal shared identity-header row was deliberately not added to Patch's layout for the same golden-churn reason: PatchInfoPanel already carries Patch's identity and state/count information. That migration is also left as explicit follow-up work.