The part vocabulary

A SHERU theme never reaches into component internals. It paints by selecting against a small, stable set of part names that the headless components stamp onto the DOM. This is the styling contract between the theme API and the components in @sheru-app/webui: components own structure, themes own paint, and the only thing they agree on is the part vocabulary.

Two rules make the whole system work, and they are worth stating up front:

  1. Every paintable element carries data-part="<name>". Themes select with [data-theme=<id>] [data-part=<name>] — never against tag names, never against component-internal class names.
  2. State is data, not classes. Interactive state is exposed as data-state, data-focused, data-zebra, aria-disabled, and friends — never as bespoke CSS classes. A theme reads those attributes; it does not invent its own.

The PARTS map

The part vocabulary is PARTS, a single frozen object. Each key is the symbolic name a component imports; each value is the literal string stamped into the DOM.

export const PARTS = {
  // Window chrome
  window: "window", // root frame; also carries data-focused, data-theme lives on <html>
  titlebar: "titlebar", // drag region (data-drag-region is set by the component)
  title: "title",
  titleIcon: "title-icon",
  controls: "controls", // window-control cluster
  control: "control", // single control; data-action="close"|"minimize"|"zoom"
  windowBody: "window-body",
  windowFrameEdge: "window-frame-edge", // thick Win98-style outer frame, when --window-frame-w > 0
  // …
} as const;

export type PartName = (typeof PARTS)[keyof typeof PARTS];

PartName is the union of every value, so a component that asks for a part name that isn't in the map fails to type-check. The vocabulary is closed and governed: you cannot paint a part that doesn't exist, and you cannot ship a part name that hasn't been added to the vocabulary.

The part(name) helper

Components don't write data-part="…" by hand. They spread the helper:

/** Helper: the props object that stamps an element as a themable part. */
export function part(name: PartName): { "data-part": PartName } {
  return { "data-part": name };
}

A component element then looks like this in practice:

<div {...part(PARTS.fileRow)} data-state={selected ? "selected" : undefined} data-zebra={i % 2 ? "odd" : "even"}>

</div>

The part() call stamps the part; the extra data-* attributes carry state. That is the entire surface a theme sees.

State is data-*, never a class

The base layout sheet and every theme select state through attributes. The recurring ones:

Attribute Values Meaning
data-focused "true" / "false" On the window root while the native NSWindow is key. Drives the active/inactive titlebar split.
data-state "selected", "active", "open", "current", "closed" Generic interactive state. selected on rows/items/tabs/segments; active on a toggled toolbar-button; current on the last breadcrumb; closed on the terminal panel.
data-zebra "odd" / "even" Alternating-row striping on file-row.
data-col "name"/"date"/"size"/"kind" Column identity on file-list-col / file-cell.
data-action "close"/"minimize"/"zoom" Which window control a control is.
aria-disabled "true" Disabled affordance (e.g. the status bar's "Load more").

A theme reads them; for example, this is how the active/inactive titlebar is expressed:

[data-theme="win98"] [data-part="titlebar"] {
  background: var(--titlebar-bg);
  color: var(--titlebar-fg);
}
[data-theme="win98"] [data-part="window"][data-focused="false"] [data-part="titlebar"] {
  background: var(--titlebar-bg-inactive);
  color: var(--titlebar-fg-inactive);
}

Notice there is no .inactive class anywhere — the window root flips data-focused and the theme keys off it.

How a theme selects a part

The selector shape is always [data-theme=<id>] [data-part=<name>], optionally narrowed by a state attribute. data-theme lives on <html> (set by applyTheme), so every rule is scoped to exactly one theme:

[data-theme="win98"] [data-part="file-row"][data-state="selected"] {
  background: var(--selection-bg);
  color: var(--selection-fg);
}

The shared base stylesheet defines the same parts but contributes layout only — flex/grid skeletons, metrics that read from tokens, and native-feel conventions. The hard rule there is no paint: the only color words allowed are transparent and currentColor. For instance, the base sheet sizes and positions the window frame from tokens, but leaves the look to the theme:

[data-part="window"] {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  background: var(--window-bg);
  border: var(--window-border);
  border-radius: var(--window-radius);
  overflow: hidden;
}

So the part appears twice: once in the base sheet for structure, once per theme for paint. Both target it through [data-part=…].

The vocabulary, by area

The parts group by the region of the UI they belong to. The names and their state attributes:

Window chrome

Toolbar

File list

The list header and rows share a CSS grid whose template the component sets via a --file-list-cols variable, so columns line up across both.

Icon grid (Finder "Icons" view)

Like the file list, the base sheet leaves all selection styling to themes via data-state="selected".

In-page tabs

Web mode draws its own tab strip rather than relying on native window tabs.

Deep-search results (⌘F mode)

See search for the feature itself.

Split panes

Terminal

See the terminal for the panel's behavior.

A note from the changelog: themes must paint the terminal panel and resize handle from var(--surface), not var(--terminal-bg) — only the host paints the terminal background. Painting the 7px transparent resize handle with the terminal's (black) background produced a stray black bar above the "Terminal" header until it was fixed.

Status bar

Generic controls

Reusable primitives shared across the UI:

The vocabulary is closed and additive

The part vocabulary, like the token schema (roughly ~80 CSS custom properties grouped into identity/chrome/bevel/semantic layers), is governed:

This is why a theme written today keeps working: the names it selects against are promised not to move. When a component needs to expose a new paintable surface, the part is added to the vocabulary, the base sheet gains its layout-only rule, and existing themes simply don't style it yet — they degrade to the base layout rather than breaking.

Where parts meet glyphs

A handful of parts are rendered by the declarative glyph spec rather than plain markup, but they still stamp the same parts so existing theme stylesheets keep matching. DataWindowControls emits, per action, exactly:

<button data-part="control" data-action data-focused aria-label>…</button>

— identical DOM to the default renderer, with the control artwork supplied as JSON GlyphDrawing data (see the glyph & icon spec). The part contract is the seam: the glyph system changes what's inside a control, never the part it carries.