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:
- 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. - 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. - Identity/scope header — built from the active
ArtifactsPaneContract: the contract's icon/label on the contract's accent and the project scope. Built withshell.build_shell_scope. The committed query is not echoed here; it lives only in the query bar. - 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_badgewhen the pane is loading or stale. - Content region — the pane's list/detail split, using the shared split-mode
classes owned by
ArtifactsViewand a stable*-detail-scrollid when the contract declares one (contract.detail_scroll_id). - Footer-hint lane — the pane's configured key/label hints, built with
shell.build_footer_hintsso 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
Refreshingbadge (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_cardpicks between the two fromhas_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_cardrenders(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 explicitaccentparameter that defaults to the pane's pinned built-in color) — neverARTIFACTS_ACCENTS[<pane id>]insideshell.py. This is what stops a document-provider pane likeref:researchfrom rendering Plans-purple: before this phase,plans_rendering.pyhard-codedARTIFACTS_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_ACCENTSin_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_kindhashing) 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#1A1A1Atext, and is at least0.085OKLab units from every other palette entry and from every reservedARTIFACTS_ACCENTS/EXTERNAL_ACCENTcolor.tests/ace/tui/test_artifacts_provider_palette.pypins all of these properties, plus that installing or removing an unrelatedref_kindcannot repaint an existing tab (the hash is a pure function of the kind string alone) and that provider discovery never mutatesARTIFACTS_ACCENTS. - Filter bars resolve border, sigil, completion-highlight, and match-count colors from
their own
ACCENT, which is the pane'sArtifactsPaneContract.accent.FilterBaraccepts an optionalaccentconstructor kwarg so a document-provider pane's bar uses that contract accent instead of a pinned default (this is what stops aref:researchpane from drawing Plans-purple). - Query highlighting is presentation only:
highlight_queryinsrc/sase/ace/query/profile_highlighting.pymaps the same lexer the parser uses —tokenize_query_for_displayfor the boolean dialect,sase.filter_tokens.tokenizefor flat dialects — ontoQUERY_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#1A1A1Atext 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:
- 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). - Build the identity header with
shell.build_shell_scope(label=contract.label, accent=contract.accent, ...)instead of a pane-local accent chip. - Derive
ArtifactsShellStatefrom 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 withshell.resolve_pane_state.ArtifactsSnapshotPanesubclasses get this for free by overriding_snapshot_matches_scope/_snapshot_row_countand callingself.pane_state(...). - Feed the resolved state into
shell.build_state_badgefor the state/count lane, and intoshell.build_empty_cardfor the empty/no-match surface. - Build the footer with
shell.build_footer_hints(keymap_pairs, accent=contract.accent). - 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, exercisePANE_CONFORMANCE_CHECKSdirectly the waytest_degraded_descriptor_satisfies_every_conformance_checkdoes. - If the pane earns
FILTER_SESSION, declarePERSISTENT = Trueand a pane-specificDISPLAY_IDon itsFilterBarsubclass, passaccent=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 bytests/ace/tui/test_artifacts_query_bar_invariant.py. - 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.