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:
- 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. - 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#
window— the root frame. Carriesdata-focused. (data-themelives on<html>, not here.)titlebar— the drag region (data-drag-regionis set by the component).title,title-icon— the centered title text and its leading icon.controls— the window-control cluster. Cluster order and side are data-driven fromThemeChrome.controlsOrder/controlsSide; the base sheet honors them withdata-controls-sideand flexorder, keeping DOM order stable.control— one control button.data-action="close"|"minimize"|"zoom".window-body— the content region below the titlebar.window-frame-edge— the thick Win98-style outer frame overlay, painted only when--window-frame-w > 0.
Toolbar#
toolbar,toolbar-grouptoolbar-button—data-state="active"when toggled on.breadcrumbs,breadcrumb—data-state="current"on the last crumb.search-field
Sidebar#
sidebar,sidebar-section,sidebar-headingsidebar-item—data-state="selected".sidebar-item-icon
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.
file-list,file-list-headerfile-list-col—data-col="name"|"date"|"size"|"kind".file-row—data-state="selected",data-zebra="odd"|"even". Also takesdata-drop="true"during a drag-over.file-cell—data-colas above.file-icon,file-namelist-empty— the empty-folder placeholder.rename-input— the inline-rename text field (used by both list and grid).marquee— the rubber-band selection rectangle (list and grid).
Icon grid (Finder "Icons" view)#
icon-gridicon-grid-item—data-state="selected".icon-grid-icon,icon-grid-label.
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.
tab-bartab—data-state="selected".tab-label,tab-close,tab-add.
Modal dialog (Go to Folder…)#
dialog-overlay— the fixed, centered backdrop.dialog,dialog-title,dialog-body,dialog-actions.text-input— the token-driven text field used inside dialogs.
Deep-search results (⌘F mode)#
See search for the feature itself.
search-results,search-scope-bar,search-hit-parent.
Split panes#
split-view— the sidebar/content split; the base sheet sizes the first child to--sidebar-w.split-divider— a thin draggable line with a generous hit area.
Terminal#
See the terminal for the panel's behavior.
terminal-panel—data-state="closed"collapses it;data-resizing="true"drops the slide transition while dragging.terminal-resize-handle— the drag bar at the panel's top edge.terminal-headerterminal-host— the elementxterm.jsmounts into. This part is also one of the few that opts text selection back in (it's in the base sheet'suser-select: textset, alongsideinput,textarea, and[contenteditable="true"]).
A note from the changelog: themes must paint the terminal panel and resize handle from
var(--surface), notvar(--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#
status-barstatus-itemstatus-bar-info— left-aligned info string (item counts).status-bar-more— right-aligned manual "Load more" button;aria-disabled="true"when there's nothing more to load.
Generic controls#
Reusable primitives shared across the UI:
button,icon-buttonsegmented,segment—data-state="selected"on the active segment.menu,menu-item,menu-separatorscroll-area— a generic scroll container (overflow: auto).
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:
- Closed. A theme can only style parts that exist in
PARTS. There is no escape hatch for selecting arbitrary DOM, because a component without adata-partis, by construction, not themable. - Additive, with stable names. Part names are kept stable and additive: you
add new parts; you never rename or remove an existing one without a contract
bump. In the contract-versioning rules, adding a part is a MINOR change,
while removing or renaming a part is MAJOR. The theme contract currently
ships at
"1.0".
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.