diff --git a/packages/solid-plugin/src/Positioner.tsx b/packages/solid-plugin/src/Positioner.tsx
new file mode 100644
index 0000000..12a1747
--- /dev/null
+++ b/packages/solid-plugin/src/Positioner.tsx
@@ -0,0 +1,245 @@
+import { children, createMemo, createSignal } from 'solid-js';
+import type { JSX } from 'solid-js';
+import { PositionerContext } from './context.js';
+import type { PositionerChildInfo } from './context.js';
+import { toPx } from './utils.js';
+
+export interface PositionerProps {
+ spacing?: number;
+ class?: string;
+ children?: JSX.Element;
+}
+
+type StackMode = 'none' | 'stack-center' | 'stack-fill';
+
+interface BucketGroup {
+ start: JSX.Element[];
+ center: JSX.Element[];
+ end: JSX.Element[];
+ fill: JSX.Element[];
+}
+
+interface BucketModes {
+ start: StackMode;
+ center: StackMode;
+ end: StackMode;
+ fill: StackMode;
+}
+
+const BUCKET_KEYS = ['start', 'center', 'end', 'fill'] as const;
+
+/*
+ * Two-component pattern (Positioner + PositionerInner) solves a SolidJS
+ * context ownership issue:
+ *
+ * `children(() => props.children)` evaluates child component functions
+ * lazily during rendering. Those child functions need to read
+ * `useContext(PositionerContext)` to register their alignment data.
+ *
+ * SolidJS context lookup follows the OWNER chain. If the outer
+ * Positioner calls `children()` itself, the Provider and the
+ * `children()` call are siblings in the owner tree — children
+ * evaluated by `children()` wouldn't see the Provider.
+ *
+ * Solution: Positioner renders the Provider, then delegates to
+ * PositionerInner as a child OF the Provider. PositionerInner
+ * calls `children()` — now the evaluated children are inside the
+ * Provider's rendered subtree, and context lookup finds it.
+ */
+
+export function Positioner(props: PositionerProps) {
+ const pending: PositionerChildInfo[] = [];
+ const [tick, setTick] = createSignal(0);
+
+ return (
+ t + 1);
+ },
+ unregister(info) {
+ const idx = pending.indexOf(info);
+ if (idx >= 0) pending.splice(idx, 1);
+ setTick((t) => t + 1);
+ },
+ }}
+ >
+
+ {props.children}
+
+
+ );
+}
+
+function PositionerInner(props: {
+ spacing?: number;
+ class?: string;
+ pending: PositionerChildInfo[];
+ tick: () => number;
+ children?: JSX.Element;
+}) {
+ const resolved = children(() => props.children);
+
+ const buckets = createMemo(() => {
+ props.tick();
+ /* Clear stale registrations before evaluating children — old child
+ components' onCleanup may not fire synchronously on re-eval. */
+ props.pending.length = 0;
+ const kids = resolved();
+ const kidArr = (
+ Array.isArray(kids) ? kids : kids != null ? [kids] : []
+ ) as JSX.Element[];
+ const regs = props.pending;
+ const regCount = regs.length;
+
+ if (kidArr.length !== regCount) {
+ console.warn(
+ `[Positioner] ${regCount} child(ren) registered but ${kidArr.length} found. All direct children of Positioner must call useUnion.`,
+ );
+ }
+
+ const items: BucketGroup = { start: [], center: [], end: [], fill: [] };
+ const modes: BucketModes = { start: 'none', center: 'none', end: 'none', fill: 'none' };
+ const bg: BucketGroup = { start: [], center: [], end: [], fill: [] };
+ const content: BucketGroup = { start: [], center: [], end: [], fill: [] };
+
+ const n = Math.min(kidArr.length, regCount);
+ for (let i = 0; i < n; i++) {
+ const info = regs[i];
+ const container = info.container();
+ const h = info.horizontal();
+ const v = info.vertical();
+ const key = h === 'stack-center' || h === 'stack-fill' ? 'start' : (
+ h === 'start' || h === 'center' || h === 'end' || h === 'fill' ? h : 'start'
+ );
+
+ if (container === 'background') {
+ bg[key].push(kidArr[i]);
+ } else if (container === 'content') {
+ content[key].push(kidArr[i]);
+ } else {
+ items[key].push(kidArr[i]);
+ }
+
+ /* Track stack mode per-bucket — if any child in a bucket has
+ stack-center/stack-fill, the whole bucket switches to column. */
+ if (v === 'stack-center' && modes[key] === 'none') modes[key] = 'stack-center';
+ if (v === 'stack-fill') modes[key] = 'stack-fill';
+ }
+
+ for (let i = regCount; i < kidArr.length; i++) {
+ items.start.push(kidArr[i]);
+ }
+
+ return { items, modes, bg, content };
+ });
+
+ const b = buckets();
+
+ return (
+
+
+
+
+
+ {renderOverlay('positioner-bg', b.bg, props.spacing)}
+ {renderOverlay('positioner-content', b.content, props.spacing)}
+
+ );
+}
+
+function renderOverlay(cls: string, group: BucketGroup, spacing?: number) {
+ const hasItems = BUCKET_KEYS.some((k) => group[k].length > 0);
+ if (!hasItems) return null;
+
+ return (
+
+ {BUCKET_KEYS.map((key) => (
+
+ ))}
+
+ );
+}
+
+function BucketRow(props: {
+ group: BucketGroup;
+ modes: BucketModes;
+ spacing?: number;
+}) {
+ return (
+ <>
+ {BUCKET_KEYS.map((key) => (
+
+ ))}
+ >
+ );
+}
+
+function PositionerBucket(props: {
+ kids: JSX.Element[];
+ class: string;
+ fill?: boolean;
+ column?: boolean;
+ align?: string;
+}) {
+ return (
+
+ {props.kids}
+
+ );
+}
diff --git a/packages/solid-plugin/src/StyledRect.tsx b/packages/solid-plugin/src/StyledRect.tsx
index dd90be7..9273da6 100644
--- a/packages/solid-plugin/src/StyledRect.tsx
+++ b/packages/solid-plugin/src/StyledRect.tsx
@@ -8,6 +8,7 @@ export type StyledRectElement = keyof JSX.IntrinsicElements;
export interface StyledRectProps {
style?: UnionStyleProps;
+ css?: JSX.CSSProperties;
as?: StyledRectElement;
class?: string;
children?: JSX.Element;
@@ -31,19 +32,8 @@ function styleFromUnionProps(props: UnionStyleProps | undefined): JSX.CSSPropert
if (props.paddingBottom !== undefined) s['padding-bottom'] = toPx(props.paddingBottom);
if (props.paddingLeft !== undefined) s['padding-left'] = toPx(props.paddingLeft);
- /* Inset: spacing around the background layer. Single-div can't faithfully
- separate inset from padding/margin — multi-div overlay from design doc
- not yet implemented. For now, prefer padding over margin semantics
- since inset shrinks background around content. */
- if (props.inset !== undefined) s.margin = toPx(props.inset);
- if (props.insetTop !== undefined) s['margin-top'] = toPx(props.insetTop);
- if (props.insetRight !== undefined) s['margin-right'] = toPx(props.insetRight);
- if (props.insetBottom !== undefined) s['margin-bottom'] = toPx(props.insetBottom);
- if (props.insetLeft !== undefined) s['margin-left'] = toPx(props.insetLeft);
-
- /* Margin: spacing between sub-elements. Applied after inset so it
- takes precedence when both are set on the same element — theme
- authors should not set both for the same element. */
+ /* Margin: spacing between sub-elements. The union css "inset" property (background
+ container shrink) is wholly owned by the Positioner — StyledRect never maps it. */
if (props.margin !== undefined) s.margin = toPx(props.margin);
if (props.marginTop !== undefined) s['margin-top'] = toPx(props.marginTop);
if (props.marginRight !== undefined) s['margin-right'] = toPx(props.marginRight);
@@ -110,34 +100,72 @@ function styleFromUnionProps(props: UnionStyleProps | undefined): JSX.CSSPropert
s['box-shadow'] = parts.join(' ');
}
- /* Outline as inset box-shadow — check for uniform (all four sides equal) */
- const hasLeftOutline = props.outlineLeftSize !== undefined && props.outlineLeftColor;
- const hasRightOutline = props.outlineRightSize !== undefined && props.outlineRightColor;
- const hasTopOutline = props.outlineTopSize !== undefined && props.outlineTopColor;
- const hasBottomOutline = props.outlineBottomSize !== undefined && props.outlineBottomColor;
- const allOutlineSides = hasLeftOutline && hasRightOutline && hasTopOutline && hasBottomOutline;
- const uniformOutline = allOutlineSides
- && props.outlineLeftSize === props.outlineRightSize
- && props.outlineLeftSize === props.outlineTopSize
- && props.outlineLeftSize === props.outlineBottomSize
- && props.outlineLeftColor === props.outlineRightColor
- && props.outlineLeftColor === props.outlineTopColor
- && props.outlineLeftColor === props.outlineBottomColor;
-
- const outlineShadows: string[] = [];
- if (uniformOutline) {
- outlineShadows.push(`inset 0 0 0 ${toPx(props.outlineLeftSize)} ${props.outlineLeftColor}`);
- } else {
- if (hasLeftOutline) outlineShadows.push(`inset ${toPx(props.outlineLeftSize)} 0 0 0 ${props.outlineLeftColor}`);
- if (hasTopOutline) outlineShadows.push(`inset 0 ${toPx(props.outlineTopSize)} 0 0 ${props.outlineTopColor}`);
- if (hasRightOutline) outlineShadows.push(`inset 0 0 ${toPx(props.outlineRightSize)} 0 ${props.outlineRightColor}`);
- if (hasBottomOutline) outlineShadows.push(`inset 0 0 0 ${toPx(props.outlineBottomSize)} ${props.outlineBottomColor}`);
+ /* Outline — flattened to native CSS `outline`.
+ *
+ * TODO: Per-side outline (+ expanded corner radius per adjacent widths)
+ * requires the pseudo-element approach from styledrect-css-mapping-design(1).md §4.6:
+ *
+ * outline pseudo-element {
+ * content: "";
+ * position: absolute;
+ * pointer-events: none;
+ * inset: -Npx;
+ * border: Npx solid color;
+ * border-radius: calc(R + min(Nadj))px;
+ * }
+ *
+ * Union expresses outline as per-side widths/colors/styles, but CSS
+ * `outline` is inherently uniform. For temporary simplicity, we flatten
+ * all sides into a single uniform outline by picking the first side
+ * with a defined width. Per-side differences are dropped at this layer.
+ */
+ const outlineW = props.outlineLeftSize ?? props.outlineRightSize ?? props.outlineTopSize ?? props.outlineBottomSize;
+ if (outlineW !== undefined) {
+ const outlineC = props.outlineLeftColor ?? props.outlineRightColor ?? props.outlineTopColor ?? props.outlineBottomColor;
+ const outlineS = props.outlineLeftStyle ?? props.outlineRightStyle ?? props.outlineTopStyle ?? props.outlineBottomStyle;
+ s.outline = [toPx(outlineW), outlineS ?? 'solid', outlineC].filter(Boolean).join(' ');
+ s['outline-offset'] = '0px';
+ } else if (props.outline) {
+ s.outline = props.outline;
+ s['outline-offset'] = '0px';
+ s['outline-style'] = 'solid';
}
- if (outlineShadows.length > 0) {
- const existing = s['box-shadow'] || '';
- s['box-shadow'] = existing
- ? `${existing}, ${outlineShadows.join(', ')}`
- : outlineShadows.join(', ');
+
+ /* Positioner child — self-applied layout properties.
+ * These are no-ops outside a flex container (when not inside a ). */
+ if (props.layoutAlignmentOrder !== undefined) s.order = props.layoutAlignmentOrder;
+ if (props.layoutAlignmentVertical) {
+ const v = props.layoutAlignmentVertical;
+ s['align-self'] = v === 'start' ? 'flex-start'
+ : v === 'center' ? 'center'
+ : v === 'end' ? 'flex-end'
+ : v === 'fill' ? 'stretch'
+ : undefined;
+ }
+
+ return s;
+}
+
+/* ── Positioner child style utility ─────────────────────────── */
+
+export function positionerChildStyle(props: UnionStyleProps | undefined): JSX.CSSProperties {
+ if (!props) return {};
+ const s: JSX.CSSProperties = {};
+
+ if (props.margin !== undefined) s.margin = toPx(props.margin);
+ if (props.marginTop !== undefined) s['margin-top'] = toPx(props.marginTop);
+ if (props.marginRight !== undefined) s['margin-right'] = toPx(props.marginRight);
+ if (props.marginBottom !== undefined) s['margin-bottom'] = toPx(props.marginBottom);
+ if (props.marginLeft !== undefined) s['margin-left'] = toPx(props.marginLeft);
+
+ if (props.layoutAlignmentOrder !== undefined) s.order = props.layoutAlignmentOrder;
+ if (props.layoutAlignmentVertical) {
+ const v = props.layoutAlignmentVertical;
+ s['align-self'] = v === 'start' ? 'flex-start'
+ : v === 'center' ? 'center'
+ : v === 'end' ? 'flex-end'
+ : v === 'fill' ? 'stretch'
+ : undefined;
}
return s;
@@ -147,10 +175,12 @@ export function StyledRect(props: StyledRectProps) {
const inlineStyle = createMemo(() => {
const base: JSX.CSSProperties = {
position: 'relative',
+ 'overflow': 'visible',
display: 'flex',
- 'flex-direction': 'column',
'box-sizing': 'border-box',
+ 'background-clip': 'padding-box',
...styleFromUnionProps(props.style),
+ ...props.css,
};
/* Merge style: directive overrides used by call sites */
for (const key of Object.keys(props)) {
@@ -165,7 +195,7 @@ export function StyledRect(props: StyledRectProps) {
});
const passthrough = () => {
- const { style: _, as: _as, children: _c, class: _cl, ...rest } = props;
+ const { style: _, css: _css, as: _as, children: _c, class: _cl, ...rest } = props;
const clean: Record = {};
for (const key of Object.keys(rest)) {
if (!key.startsWith('style:')) {
diff --git a/packages/solid-plugin/src/context.ts b/packages/solid-plugin/src/context.ts
index 4ecc159..5fc3b45 100644
--- a/packages/solid-plugin/src/context.ts
+++ b/packages/solid-plugin/src/context.ts
@@ -20,3 +20,18 @@ export interface UnionAncestorData {
}
export const UnionAncestorContext = createContext({ elements: [] });
+
+/* Positioner */
+
+export interface PositionerChildInfo {
+ horizontal: () => string;
+ vertical: () => string;
+ container: () => string;
+}
+
+export interface PositionerCtx {
+ register(info: PositionerChildInfo): void;
+ unregister(info: PositionerChildInfo): void;
+}
+
+export const PositionerContext = createContext(null);
diff --git a/packages/solid-plugin/src/index.ts b/packages/solid-plugin/src/index.ts
index 6b98973..c8c2648 100644
--- a/packages/solid-plugin/src/index.ts
+++ b/packages/solid-plugin/src/index.ts
@@ -1,15 +1,17 @@
/* Core infrastructure */
export { UnionIRProvider } from './UnionIRProvider.js';
export type { UnionIRProviderProps } from './UnionIRProvider.js';
-export { UnionIRContext, UnionAncestorContext } from './context.js';
-export type { UnionIR, UnionAncestorData, AncestorElementData } from './context.js';
+export { UnionIRContext, UnionAncestorContext, PositionerContext } from './context.js';
+export type { UnionIR, UnionAncestorData, AncestorElementData, PositionerCtx, PositionerChildInfo } from './context.js';
export { useUnion } from './useUnion.js';
export type { UseUnionOptions, UseUnionResult } from './useUnion.js';
export { styleGroupToUnionStyleProps } from '@union-web/core';
export type { UnionStyleProps } from '@union-web/core';
export { toPx } from './utils.js';
-export { StyledRect } from './StyledRect.js';
+export { StyledRect, positionerChildStyle } from './StyledRect.js';
export type { StyledRectProps, StyledRectElement } from './StyledRect.js';
+export { Positioner } from './Positioner.js';
+export type { PositionerProps } from './Positioner.js';
/* Components */
export { ApplicationHeader } from './ApplicationHeader.js';
diff --git a/packages/solid-plugin/src/useUnion.ts b/packages/solid-plugin/src/useUnion.ts
index 40503aa..346f110 100644
--- a/packages/solid-plugin/src/useUnion.ts
+++ b/packages/solid-plugin/src/useUnion.ts
@@ -1,7 +1,7 @@
-import { useContext, createMemo, createComponent } from 'solid-js';
+import { useContext, createMemo, createComponent, onCleanup } from 'solid-js';
import { createElement, resolveElement, matchRules } from '@union-web/core';
import type { StylePropertyGroup } from '@union-web/core';
-import { UnionIRContext, UnionAncestorContext } from './context.js';
+import { UnionIRContext, UnionAncestorContext, PositionerContext } from './context.js';
import type { AncestorElementData } from './context.js';
const DEFAULT_STATES = [
@@ -189,6 +189,18 @@ export function useUnion(options: UseUnionOptions): UseUnionResult {
console.groupEnd();
}
+ /* Auto-register with parent Positioner if one exists */
+ const posCtx = useContext(PositionerContext);
+ if (posCtx) {
+ const info = {
+ horizontal: () => resolvedCurrent().layout?.alignment?.horizontal ?? 'start',
+ vertical: () => resolvedCurrent().layout?.alignment?.vertical ?? 'center',
+ container: () => resolvedCurrent().layout?.alignment?.container ?? 'item',
+ };
+ posCtx.register(info);
+ onCleanup(() => posCtx.unregister(info));
+ }
+
return {
styles,
current: resolvedCurrent,