49 KiB
Gui DOM, Tailwind, and flat conditional values
Status
Design plan, July 2026. This consolidates the current direction for:
- regular Gui and its future inline style syntax;
- the separate
@hanzogui/tailwindfrontend; - Gui-owned DOM components aligned with React Strict DOM;
- the standalone
hanzogui/domentry; - compiler, runtime, type, bundle, and migration boundaries.
The design is intentionally compiler-led. V2 applications receive an explicit V3 codemod, while DOM and Tailwind authoring get strict package and compiler boundaries.
Decisions
hanzoguiand@hanzogui/coreremain regular Gui.- There is no
@hanzogui/inlinepackage. - Tailwind authoring lives in
@hanzogui/tailwind. - There is no unified inline-and-Tailwind mode and no global
styleMode. - Gui owns its DOM implementation. React Strict DOM is the semantic reference and conformance oracle, not a runtime dependency.
- DOM components are available from regular Gui and Tailwind roots.
- Standalone DOM is available from
hanzogui/domand@hanzogui/core/dom. - The standalone style API is
style(), notcss.create(). style(definition)uses the same style-definition grammar asstyled(Component, definition), with the component argument removed.tailwind-mergeis removed from Gui.- New DOM authoring always requires the compiler. It cannot bail to an untransformed runtime path.
- Conditional styling moves into individual property values in v3. The property supplies the utility family and its value uses exact Tailwind candidate suffixes and modifiers.
- The new flat value grammar does not use
$. The V3 migration codemod removes it from statically known token values. Raw numeric JSX values keep their existing pixel behavior. - In v3,
bgstops being a fixed alias forbackgroundColorand becomes the complete background candidate family. A resolved color candidate still writes onlybackgroundColor; it does not lower blindly to the resetting CSSbackgroundshorthand. - Built-in names in the V6 default config use kebab-case wherever a name has
multiple parts. The flat authoring syntax uses those names without
$. - Group variants use Tailwind's exact modifier spelling:
group-hover:for an unnamed group andgroup-hover/card:for a named group. - Groups and query containers are independent.
groupexposes parent state and does not establish a size container. On regular Gui components, booleancontaineris the shorthand for an unnamedcontainerType="inline-size"container. Named and full-size containers usecontainerNameandcontainerType. - Plain responsive modifiers such as
sm:are viewport media queries. The@prefix is reserved for container queries:@sm:targets the nearest container and@sm/card:targets a named container.
Product shape
Regular Gui
import { View, Text, html, styled } from '@hanzo/gui'
const Card = styled(View, {
p: '4',
bg: 'surface hover:surface-hover',
})
export function Example() {
return (
<html.main p="4">
<html.h1 color="color">Account</html.h1>
<Card>Content</Card>
</html.main>
)
}
The lower-level equivalent remains:
import { View, Text, html, styled } from '@hanzogui/core'
Regular Gui retains:
- shorthand and longhand style props;
- tokens, themes, media, variants, groups, and animations;
styled();- existing
View,Text, and component behavior; - runtime correctness for existing non-DOM components.
The V3 style direction changes how conditions are written, while the package and component model remain familiar.
Tailwind Gui
import { View, Text, html, styled } from '@hanzogui/tailwind'
export function Example() {
return (
<html.main className="p-4 bg-surface">
<html.h1 className="text-color">Account</html.h1>
<View className="p-4 hover:bg-surface-hover">Content</View>
</html.main>
)
}
@hanzogui/tailwind owns:
- Tailwind-style
classNameauthoring; - class-oriented component and
styled()types; - the Gui candidate grammar;
- compiler integration;
- its Tailwind and Vite integration.
It shares the renderer, config, tokens, themes, events, refs, accessibility,
animations, and normalized style output with core. It does not import the
@hanzogui/core root or its inline-style frontend.
Standalone Gui DOM
import { html, style } from '@hanzo/gui/dom'
// lower-level alias: @hanzogui/core/dom
const root = style({
display: 'flex',
padding: 16,
backgroundColor: 'surface hover:surface-hover',
})
const heading = style({
color: 'color',
fontSize: 24,
})
export function Example() {
return (
<html.main style={root}>
<html.h1 style={heading}>Account</html.h1>
</html.main>
)
}
style() creates one style handle per call. It does not return a namespace
containing named sub-objects.
Conceptually:
styled(Component, definition)
style(definition)
Both consume the same style-definition grammar. styled() binds the
definition to a component, while style() returns an opaque compiled handle
for the DOM style prop.
The standalone entry contains:
- strict DOM component props;
html.*;style();- opaque compiled style handles and their conditional composition;
- the minimum DOM behavior runtime needed after compilation.
It contains neither regular Gui style props nor the Tailwind parser.
The public name is Gui DOM or DOM mode. The word strict describes the
contract but is not part of the entrypoint or product name.
One runtime, three frontends
regular Gui props ──────┐
│
Tailwind class candidates ──┼─> shared property/condition/value IR
│ │
DOM style() definitions ────┘ ├─> web CSS and semantic tags
└─> native style records and primitives
The shared runtime owns:
- config, tokens, themes, and media state;
- component state and contexts;
- events, refs, accessibility, and focus behavior;
- animations;
- normalized style output;
- DOM tag classification and semantic adapters.
The frontends own only authoring syntax, types, parsing, and compiler lowering.
Styling contracts
Each strict DOM component receives exactly one styling language based on its import source.
| Import | DOM styling language |
|---|---|
hanzogui, @hanzogui/core |
regular Gui style props |
@hanzogui/tailwind |
Tailwind className |
hanzogui/dom, @hanzogui/core/dom |
style() handles |
Examples:
// regular Gui
<html.div p="4" bg="surface hover:surface-hover" />
// Tailwind
<html.div className="p-4 bg-surface hover:bg-surface-hover" />
// standalone DOM
<html.div style={root} />
The strict inline DOM types reject Tailwind className. The strict Tailwind
DOM types reject Gui style props. Standalone DOM accepts compiled style
handles rather than either frontend.
Ordinary Gui components may retain raw className and style where needed
for existing web and React Native interoperability. Core never interprets a
raw className as Gui Tailwind syntax.
Web-aligned transitions
V3 should align timing-transition authoring with the CSS transition shorthand
and its five longhands:
transitionProperty;transitionDuration;transitionTimingFunction;transitionDelay;transitionBehavior.
The leading shorthand direction is:
// CSS defaults fill in property=all, timing=ease, delay=0s, behavior=normal
transition = '200ms'
// complete CSS shorthand, including per-property transitions
transition = 'opacity 150ms ease-out, transform 250ms cubic-bezier(0.2, 0, 0, 1) 50ms'
The expanded form uses the real web longhands rather than a second Gui-specific object:
transitionProperty = 'opacity, transform'
transitionDuration = '150ms, 250ms'
transitionTimingFunction = 'ease-out, cubic-bezier(0.2, 0, 0, 1)'
transitionDelay = '0ms, 50ms'
transitionBehavior = 'normal'
Configured animation-driver presets remain useful for springs and shared product motion:
transition = 'quick'
transition = 'bouncy'
The parser needs one deterministic resolution rule. The proposed rule is that
an exact single identifier matching a configured animation key is a preset.
Every other string follows the CSS transition grammar. CSS global values and
reserved transition keywords cannot be animation preset names. Duration-shaped
values such as 200ms always use CSS semantics, even if a legacy config has a
same-named preset. The V3 codemod must rename or expand a colliding preset when
its configured easing differs from the CSS default.
Both forms lower into one transition IR containing property, duration, timing function, delay, and behavior. The CSS driver serializes that IR without changing supported web semantics. Native timing drivers translate supported durations and easing functions. Spring presets remain driver configuration rather than pretending to be CSS.
Native does not silently approximate unsupported web behavior. Examples that
need capability diagnostics include unsupported properties, steps(), and
allow-discrete. The native capability table and the migration of the existing
array and per-property preset object forms require a focused prototype before
the V3 contract is locked.
Flat conditional values
Goal
All conditional styling belongs to the individual property value. Top-level style definitions stay flat.
The new form replaces:
hoverStyle,pressStyle,focusStyle, and other pseudo-style objects;$theme-*sub-objects;$platform-*sub-objects;- media, group, and container sub-objects;
- recursively nested combinations of those objects.
Property-scoped candidate grammar
The V3 grammar uses Tailwind candidate suffixes inside each style prop. The prop supplies the utility family:
<View p="4 sm:6" bg="red-500 hover:blue-500 dark:hover:blue-700" w="full md:[42rem]" />
Conceptually:
bg="red-500 hover:blue-500"
-> bg-red-500 hover:bg-blue-500
The implementation does not need to construct class-name strings. It binds the
candidate family before sending each suffix through the same candidate parser
and ordered style IR used by @hanzogui/tailwind.
Group state uses Tailwind's modifier grammar unchanged. A group exposes parent state and does not make the parent a size container:
// unnamed group
<View group>
<Text color="muted group-hover:foreground" />
</View>
// named group
<View group="card">
<Text color="muted group-hover/card:foreground" />
</View>
The equivalent Tailwind frontend marks the named parent with group/card and
uses the same group-hover/card: modifier on descendants. Gui-specific
native states extend the same shape, for example group-press/card:. Variants
remain stackable, such as sm:dark:group-hover/card:foreground.
Viewport and container conditions have different spellings:
// viewport sm plus card hover
color = 'muted sm:group-hover/card:foreground'
// nearest container sm plus parent hover
color = 'muted @sm:group-hover:foreground'
// named layout container sm plus named card group hover
color = 'muted @sm/layout:group-hover/card:foreground'
The @ prefix does not mean media in general. Tailwind uses ordinary sm:,
md:, and related modifiers for viewport media. Only container query variants
use @sm:, @md:, @max-md:, and their named forms.
The parent capabilities are explicit and can be combined on one element:
// regular Gui
<View group="card" container />
// Tailwind Gui
<View className="group/card @container" />
On regular Gui base components, container is boolean-only:
// nearest unnamed inline-size container
<View container />
// named inline-size container
<View container containerName="layout" />
// named container that queries both axes
<View containerName="layout" containerType="size" />
container lowers to containerType="inline-size" and does not create a
container name. An explicit containerType replaces the shorthand and cannot
be supplied together with container. containerType="size" corresponds to
Tailwind's @container-size; adding containerName="layout" corresponds to
the /layout modifier.
The boolean shorthand belongs only to regular Gui components. Tailwind
Gui uses @container, and standalone style() retains the actual CSS
container, containerName, and containerType properties. Native treats the
regular props as semantic query-container configuration.
This changes current Gui behavior, where group="card" also emits
container-name: card and defaults container-type to inline-size. V3
removes that coupling so hover-only groups do not pay container setup or native
measurement costs.
Tailwind arbitrary values are the literal escape hatch:
bg = '[linear-gradient(135deg,_#f00,_#00f)]'
w = '[117px] md:[344px]'
bg = '(--my-background)'
Using this exact spelling lets regular and Tailwind Gui share one parser. The brackets also give whitespace, functions, slashes, and modifier boundaries one unambiguous representation.
Raw JSX numbers remain raw platform values, including the existing numeric pixel behavior:
p={16}
w={117}
A numeric string is a candidate, so p="4" resolves the configured 4 token.
Conditional raw values use brackets, for example p="4 hover:[18px]".
V6 candidate naming
The Tailwind-derived V6 palette already uses kebab-case names such as
slate-500. The remaining inherited semantic theme and token names should
follow the same convention:
backgroundHover -> background-hover
backgroundPress -> background-press
borderColorHover -> border-color-hover
placeholderColor -> placeholder-color
This makes the same configured value read consistently in both frontends:
// regular Gui
bg = 'background hover:background-hover'
// Tailwind Gui
className = 'bg-background hover:bg-background-hover'
Underlying config storage may still use $ until the token representation is
migrated, but $ is absent from the new flat candidate syntax. The V3 codemod
converts built-in camelCase names to their V6 kebab-case replacements.
User-defined names retain their authored spelling, with kebab-case recommended
for new configuration. The parser must not guess camelCase-to-kebab-case
aliases at runtime because two configured names could collide.
bg is a candidate family, not a direct alias for the CSS background
shorthand. The candidate resolver determines whether a value contributes
backgroundColor, backgroundImage, backgroundPosition, or another
background property. This avoids the reset behavior of emitting
background: ... for every ordinary color.
Overloaded Tailwind families require property validation. For example,
fontSize="xl" and color="red-500" both bind to the text-* family, then
verify that the resolved candidate contributes to the property named by the
prop. A mismatched candidate is a compiler diagnostic.
The selected syntax is exact scoped Tailwind:
bg = 'red-500 hover:blue-500'
bg = '[linear-gradient(135deg,_#f00,_#00f)]'
This removes $, uses one parser, and gives arbitrary values one explicit
form. Keeping $ in candidate atoms and interpreting unknown function-shaped
tokens as bare CSS were considered and rejected. Both would create a second
permanent value path.
Types and editor tooling
The candidate grammar must not become an exhaustive TypeScript template literal union. Property types retain their existing raw value type plus a broad string:
type FlatStyleValue<T> = T | (string & {})
Candidate, token, modifier, arbitrary-value, and target validation live in the compiler and a Gui language server backed by the same candidate engine. Thin editor integrations launch that server through the Language Server Protocol. TypeScript can expose small finite unions where they remain cheap, but type performance takes priority over exhaustive string validation.
Rejected condition-call proposal
The earlier condition-call proposal was:
bg = '$red :hover($green) :light($color1/50) :dark:hover($color2/20)'
Compact input may be accepted:
bg = '$red:hover($green):dark:hover($blue)'
Documentation and formatting always use one space before each conditional clause:
bg = '$red :hover($green) :dark:hover($blue)'
This is a property-centered conditional program. It reaches a similar intermediate representation to StyleX and React Strict DOM conditional value objects, with a surface that composes naturally through JSX props.
The rest of this section records that earlier proposal so its cascade, condition, native, and validation requirements are not lost. The chosen public grammar is property-scoped Tailwind candidates, and no condition-call implementation path remains.
Grammar
program = base-value? clause*
clause = condition+ "(" property-value ")"
condition = ":" registered-condition
Example parse:
;[
{ when: [], value: '$red' },
{ when: ['hover'], value: '$green' },
{ when: ['light'], value: '$color1/50' },
{ when: ['dark', 'hover'], value: '$color2/20' },
]
A program may omit its base:
bg = ':hover($green)'
Empty clauses are invalid:
// compile error
bg = '$red :hover()'
Property values with defined semantics handle clearing. Examples include
none, transparent, initial, or unset where supported. Empty
parentheses do not mean deletion.
CSS-aware parser
The parser cannot split on colons, whitespace, commas, slashes, or parentheses. All appear in valid CSS values:
background = 'linear-gradient(to right, red 0%, blue 100%)'
shadow = '0 2px 8px rgb(0 0 0 / 20%) :hover(0 4px 16px rgb(0 0 0 / 30%))'
backgroundImage = 'url(https://example.com/a:b)'
width = 'calc(100% - var(--sidebar-width))'
fontFamily = "'IBM Plex Sans', sans-serif"
Parsing follows CSS component-value rules:
- Track balanced functions, blocks, strings, URLs, and escapes.
- Preserve whitespace inside property values.
- Recognize a clause only at the top level.
- Require
:<registered-condition>(before interpreting text as a clause. - Validate the payload according to the owning style property.
The compiler and runtime parser share one implementation and one test corpus.
Conditions
Conditions form one registry assembled from built-ins and Gui config.
Initial categories:
- state:
hover,press,active,focus,focusVisible,focusWithin,disabled,checked,selected,open,invalid; - themes:
light,dark, and configured theme conditions; - media: configured media names;
- platform:
web,native,ios, andandroid; - group and container conditions.
Simple conditions use bare registered names:
opacity = '1 :disabled(0.5)'
display = 'flex :sm(grid)'
bg = '$surface :dark($surfaceDark)'
Conditions compose as an AND chain:
bg = '$surface :dark:hover($surfaceDarkHover)'
Parameterized group or container conditions need one canonical form. The condition-call proposal used:
bg = '$surface :group[card]:hover($surfaceHover)'
That spelling does not carry into the property-scoped candidate direction.
The selected candidate spelling is group-hover/card:surface-hover, matching
Tailwind's named-group grammar. Container queries use @sm: or the named
@sm/card: form and stack independently with group state.
There is one global condition namespace. Duplicate configured names are reported when the config is created. The implementation must not silently choose between a theme, media, platform, or state condition with the same name.
Exact theme matching semantics must reuse Gui's established theme inheritance rules. The flat syntax must not invent a second definition of whether a parent theme condition matches a child theme.
Ordering and replacement
Within one property program, clauses are evaluated left to right. The last matching clause wins:
bg = '$red :hover($green) :dark($gray) :dark:hover($blue)'
When dark mode and hover are active, $blue wins.
A later property contribution replaces the entire earlier property program. It does not recursively merge clauses:
const Card = styled(View, {
bg: '$surface :hover($surfaceHover)',
variants: {
danger: {
true: {
bg: '$red',
},
},
},
})
The danger variant removes the earlier hover behavior. Preserving a
conditional behavior requires restating the complete property program:
bg = '$red :hover($redHover)'
This atomic property replacement is intentional. It makes component props, variants, spreads, and overrides predictable without recursive object merging.
Web lowering
Ordinary independent atomic classes cannot represent authored clause order. The order of class names in HTML does not control the CSS cascade, and selector specificity can override source order.
The compiler therefore lowers a complete property program together. One possible output is a hash shared by identical property programs:
._bg_ab12 {
background: var(--red);
}
._bg_ab12:where(:hover) {
background: var(--green);
}
:where(.dark) ._bg_ab12 {
background: var(--gray);
}
:where(.dark) ._bg_ab12:where(:hover) {
background: var(--blue);
}
Requirements:
- all clauses for one property program have equal effective specificity;
- emitted rule order preserves the program order;
- identical property programs deduplicate;
- the hash includes the property, program, and relevant config identity;
- server and client hashing is deterministic;
- native evaluation uses the same last-matching-clause rule.
The exact CSS encoding may differ, but these semantics are fixed.
Tokens and opacity
Gui tokens keep $:
bg = '$green'
Real CSS custom properties retain CSS syntax:
bg = 'var(--green)'
Bare --green is not the token syntax. In CSS, --green names a custom
property declaration and is not itself a value. Keeping $ makes the
cross-platform Gui token distinct from a web-only CSS variable.
Color-token opacity uses:
bg = '$green/50'
Initial rule:
- the suffix is an integer percentage from 0 through 100;
- it is recognized only directly after a color token;
- applying it to a non-color token is a compiler diagnostic;
- token alpha composition must behave identically on web and native.
Non-string values
Unconditional values keep their natural representation:
opacity={0.5}
zIndex={2}
Conditional values use a string program:
opacity = '0.5 :hover(1)'
zIndex = '2 :focus(3)'
The v4 direction prefers CSS-shaped strings for structured styles:
transform = 'scale(1) :hover(scale(1.05))'
boxShadow = '0 2px 8px #0003 :hover(0 4px 16px #0004)'
React Native object and array values that lack a faithful CSS-shaped replacement require an explicit migration table. Legacy structured values must not be converted by guesswork.
Dynamic programs
Literal programs compile completely:
bg = '$surface :hover($surfaceHover)'
Dynamic programs require the shared runtime parser unless the surrounding entrypoint is compile-only:
bg={`${base} :hover(${hover})`}
Runtime parsing is cached by property, program, and config. Dynamic values receive the same validation and injection protections as existing dynamic styles.
Standalone DOM is compile-only and reports dynamic structures that cannot be lowered safely. Existing regular Gui components remain runtime-correct during the v3 migration. Whether ordinary v4 components require compilation is a separate release-level decision.
Types and tooling
The full grammar must not be modeled as a recursive TypeScript template literal union. That would recreate the styled type-performance problem.
The public property type remains broad:
type ConditionalStyleValue<T> = T | (string & {})
Validation belongs in:
- compiler diagnostics;
- config validation;
- an editor language service;
- a formatter that emits the canonical spaced form;
- development-only diagnostics for runtime-parsed values.
TypeScript may provide lightweight token or condition suggestions, but type performance takes priority over exhaustive validation.
Relationship to Tailwind
Property-scoped candidates and Tailwind classes lower into the same ordered IR:
// regular Gui
bg = 'red hover:green dark:hover:blue'
// Tailwind Gui
className = 'bg-red hover:bg-green dark:hover:bg-blue'
Both become:
;[
{ property: 'backgroundColor', when: [], value: '$red' },
{ property: 'backgroundColor', when: ['hover'], value: '$green' },
{ property: 'backgroundColor', when: ['dark', 'hover'], value: '$blue' },
]
Shared internals include:
- property and shorthand normalization;
- token and opacity parsing;
- condition registration and validation;
- ordered contribution resolution;
- target capability diagnostics;
- web and native emission.
The two public syntaxes do not import one another.
Tailwind merging
tailwind-merge is removed completely.
For Gui-owned candidates:
<View className="p-2 p-4" />
The Tailwind frontend emits ordered contributions. Gui's resolver makes the later padding contribution win.
Unknown classes are preserved for official Tailwind on web:
<View className="grid-cols-2 grid-cols-3" />
Gui does not attempt to merge candidates it does not own. Applications
that dynamically compose unknown Tailwind utilities can install and call
tailwind-merge themselves. It is never a dependency of core or
@hanzogui/tailwind.
@hanzogui/tailwind/vite owns:
- official Tailwind scanning;
- passthrough candidate compilation;
- CSS layer ordering;
- filtering Gui-owned candidates;
- Tailwind build dependencies.
Gui DOM contract
Why Gui owns it
Wrapping official React Strict DOM would combine two component and style runtimes.
Official RSD currently owns:
- StyleX compilation and style merging;
- native media queries, pseudo states, transitions, variables, and inherited styles;
- View/Text selection and text-child wrapping;
- accessibility and event translation;
- block-flow emulation;
- ref and DOM API adaptation.
Gui already owns equivalents for much of this. A wrapper would also have to reconcile:
- RSD's StyleX-only
stylecontract; - RSD's rejection of
className; - Gui's optimized class output;
- both Babel/compiler transforms;
- duplicate native state and style processing;
- RSD's React Native version requirements.
Official RSD remains useful as:
- the semantic reference;
- a pinned prop, element, style, and native-compatibility matrix;
- a source of conformance fixtures;
- a comparison oracle for web output and supported behavior.
It is not a production dependency.
Elements and props
The first complete target is the same 49 html.* elements exposed by RSD,
with explicit element-specific prop interfaces.
The strict contract targets:
- approximately 145 named RSD props plus
data-*; - all supported non-deprecated ARIA props;
- RSD's element-specific anchor, button, image, input, label, list, select, textarea, and related contracts;
- RSD's event policy;
- a documented web and native compatibility table.
RSD deliberately excludes capture-phase JSX event props. DOM mode follows that strict contract. Capture listeners require the underlying event target API where the target supports it.
Ordinary Gui components may expose a broader web-only prop surface. Those
props carry clear @platform web JSDoc and do not weaken strict DOM mode.
The initial web-only prop gap found in regular Gui included:
onAuxClick;onFocusInandonFocusOut;onFullscreenChangeandonFullscreenError;onGotPointerCaptureandonLostPointerCapture;onPointerEnterandonPointerLeave;onPointerOutandonPointerOver;- requested capture handlers such as
onKeyDownCaptureandonWheelCapture.
The regular component expansion should use explicit generated or curated
interfaces. Intersecting every Gui component with React's complete
HTMLAttributes is rejected until a type-performance benchmark proves it
safe.
Styles
The July 2026 comparison found:
| Surface | Concrete style keys |
|---|---|
| Official RSD | 148 |
| Gui View on web | 244 |
| Gui Text on web | 263 |
| Gui View on native | 187 |
| Gui Text on native | 202 |
At that snapshot, Gui covered 139 of RSD's 148 style names on web and 131 on native. Some apparent gaps were API-shape differences:
- RSD uses raw transition properties while Gui uses animation drivers and
transition; - RSD uses conditional objects while existing Gui uses pseudo-style objects;
- RSD treats
pointerEventsas a style while Gui exposes it as a prop.
The flat conditional-value syntax brings Gui closer to RSD's property-centered model without adopting StyleX handles as the regular Gui authoring syntax.
Native cannot promise every CSS behavior. Known difficult areas include:
- grid and general inline layout;
- fixed and sticky positioning;
calc()andclamp()where React Native cannot evaluate them;- list markers and browser form behavior;
- some transition and animation properties;
- DOM inheritance and custom-property behavior;
- mixed dynamic Text/View children.
Unsupported behavior gets a compile diagnostic. DOM mode does not silently invent an approximation.
Native tag lowering
Tags are classified at compile time as View-backed, Text-backed, interactive, media, input, or unsupported.
The compiler:
- Recognizes imported
html.tagbindings using module provenance. - Supports JSX, compiled
jsx/jsxs, andcreateElementforms through the shared element IR. - Injects only the native DOM primitives used by the file.
- Maps supported attributes and events.
- Wraps direct literal string and number children of View-backed tags at compile time.
- Reports statically knowable invalid Text/View nesting.
- Reports unsupported tags, props, styles, and structures.
The runtime does not scan resolved children to rescue dynamic strings. Dynamic children retain normal React Native constraints and errors.
Native primitives own only behavior that cannot be erased:
- DOM block and flex defaults;
- dynamic inherited text styles;
- accessibility and event adapters;
- anchor and button behavior;
- image, input, textarea, and select host behavior where supported;
- development diagnostics requiring runtime knowledge.
Refs and events
DOM-shaped refs and events exceed React Native host capabilities in places. The contract is the proven subset, documented per tag and platform.
V1 does not promise:
- a complete
HTMLElementimplementation; - browser form submission on native;
- download behavior;
- file inputs;
- exact DOM bubbling for every event;
- browser dialogs;
- all ARIA relationships on native.
Compatibility expands only through concrete fixtures and conformance tests.
Compiler contract
Import provenance selects the frontend:
| Binding source | Compiler interpretation |
|---|---|
hanzogui, @hanzogui/core |
DOM plus regular Gui props |
@hanzogui/tailwind |
DOM plus Tailwind candidates |
hanzogui/dom, @hanzogui/core/dom |
DOM plus style() handles |
All lower to:
typed html element
→ validate tag, props, styles, and nesting
→ classify native backing primitive
→ normalize ordered property programs
→ emit target output
Example:
<html.main p="$4">Hello</html.main>
Approximate web output:
<main className="_p4">Hello</main>
Approximate native output:
<DOMView style={styles.p4}>
<DOMText>Hello</DOMText>
</DOMView>
Rules:
- any
html.*usage requires the DOM compiler; - the compiler cannot bail to an untransformed DOM runtime;
- a missing compiler produces an explicit development or build failure;
- published libraries using DOM mode must be precompiled or included in dependency compilation;
- literal text wrapping happens at compile time;
- unsupported native semantics produce actionable diagnostics;
- ordinary
ViewandTextretain their established runtime path.
@hanzogui/tailwind is also compiler-led. It does not ship the Tailwind parser
inside every rendered component. Dynamic class values require a statically
bounded compiler representation or a documented Tailwind-package runtime
cost. Core never pays that cost.
Package and export boundaries
Conceptual exports:
{
"hanzogui": {
".": "regular Gui plus html",
"./dom": "standalone DOM plus style()"
},
"@hanzogui/core": {
".": "regular Gui plus html",
"./dom": "standalone DOM plus style()",
"./internal-runtime": "private shared implementation boundary"
},
"@hanzogui/tailwind": {
".": "Tailwind Gui plus html",
"./vite": "Tailwind build integration"
}
}
@hanzogui/core/internal-runtime is implementation plumbing, not another user
package. Its exact name is not public API unless package tooling requires an
export.
Barrels must not reconnect the frontend graphs. Runtime exports and type exports require separate bundle and declaration checks.
Type architecture
Regular core
Core keeps its regular component prop graph and removes global styling-mode branches.
Flat conditional values add a broad string alternative to each style property. They do not create recursive condition object types or exhaustive template literal unions.
Tailwind
Tailwind components expose normal component behavior plus:
type TailwindStyleProps = {
className?: string
}
Their style types do not instantiate the regular Gui shorthand, pseudo, media, theme, and variant prop graph.
Standalone DOM
type DOMProps = StrictHTMLProps & {
style?: CompiledStyle | readonly ConditionalCompiledStyle[]
}
CSS property checking happens at the style() call. It is not intersected
into every JSX tag.
Generated strict DOM props
Generate explicit interfaces from a checked-in semantic table. The generated surface must:
- preserve element-specific props;
- add all agreed ARIA and
data-*support; - omit unsupported and capture-phase JSX props from strict DOM;
- carry platform JSDoc where behavior differs;
- avoid importing React's complete
HTMLAttributesinto every component.
Type performance is an acceptance criterion, not a post-implementation cleanup.
Bundle evidence and budgets
Historical probes
The July 2026 probes used tiny minified production bundles with React and
React Native externalized. Browser export conditions were used for web, and
the module graph was checked for .native files.
Corrected View results:
| Configuration | Web gzip |
|---|---|
main @hanzogui/web |
24.3 KB |
| v3-beta as shipped | 43.9 KB |
v3-beta with tailwind-merge externalized |
35.6 KB |
v3-beta with tailwind-merge and style grammar externalized |
31.5 KB |
The v3-beta browser bundle contained zero .native modules. It did contain
the Tailwind pipeline even when Tailwind mode was disabled at runtime.
Measured contributors:
tailwind-merge: about 8.3 KB gzip;- shared style grammar: about 4.1 KB gzip before the parser embedded in
getSplitStyles; - v3-beta total cost over main: about 19.6 KB gzip.
The expected v3-beta result after fully removing Tailwind from core was approximately 25 to 30 KB gzip.
The same probe, with StyleX externalized, measured official RSD html.div at
about 3.4 KB gzip on web and 15.8 KB on native. Those numbers describe RSD
glue rather than the complete StyleX requirement.
Current styledHtml added about 1 KB gzip to an existing Gui graph.
Layering official RSD on Gui added roughly 3.3 KB on web and 15.4 KB on
native in that probe, while also duplicating runtime responsibilities.
These measurements are comparison evidence, not permanent budgets. The final implementation gets fresh repeatable probes.
Bundle gates
Required gates:
- core
Viewcontains zero Tailwind grammar, parser, merger, or build modules; - Tailwind
Viewcontains zero regular inline-style frontend modules; - standalone DOM contains neither frontend;
- compiled DOM web output has effectively zero general style runtime;
- DOM native includes only primitives required by the tags used;
- root and type barrels do not reconnect isolated graphs;
- no
.nativemodules enter a browser-conditioned probe; - no StyleX or React Strict DOM code enters production bundles.
Initial targets:
- regular core returns to the 25 to 30 KB gzip range in the established probe;
- compiled DOM adds less than 2 KB gzip on web to an existing Gui app;
- compiled DOM adds less than 8 KB gzip on native before app-used semantic primitives;
- any miss triggers a design review rather than silently raising the budget.
Migration
Inline to Tailwind
Package imports define the mode:
import { View as InlineView } from '@hanzogui/core'
import { View as TailwindView } from '@hanzogui/tailwind'
Both use the same provider and config:
const config = createGui({
tokens,
themes,
media,
})
Migration:
- Add
@hanzogui/tailwind. - Keep existing components unchanged.
- Convert one component or file.
- Switch that file's component imports to
@hanzogui/tailwind. - Continue until the intended surface is converted.
- Remove temporary mixed imports.
There is no global switch and no combined mode.
Conditional objects to flat values
V3 makes the flat candidate grammar canonical. A component does not carry two competing condition systems.
Examples:
// existing
<View
bg="$surface"
hoverStyle={{ bg: '$surfaceHover' }}
$theme-dark={{ bg: '$surfaceDark' }}
/>
// flat values
<View
bg="surface hover:surface-hover dark:surface-dark"
/>
Group migration distinguishes parent state, viewport media, and container queries:
// existing: group hover only
<View group="card">
<Text $group-card-hover={{ color: '$foreground' }} />
</View>
// V3
<View group="card">
<Text color="group-hover/card:foreground" />
</View>
// existing: nearest group container size plus hover
<View group>
<Text $group-sm-hover={{ color: '$foreground' }} />
</View>
// V3
<View group container>
<Text color="@sm:group-hover:foreground" />
</View>
The codemod adds the boolean container prop only when descendants use a group
size condition. Pure $group-hover and $group-card-hover cases remain
groups without containers. Existing viewport media combined with group state
becomes sm:group-hover/card:, without @.
Named legacy group-container queries preserve their name during migration:
$group-card-sm-hover becomes
@sm/card:group-hover/card:foreground, and the parent receives
group="card" container containerName="card". New code should normally use
the nearest unnamed container and reserve the repeated named form for cases
that require disambiguation.
The codemod can convert statically local cases. It must report cases where:
- base and conditional values are spread from different objects;
- variants partially modify an inherited property program;
- computed property names hide the affected property;
- dynamic theme, platform, media, or group objects cannot be resolved;
- structured React Native values lack a CSS-shaped equivalent.
V3 removes the old condition object path on a release schedule with a codemod and diagnostics.
Background shorthand migration
In v3, bg changes from an alias for backgroundColor to the complete
background candidate family. Ordinary color uses remain mechanically
migratable:
// current
bg = '$surface'
// exact scoped-candidate direction
bg = 'surface'
The compiler resolves each candidate to its actual background contribution. It
must not emit the CSS background shorthand for every value because that would
reset separately authored background image, position, size, repeat, attachment,
origin, and clip values.
The migration tool can remove $ from statically known token values, convert
conditional objects into modifiers, and wrap raw literals in Tailwind arbitrary
value brackets. It reports dynamic strings and spreads whose meaning cannot be
resolved locally.
DOM adoption
Existing View and Text code does not need to migrate.
DOM adoption is explicit:
import { html } from '@hanzo/gui'
Libraries that publish html.* source either publish compiled output or
declare that consumers must compile the dependency.
Implementation sequence
Phase 1: lock the shared IR
- Define the property/candidate/condition/value IR.
- Implement property-scoped parsing through the shared Tailwind candidate engine.
- Define the modifier registry and duplicate-name diagnostics.
- Prove identical ordering on web and native.
- Add parser fuzz cases for arbitrary values, CSS punctuation, nesting, strings, URLs, and escapes.
Phase 2: isolate Tailwind
- Remove global
styleModefrom public types and runtime branches. - Extract the narrow shared runtime entry.
- Restore core
getSplitStylesto regular Gui responsibilities. - Move candidate parsing into
@hanzogui/tailwind. - Remove
tailwind-merge. - Move Vite and official Tailwind integration to
@hanzogui/tailwind/vite. - Add graph and type-entry isolation tests.
Phase 3: prove DOM lowering
- Add member-expression recognition for imported
html.div. - Prove JSX,
jsx/jsxs, andcreateElementnormalization. - Rewrite a web fixture to literal semantic tags.
- Rewrite a native fixture to placeholder DOM primitives.
- Wrap direct literal text at compile time.
- Prove one unsupported prop and one invalid-nesting diagnostic.
- Verify no RSD or StyleX code enters either bundle.
Phase 4: implement the DOM contract
- Check in the tag, attribute, event, native-backing, and compatibility tables.
- Generate explicit prop interfaces.
- Implement the minimum native semantic primitives.
- Expose regular-Gui
htmlfrom core. - Expose Tailwind
htmlfrom@hanzogui/tailwind. - Add
hanzogui/domand@hanzogui/core/dom. - Implement
style()on the same style grammar asstyled(). - Add missing-compiler failures.
Phase 5: introduce flat values
- Make property-scoped candidates the V3 regular Gui grammar.
- Parse and lower literal candidate programs.
- Define and cache runtime parsing for permitted dynamic strings.
- Add compiler diagnostics, canonical formatting, and language-service completions.
- Build the static codemod for
$, camelCase V6 built-ins, and conditional objects. - Migrate representative internal components.
- Measure type, runtime, CSS, and bundle effects.
Phase 6: complete the V3 migration
- Publish the V3 breaking-change and codemod guide.
- Finish structured native value migration rules.
- Remove old recursive condition paths after repository migration.
- Align transition shorthand and longhand behavior.
- Re-run all prop, style, DOM, bundle, and type conformance gates.
Validation
Compiler
- source and transformed-output fixtures for every frontend and target;
- execution tests for transformed web and native fixtures;
- import-provenance tests that reject unrelated
htmlobjects; - dependency-compilation and missing-compiler tests;
- deterministic server/client hash tests;
- negative tests for unsupported native semantics.
Flat values
- CSS component values containing colons, spaces, commas, slashes, strings, URLs, functions, and nested parentheses;
- chained conditions;
- condition namespace collisions;
- last-matching-clause behavior;
- whole-property replacement through styled bases, variants, and JSX props;
- web and native parity;
- color token opacity and invalid non-color opacity;
- dynamic parsing cache behavior;
- hostile dynamic values cannot inject selectors or rules.
DOM
One shared source fixture covers:
- semantic structure, headings, and text;
- direct literal text inside a View-backed tag;
- dynamic invalid text remaining an error;
- theme and inherited text styles;
- button and anchor interaction;
- image accessibility;
- input, textarea, and select behavior where supported;
- focus, keyboard, pointer, and accessibility events;
- refs within the documented subset;
- an unsupported feature diagnostic.
Compare supported behavior with a pinned official RSD fixture. Differences are recorded in the compatibility table.
Types
- element-specific positive and negative tests;
- ARIA and
data-*coverage; - strict rejection of capture-phase JSX props in DOM mode;
- regular Gui web-only props with platform JSDoc;
- Tailwind components do not instantiate regular inline style types;
- standalone DOM checks style properties at
style(); - declaration emit size and TypeScript diagnostic-time baselines.
Bundles
Repeat the established minified gzip probes for:
- core
View; - Tailwind
View; - regular core
html.div; - Tailwind
html.div; - standalone DOM
html.divplus onestyle()handle; - native equivalents;
- combined imports to measure incremental cost.
Record module graphs with every number.
Native runtime performance
Native RSD and DOM-polyfill work is sensitive to small amounts of per-element overhead. React Strict DOM PR #512 reports roughly a five percent improvement in its benchmark from changes that appear locally small:
- do not create or attach a callback ref when no ref was passed;
- replace stacked callback/ref hooks with one memoized callback;
- reuse default image props when no aspect-ratio style must be added.
Final passes over Gui DOM and RSD-aligned code must treat every hook, callback ref, wrapper object, style array, default-prop object, context read, and tag-specific polyfill on the generic native host path as a measured cost. Optional behavior must not allocate or subscribe when the corresponding prop or feature is absent.
The native benchmark gate covers:
- mount and update time for large
html.div,html.span,View, andTexttrees; - retained memory after mount, update, unmount, and remount;
- components with and without refs, styles, events, inheritance, and tag-specific polyfills;
- Hermes or a documented jitless proxy in addition to ordinary Node.js;
- comparison with the pinned RSD fixture and with Gui's direct native primitives.
Record the benchmark command, device or runtime, sample size, variance, and before/after numbers. Bundle size alone cannot approve a native DOM-path change.
Explicit exclusions
- no React Strict DOM runtime dependency;
- no StyleX runtime or StyleX compiled-object protocol;
- no promise of drop-in compatibility for third-party RSD packages in v1;
- no
@hanzogui/inline; - no combined inline-and-Tailwind mode;
- no global style-mode switch;
- no
tailwind-mergedependency; - no runtime child scan that rescues dynamic strings;
- no silent native approximation for unsupported DOM or CSS behavior;
- no exhaustive conditional grammar encoded in TypeScript;
- no dual
$tokenand--tokensyntax; - no public
strict-domproduct or entrypoint name.
Remaining design work
These decisions need focused prototypes before implementation is considered locked:
- The runtime and compiler boundary for dynamic candidate strings, including the cache key and development diagnostics.
- The CSS transition shorthand, configured-preset resolution, expanded longhands, and native capability matrix.
- Native container-query measurement timing, initial render behavior, and performance gates for explicit query containers.
- The complete built-in condition list and collision policy for common configs.
- How
style()conditionally composes multiple handles while preserving whole-property replacement. - The structured React Native value migration table.
- The minimum native DOM ref API.
- The exact dependency-precompilation metadata and error experience.
- Which dynamic regular Gui cases require the compiler in V3.
Each prototype must end with one chosen path. The implementation must not ship multiple equivalent syntaxes or runtime fallback paths.
References
- React Strict DOM common props
- React Strict DOM HTML compatibility
- React Strict DOM CSS compatibility
- React Strict DOM component guide
- Tailwind state variants
- Tailwind responsive and container-query variants
- CSS Syntax Module Level 3
- CSS transition shorthand
- CSS Transitions Level 2
- React Strict DOM PR #512: avoid ref overhead when not needed
styledHtmlintroduction: commitab8517c5e4