Surfaces — spec¶
Status: specified, not built. Every decision here was made on the map
Map: surfaces — card-style theming,
and each section links the ticket that holds its reasoning. The vocabulary is in
CONTEXT.md § Surfaces.
The model¶
A surface is how a component's edge and body are drawn. It is data that the pane interprets, written in colour roles. Any palette can be drawn with any surface.
The user picks two things independently: a palette (colours) and a surface (shape). The shell combines them, with the glyphs, into the Theme that components are built from.
Five surfaces ship:
| Surface | Look | Needs |
|---|---|---|
Outlined |
a line border, no fill. Today's look, byte-identical | nothing |
Filled |
a solid card whose half-block rows (▄/▀) are its own edge |
256 colours, block glyphs |
Banded |
a card under a header band carrying the title; focus colours the band | 256 colours |
Elevated |
a card with a half-block drop shadow (▌/▀) right and below; accent stripe on focus |
256 colours, block glyphs |
Accented |
unfilled, with a ▌ bar down the left edge, accent-coloured when focused |
block glyphs (else ┃) |
Ruled, Lipped (eighth-block lips) and Bare were considered and don't ship; they are what custom surfaces look like (Which built-in surfaces ship).
Build order¶
Each step ships on its own and leaves the library working.
ansi.Filland the selection fix. Today's selection holes are fixed under Outlined before any surface exists. See Selection.- Outlined goldens. Capture every launcher example's rendered frame from today's output. From here on, Outlined must match them byte for byte. See Enforcement.
pkg/surfaceandpkg/paletteas leaf packages, with derivation, quantisation and resolution unit-tested with no UI.- The pane takes a resolved surface: edges, text rows, dividers, the fill
pass, inset-aware hit-testing. The ~12 hard-coded
width-2/Y+1sites move toContentRect. The pane draws the header divider, so the 8 copies ofglyphs.Rulego. layout.ZStackcomposites by painted cells.- The app shell: two axes, the body padding, the screen pass, bars on the surface's role, fallback logging, config keys.
- Migration of the library's examples and the six downstream modules, with a "Migrating to surfaces" section in this doc's successor guide.
- The surface demo (
examples/shell/chromebecomes it), defining one custom surface as a struct literal.
Packages¶
Imports point one way: pane → surface, palette → surface, and
theme → palette, surface, pane, …. surface imports nothing from tuilib, and palette only surface.
pkg/surface(leaf):Surface,Role, the five built-ins,Derive,Resolve, and the resolved type. All colour maths lives here.pkg/palette(leaf, importssurface):Palette, the 19 named palettes (palette.Dark(),palette.FromBase16,palette.Terminal(),palette.All()),ByName,Resolve.pkg/themekeepsTheme= palette + surface + glyphs, and theth.Component()builders, which need all three. It is not renamed.
(Where the surface choice lives, The surface seam)
A surface is data¶
A Surface is a struct literal, the way lipgloss.Border is. There is no
Draw interface, so a custom surface can't get the geometry wrong. It declares:
- Name, for config matching and pickers.
- Edges per side: glyphs, the cells each side takes, and where each glyph's
background comes from: the card, the parent (whatever contains the
surface), or reversed (Textual's
BORDER_LOCATIONS; it is what makes half-block edges expressible). - Text rows: every surface has a top and a bottom text row, so all five slots (title, top-right, bottom-left, bottom-middle, bottom-right) exist on every surface. The surface decides how each row looks: a line with text inlaid, a band, or a card-tone row inside a lip or shadow. Elevated's bottom edge is two rows: footer, then shadow. Slot brackets are text-row data.
- The divider under a pinned header: glyph, role, the role while the header has input, and which background it sits on.
- Focus effects for two states, focused and blurred: edge role, fill role, band role, whether the accent bar shows. Edge space never differs between states, so Accented reserves its bar column when blurred.
- Selection treatment (see Selection).
- Bar role: which role the breadcrumb and statusbar sit on.
- Overlay form: another surface value for modals (see Overlays).
- Needs and fallback: minimum colour depth, whether it uses block glyphs, and
the
Fallbacksurface (see Degraded terminals).
Colours appear only as roles, never as literals.
Geometry¶
- A surface draws only inside its rect. The rect
layouthands out is where the surface draws, and content is the rect minus the edges. Elevated's shadow is edge, drawn on the parent. - Margins: a filled surface's edge includes one cell on its right and bottom, drawn with no background, so whatever contains it paints it: an outer pane's fill pass, or at the top level the shell's screen pass. Adjacent cards end up exactly one cell apart. Elevated's shadow is its margin. Outlined has none.
- The shell pads the body one column on the left and one row on top when the
current surface has margins, so the screen's edges match.
layoutknows nothing about any of this. - Hit-testing:
pane.RowAt, scrollbar hits and slot clicks derive from the surface's per-side edge space. A click on a margin or shadow cell is in the rect but outside the card, so the component declines it (rule 28).
(The surface seam, Gutters between surfaces)
Colour roles¶
A palette gains two seeds: Background (the palette's canonical background:
base16 base00, Catppuccin base, nord0) and Foreground. Built-in palettes
set them from upstream. Unset, they fall back to BarBG and BarFG.
Every role is derived from the palette, and any role can be overridden
per palette with Palette.Surface map[surface.Role]lipgloss.TerminalColor, where
a missing key means derive.
"Lift" mixes toward Foreground in OKLab. On light palettes, cards lift toward
white.
| Role | Used for | Derived (dark) | Derived (light) |
|---|---|---|---|
Screen |
behind and between surfaces | Background |
Background |
Card |
surface fill | lift 8% | toward white 70% |
CardRaised |
focused fill | lift 13% | toward white 90% |
Band |
Banded's header, Filled's divider step | lift 18% | darken 6% |
Overlay |
overlay-form fill | lift 16% | white |
Shadow |
Elevated's shadow | darken Screen 60% |
darken Screen 22% |
Selection |
focused selected row | Card mixed 45% toward EdgeActive |
same |
SelectionDim |
blurred selected row | lift Card 12% |
same |
Bar |
bars under Outlined | BarBG |
same |
Text / TextMuted |
body text on filled surfaces | Foreground / Muted |
same |
Edge / EdgeActive |
lines, the active band | BorderInactive / BorderActive |
same |
Divider / Accent |
Subtle / Accent |
same |
The percentages are starting points to tune, not spec.
- Contrast floor: a derived tone too close in lightness to the role beneath
it is pushed further:
CardonScreen,CardRaisedonCard,BandonCard,SelectiononCard. - Text: filled surfaces paint body text in
Text, and the fill pass re-opens the foreground as well as the background. Outlined keeps the terminal's own colours. - Pipeline:
surface.Derive(seeds, overrides, profile)returns the full role-to-colour map;surface.Resolve(s, colours)returns a resolved surface with concrete colours. Theme builders do both. The pane only ever sees resolved surfaces. - Expected built-in override: Dark's
Selection(itsEdgeActiveis ANSI bright blue, so the mix comes out saturated).
(Surface colour tokens, Bars on filled surfaces)
The fill pass¶
The pane fills once, in pane.View, over its own rendered lines:
- open the surface background (and
Textforeground) at the start of each line; - re-open it after every SGR that clears it (
0, empty,49, and the foreground equivalents), found with a real parameter walk that skips the operands of38/48/58(a string replace corrupts38;2;R;0;B); - pad every line to the pane width.
It is ansi.Fill(s, open), the same primitive the row renderers use. It is a
no-op for Outlined. Nesting composes: an inner pane's background wins inside its
rect. The audit measured 0 unpainted cells across 32 renders at about 100 µs per
120×36 frame, before a no-escape fast path.
Bars, layout gaps and margins are outside any pane. The shell paints them with a
whole-frame pass in Screen.
(Audit where components rely on the terminal background;
findings in research/background-fill-audit.md)
Selection¶
ansi.Fillreplacestree.renderPreserving. All six row renderers use it with the selection background and pad the row to the inner width: inspector, eventlog, logview, textview, table, list. This is step 1 of the build and fixes today's holes under Outlined.- The surface declares the treatment. Outlined declares today's exactly: the
list's bold
Accentforeground with no background, aSubtlebackground in the others, and no difference on blur. Filled, Banded, Elevated and Accented declare aSelectionbackground withTextforeground in every component, andSelectionDimwhen the pane is blurred.
(Selection on filled surfaces)
Overlays¶
- Each surface carries an overlay form,
Surface.Overlay. Unset, it is the same shape with its fill role switched fromCardtoOverlay. Outlined sets Thick lines explicitly, so today's modals are byte-identical. It replacesTheme.BorderShapeOverlay. Confirm, alert, the action menu and the help overlay are built with it. layout.ZStackcomposites by painted cells: a cell that isn't a space, or that carries a background, covers the base. On each row it splices from the first painted cell to the last. Padding fromCenter/lipgloss.Placeand margin cells stay transparent.- No scrim: the base isn't dimmed (out of scope, revisit only if modals don't stand out).
Bars¶
The surface names the role the breadcrumb and statusbar sit on. Outlined uses
Bar (byte-identical). Filled, Banded, Elevated and Accented use Screen, so
the cards float in one field. Bar text keeps BarFG, falling back to Text
under the contrast floor. Info and Error keep their own bands. The ? help
affordance and the inline hints take the bar's role as their background.
Choosing a surface¶
app.Options.Palettes(wasThemes), cycled byThemeKey.app.Options.Surfaces, default[surface.Outlined], cycled bySurfaceKey, unset by default (opt-in, likeThemeKey).- Config:
config.yamlgainspalette:(withtheme:read as an alias),surface:(matched by name from what the app offers, ignored when it offers one) andblock_glyphs:. - Env:
ThemeEnvVarbecomesPaletteEnvVar, plus a newSurfaceEnvVar. - Screens need nothing new. A swap is a new
Themethrough the existingSetThemepath, and rule 4'sState/Restorecovers it. - Chrome moves:
BorderShapeActive/Inactivebecome Outlined's edge glyphs (surface.OutlinedWith(lipgloss.RoundedBorder())for a variant).SlotBracketsbecome text-row data.Glyphsstay onTheme, exceptGlyphs.Rule, which is the divider now.
Per-component overrides¶
opts := t.Logview()
opts.Surface = t.Resolve(surface.Outlined) // this pane stays Outlined
Options.Surface holds only a resolved surface, so skipping Theme.Resolve
is a compile error. The override survives a surface cycle because SetTheme
re-applies it. A pinned surface brings its own margins or none.
(Where the surface choice lives, Per-component surface overrides)
Degraded terminals¶
- Quantise, then enforce the floor.
Deriverounds each role to the detected colour profile, then re-checks the contrast-floor pairs and pushes any that collapsed to the next distinct step. 256 colours, and so default tmux, keep filled surfaces. Palette overrides are rounded but never pushed. - Fallback chain. Resolution follows
Fallbackuntil the terminal meets the surface's needs: - Filled → Banded
- Elevated → Banded
- Banded → Outlined, below 256 colours
- Accented → Accented with a
┃bar, without block glyphs - Outlined ends every chain
- Block glyphs:
block_glyphs:in config. Unset, it is off for known font-drawn terminals (TERM_PROGRAM=Apple_Terminalfirst) and on elsewhere. - ANSI-index inputs: every derivation that mixes in a non-seed colour has a
seeds-only recipe, used when an input has no RGB (
Selectionbecomes "liftCard25%"). ANSI colours used directly pass through, soTerminal()works. - Visibility: when a chosen surface resolves to its fallback, the shell writes
one line to the output console ("surface elevated → banded (block glyphs
off)").
SurfaceKeyskips entries that resolve to a surface already offered.
(Surface fallback on degraded terminals)
Enforcement¶
The check works from geometry, never heuristics. Four assertions:
- Every cell inside the card (rect minus margins) carries a background.
- Margin cells carry none.
- The selected row has one background across the full inner width.
- Every cell of the frame carries a background, after any
ZStackcomposite.
The classifier is an SGR state machine on x/ansi.DecodeSequence in
internal/cells, shared with ansi.Fill, so the check and the fill agree on
what clears a background. It runs over three layers:
- Components in
internal/componenttest: every pane-owning component × the 5 surfaces × focused/blurred. States: empty, loading, filter focused, a selected row holding lipgloss,CellColorand hyperlink cells, a search highlight, row activity. Plus the four overlays throughZStack, and pinned surfaces against their own surface. - Examples: every launcher example through the full app shell, per surface.
- Outlined goldens, captured before the work starts (build step 2).
Downstream apps get the same assertions as a pkg/screentest rule,
SurfacesFill, once pkg/screentest exists.
(Enforcing the background-fill invariant)
Migration¶
This is a clean break in one minor version. There are no Go shims.
| Before | After |
|---|---|
theme.Dark() (and every named palette) |
palette.Dark() |
theme.All(), theme.ByName, theme.Resolve |
palette.All(), palette.ByName, palette.Resolve |
app.Options.Themes |
app.Options.Palettes |
app.Options.ThemeEnvVar |
app.Options.PaletteEnvVar |
Theme.BorderShapeActive/Inactive |
a surface: surface.OutlinedWith(shape) |
Theme.BorderShapeOverlay |
the surface's Overlay form |
Theme.SlotBrackets |
text-row data on the surface |
Glyphs.Rule |
the surface's divider |
config.yaml theme: |
palette: (theme: still read) |
Six local modules need migrating: tui-builder, tuiquery, tuiquery_old, tuilib-skills and its golden app, and tuilib-detailed-progress.
Out of scope¶
- A scrim under modals.
- Ruled, Lipped and Bare as built-ins; they're custom surfaces.