experimental web port of the Union style engine + adapters
styling theming css solidjs react union kde
0

Configure Feed

Select the types of activity you want to include in your feed.

union-web / docs / solid-plugin.md
11 kB

@union-web/solid-plugin#

The Solid plugin provides runtime style resolution for Union themes. Components declare themselves via useUnion(), wrap children in UnionProvider to extend the ancestor context chain, and render using StyledRect (for visual layers) or plain elements (for text/layout).

This is the currently active output runtime. The react-plugin may lag behind in architecture (e.g., ancestor state propagation, typed UnionStyleProps model).

Architecture#

The web has no built-in element tree. The plugin recovers hierarchy through explicit ancestor context:

Union Concept Solid Plugin Equivalent
Union::Element createElement(role, opts) inside useUnion()
Union::ElementQuery resolveElement() from core
Widget tree (implicit in Qt) UnionProvider chain via Solid context
Union::StyleRule collection StyleRule[] from UnionIRContext
Platform renderer applies styles styleGroupToUnionStyleProps()UnionStylePropsStyledRect

Key Differences from React Plugin#

  1. Ancestor state propagation — Solid's AncestorElementData includes a states field (the react-plugin omits it). This enables selectors like toolbar:disabled > button to match correctly.

  2. Typed style props — Solid uses UnionStyleProps (typed properties matching Union's property groups) + StyledRect component instead of raw style object. This preserves Union's semantic model (alignment, inset vs margin, per-side border/outline, shadow spread+blur).

  3. Solid reactive primitives — Signals, memos, and createMemo replace React's useState/useMemo. State derivations are reactive.

API Reference#

useUnion()#

function useUnion(options: UseUnionOptions): UseUnionResult

Options:

Option Type Description
role string Element type for matching (e.g., 'button', 'textarea'). Required.
context string[] | null Explicit ancestor roles. null = root. Unset = inherit from nearest UnionProvider.
state string[] | (() => string[]) Active states (e.g., ['hovered'], ['visual-focus']). Can be a signal getter.
hints string[] | (() => string[]) Semantic hints for matching (parallels CSS .hint selectors).
attributes Record<string, string> | (() => Record<string, string>) Key-value attribute pairs for attribute selectors.
ids string[] | (() => string[]) Element IDs for ID selectors.
debug boolean Enable console group logging of selector matching.

Returns:

Return Description
styles Pre-computed StylePropertyGroup for all standard state combinations (base, hovered, pressed, checked, visual-focus, active-focus, disabled, highlighted, plus checked combos).
current The StylePropertyGroup for the element's current state (reactive signal).
UnionProvider A component wrapping UnionAncestorContext.Provider. Pushes this element into the ancestor chain for descendant useUnion() calls.

The current value is a reactive getter (() => StylePropertyGroup). It resolves the element's styles using the ancestor chain and current state. State combinations not pre-computed are resolved on the fly.

StyledRect#

<StyledRect style={unionStyleProps} as="div">
  {children}
</StyledRect>

Base component that renders a styled DOM element from UnionStyleProps. Maps Union's property groups to CSS:

  • Layoutwidth/heightmin-width/min-height (Union's width/height are implicit size hints, not fixed sizes); spacinggap; paddingpadding
  • Marginmargin → CSS margin (sub-element spacing). Union's inset property (background-container shrink) is wholly owned by the Positioner — StyledRect never maps it.
  • BackgroundbackgroundColorbackground-color
  • Border — per-side width/color/style → CSS border properties
  • CornersborderRadiusborder-radius (including per-corner)
  • Shadowbox-shadow (offset + blur + spread + color)
  • Outline → native CSS outline + outline-offset (flattened: per-side widths/ colors/styles are dropped in favor of the first defined side). The full-fidelity pseudo-element approach (negative CSS inset + border for per-side control) is deferred; see StyledRect.tsx TODO.
  • Text — color, font-size, font-weight, font-family, textWrapMode

Not all components need StyledRect. Components with only text properties (Text, Heading) or only layout (DialogButtonBox, ColumnLayout, RowLayout) should render a plain element to avoid StyledRect's display: flex base styles.

Positioner#

Status: Experimental, in-progress — nothing is final.

<Positioner spacing={8}>
  <StyledRect style={childStyle1} />
  <StyledRect style={childStyle2} />
</Positioner>

A layout component that manages the positioning of a control's internal children (icon, text, indicator, handle). Corresponds to upstream Union's PositionerLayout engine. Children declare their alignment via useUnion's layoutAlignment* fields and are automatically sorted into buckets (start/center/end/fill) within three containers (item, background, content).

Key ideas:

  • Owns inset — the background-container shrink is the Positioner's responsibility. StyledRect never maps it.
  • Child registration — children use PositionerContext (automatically wired by useUnion) to register their alignment and container.
  • Three containers — item (full box), background (shrunk by inset), content (shrunk by padding). Background and content containers render as overlay <div>s with position: absolute; inset: 0.

This component will be iterated on. See packages/solid-plugin/src/Positioner.tsx and the design reference at packages/positioner-sandbox/README.md.

UnionIRProvider#

<UnionIRProvider rules={rules}>
  <App />
</UnionIRProvider>

Root provider. Supplies the parsed CSS rules to all useUnion calls in the tree. Must be placed at the top of the application.

Virtual Module: virtual:union-ir#

import { rules } from 'virtual:union-ir';

Provided by the Vite plugin (@union-web/vite-plugin). Exports the resolved StyleRule[] array from the theme CSS.

Components#

Component useUnion role States Sub-parts StyledRect?
ApplicationWindow applicationwindow No (plain <div>, picks bg/color/padding/spacing)
ApplicationHeader applicationheader Yes (background + cond border via .with-separator)
Page page No (plain <main>, picks bg/color/padding/spacing)
ColumnLayout columnlayout No (plain <div>)
RowLayout rowlayout No (plain <div>)
Button button hovered, pressed, disabled Yes (all visual layers)
Checkbox checkbox hovered, pressed, checked, disabled indicator (role indicator, hints indicator) Yes (main uses StyledRect, indicator sub-element)
Switch switch hovered, pressed, checked, disabled indicator (role indicator, hints indicator), handle (role handle, hints handle) Yes (main + indicator + handle all use StyledRect)
TextArea textarea hovered, visual-focus, disabled placeholder (separate component, role placeholder, hints placeholder) Yes (textarea via StyledRect) + plain div (placeholder)
Text text No (plain <div>, text props only)
Heading heading hints: level-N (+ primary) No (plain <Dynamic>, text props only)
ItemDelegate itemdelegate hovered, pressed, highlighted Yes (background + cond border)
Drawer drawer Yes (background)
Toolbar toolbar hints: header or footer for position Yes (background + cond border)
DialogButtonBox dialogbuttonbox No (plain <div>)
Spacer none No (plain <div>)

Using UnionProvider#

Each component that can contain other Union-aware components should render UnionProvider to extend the ancestor chain:

function MyContainer(props) {
  const { current, UnionProvider } = useUnion({ role: 'container' });
  const styleProps = createMemo(() => styleGroupToUnionStyleProps(current()));
  return (
    <UnionProvider>
      <StyledRect style={styleProps()}>
        {props.children}
      </StyledRect>
    </UnionProvider>
  );
}

// Leaf — no UnionProvider needed
function MyLabel(props) {
  const { current } = useUnion({ role: 'text' });
  // ...render plain element with resolved text styles
}

Best practice: Always render UnionProvider unless you are certain the component is a leaf (has no descendants that call useUnion). If a leaf later gains Union-aware children, forgetting to add UnionProvider will produce a broken ancestor chain.

Debugging#

Pass debug: true to any useUnion call to see selector matching in the browser console:

[useUnion] textarea (base) ancestors: [applicationwindow, page, columnlayout]
  ✓ textarea (spec: 0.1.0)    { background-color: #2a2a2a; ... }
  ✓ > .placeholder (spec: 0.2.0)    { color: #808080; }
result: { layout: ..., background: { color: "#2a2a2a" } }

Application Setup#

import { UnionIRProvider } from '@union-web/solid-plugin';
import { rules } from 'virtual:union-ir';

render(() => (
  <UnionIRProvider rules={rules}>
    <App />
  </UnionIRProvider>
), document.getElementById("root"));

Vite Configuration#

The virtual:union-ir module is provided by the @union-web/vite-plugin package. In your vite.config.ts:

import { unionIR } from '@union-web/vite-plugin/vite';

export default defineConfig({
  plugins: [
    unionIR({
      theme: path.resolve(__dirname, "input/themes/breeze/style.css"),
      colors: path.resolve(__dirname, "input/colors/BreezeLight.colors"),
      defaults: path.resolve(__dirname, "input/defaults/default.css"),
    }),
    solidPlugin(),
  ],
});

The unionIR() plugin:

  • Parses the theme CSS and defaults at build time
  • Resolves all variable expressions (var(), custom-color(), modify-color(), mix())
  • Provides the virtual:union-ir module with resolved StyleRule[]
  • Injects resolved CSS variables into HTML <head>
  • Watches input files for HMR in dev mode