Server-side mechanics
Home-UI’s premise is that a Go server owns the state and HTMX moves markup around. This page is that premise written for the host: what the server owns, what it must send, what it gets back, and what happens to a live component when the markup underneath it is replaced.
What the server owns
Section titled “What the server owns”The host owns state, validation, persistence and routing. Home-UI owns appearance and interaction. A component holds only the interaction state it cannot delegate - which item is highlighted, whether a panel is open - and never application state.
Concretely, the server writes attributes; the component reflects the ones a reader can change back to attributes:
| The server writes | The component reflects back |
|---|---|
name, value, disabled, required, aria-invalid | open, aria-expanded, aria-selected, aria-checked, data-theme |
| Every option, item and message the host renders | the current value after an interaction |
Anything the server does not own, it must not pretend to: there is no api, auth-token or endpoint attribute, and a combobox does not fetch.
Getting values in
Section titled “Getting values in”Attributes are the input surface. A component rendered already open, already selected or already invalid needs no script, because the attribute is in the server’s HTML:
<hui-select name="courseId" value="{{ .CourseID }}"> {{ range .Courses }} <div role="option" data-value="{{ .ID }}">{{ .Name }}</div> {{ end }}</hui-select>
<hui-dialog open side="right" label="Filters"> <button slot="trigger">Filters</button> <p>…</p></hui-dialog>
<hui-input name="project" value="{{ .Project.Name }}" {{ if .Errors.Project }}aria-invalid="true"{{ end }}></hui-input>Getting values out
Section titled “Getting values out”Every value control is form-associated through ElementInternals. Give it a name and it submits like a native control:
<form hx-post="/race-entry" hx-target="#result"> <hui-input name="name" value="Ada" aria-label="Name"></hui-input> <hui-checkbox name="agree" value="yes" checked aria-label="Agree"></hui-checkbox> <hui-checkbox name="extra" value="x" aria-label="Extra"></hui-checkbox> <hui-slider name="volume" value="42" aria-label="Volume"></hui-slider> <hui-input-otp name="code" length="4" value="1234" aria-label="Code"></hui-input-otp> <hui-button type="submit">Send</hui-button></form>That is the harness case formScenario (packages/ui/test-gallery/cases.ts), and the integration suite submits it and asserts the request carries name=Ada, agree=yes, volume=42, code=1234 and not extra - an unchecked box contributes nothing, exactly as a native one does.
- A control with no
namesubmits nothing. This is the silent failure to watch for: the field looks right, the form posts, and the value is absent. hui-slider[range]submits one joined value,low,high, under a single name.hui-toggle-groupin multiple mode submits one entry per pressed item under the same name.disabledcontrols are omitted, as native ones are.
Source
<form><hui-input name="name" value="Ada" aria-label="Name"></hui-input><hui-checkbox name="agree" value="yes" checked aria-label="Agree"></hui-checkbox><hui-checkbox name="extra" value="x" aria-label="Extra"></hui-checkbox><hui-slider name="volume" value="42" aria-label="Volume" style="max-width:12rem"></hui-slider><hui-input-otp name="code" length="4" value="1234" aria-label="Code"></hui-input-otp><hui-button type="submit">Send</hui-button></form>Submitting
Section titled “Submitting”hui-button[type="submit"] is form-associated for one reason: it calls form.requestSubmit(), so the form fires a real submit event that hx-post sees. A plain <button> inside a component’s shadow root would not. type="reset" calls form.reset(), and every associated control restores the value captured from its value attribute on connect - the server-rendered attribute is the default, not something the component invents.
An hx-swap removes and re-creates the markup. The new elements upgrade and re-associate; the old ones clean up in disconnectedCallback:
- Listeners and timers are removed. A component never leaves a listener on
document. - An open overlay leaves the top layer.
hui-dialogand the popover-based overlays close on disconnect, so a swap cannot leave the page inert with nothing to dismiss. That was the shape ofISS-007. - Focus is not stolen. If the region that held an open overlay’s trigger is swapped away, the shared restore helper falls back rather than throwing, but restoring focus deliberately is the host’s job:
document.body.addEventListener('htmx:afterSwap', () => { document.querySelector('[autofocus]')?.focus();});Server-rendered validation
Section titled “Server-rendered validation”Business validation is the server’s. On failure it re-renders the same markup with aria-invalid and the error text, and hui-field wires both to the control for a screen reader:
<hui-field> <span data-label>Project name</span> <hui-input data-control name="project" value="{{ .Project.Name }}" {{ if .Errors.Project }}aria-invalid="true"{{ end }}></hui-input> {{ with .Errors.Project }}<span data-error>{{ . }}</span>{{ end }}</hui-field>aria-invalid is the single invalid signal across every control; there is no component-specific error attribute. This is how a Go validation error reaches a screen reader after a swap. See Screen readers.
Server-owned state and viewer-owned state
Section titled “Server-owned state and viewer-owned state”The rule the composition primitives will follow (UI-CONTRACT-007) is the one to hold now: the markup is the state. What the server rendered is what the component shows. The host persists and echoes back; the component does not store it. Where a component keeps something, it is the interaction state a swap would otherwise interrupt, and it is gone when the element is.
The theme goes on the server’s output, not into script:
<html lang="en" class="{{ .Theme }}"> <body class="hui-surface">.Theme is "light" or "dark", read from the cookie before first paint, so there is no flash. hui-theme-toggle exists for a host that would rather let the reader choose in the browser; a server-themed host does not load it. See Dark mode.