@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() → UnionStyleProps → StyledRect |
Key Differences from React Plugin#
-
Ancestor state propagation — Solid's
AncestorElementDataincludes astatesfield (the react-plugin omits it). This enables selectors liketoolbar:disabled > buttonto match correctly. -
Typed style props — Solid uses
UnionStyleProps(typed properties matching Union's property groups) +StyledRectcomponent instead of raw style object. This preserves Union's semantic model (alignment, inset vs margin, per-side border/outline, shadow spread+blur). -
Solid reactive primitives — Signals, memos, and
createMemoreplace React'suseState/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:
- Layout —
width/height→min-width/min-height(Union's width/height are implicit size hints, not fixed sizes);spacing→gap;padding→padding - Margin —
margin→ CSSmargin(sub-element spacing). Union'sinsetproperty (background-container shrink) is wholly owned by the Positioner — StyledRect never maps it. - Background —
backgroundColor→background-color - Border — per-side width/color/style → CSS border properties
- Corners —
borderRadius→border-radius(including per-corner) - Shadow →
box-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 CSSinset+borderfor per-side control) is deferred; seeStyledRect.tsxTODO. - 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.StyledRectnever maps it. - Child registration — children use
PositionerContext(automatically wired byuseUnion) 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 withposition: 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-irmodule with resolvedStyleRule[] - Injects resolved CSS variables into HTML
<head> - Watches input files for HMR in dev mode