esc
The Poor Man's Signals
The Solid Convergence

The Poor Man's Signals

Eurasia (in which headers become wisdom) --- Previously on The Solid Convergence... V4 had risen. The cp -R methodology harvested V3's beauty without its baggage. Gaby made the login screen glow. The...

December 15, 2025

Eurasia (in which headers become wisdom)


Previously on The Solid Convergence…

V4 had risen. The cp -R methodology harvested V3’s beauty without its baggage. Gaby made the login screen glow. The Lizard approved.

But the sidebar had secrets yet to reveal.

The Three Rivers Problem

09:00 — The shell worked. Three regions, three rivers of data:

┌─────────────────────────────────────────────────────────┐
│  TOPBAR - notifications stream in, transient           │
├─────────────────────────────┬───────────────────────────┤
│                                 │                       │
│  CHAT                           │  SIDEBAR              │
│  - hours of conversation        │  - credentials        │
│  - context accumulates          │  - agents             │
│  - long-lived                   │  - stores             │
│                                 │  - click click click  │
│                                 │                       │
└─────────────────────────────┴───────────────────────────┘

The Squirrel’s eyes lit up. “Signals! Each region needs signals! We can track—”

🦎 “What happens to the signals when the user clicks around the sidebar for two hours?”

The Squirrel paused. Its tiny paw, already reaching for the docs, froze mid-air.

The Accumulation

In v3’s world, signals live until the page dies. Beautiful for multi-page apps—each navigation is a fresh start. The browser’s refresh button is the garbage collector.

But Solid doesn’t refresh. The user opens the app Monday morning. Starts a chat. Opens the sidebar. Edits a credential—signals spawn. Navigates to agents—more signals. Back to credentials—more signals. The old ones don’t die. They can’t. The page never reloaded.

// Hour 1: 
_solid.credential_1.name = "api-key"
_solid.credential_1.secret = "***"

// Hour 2 (navigated away and back):
_solid.credential_1.name = "api-key"      // orphan
_solid.credential_1.secret = "***"        // orphan  
_solid.credential_2.name = "api-key"      // new instance
_solid.credential_2.secret = "***"        // new instance

// Hour 8:
// 847 orphaned signals
// "Why is the tab using 2GB of RAM?"

🐿️ “We could… namespace them? With components? And then manually clean—”

🦎 “Pretzel.”

“What?”

🦎 “You’re building a pretzel. Stop.”

The Choice

Three paths through the labyrinth:

  1. Fit the app to Signals — Page per config, lose the unified experience
  2. Build the pretzel — Manual signal lifecycle, component namespacing, cleanup on navigate
  3. Different tool — Let the DOM be the only client state

The Squirrel wanted door #2. It always wants door #2. Door #2 has abstractions.

🦎 “What does the server already know?”

The Server Knows

The server knows everything:

  • Which sidebar mode (compact vs detailed)
  • Which credential is being edited
  • Whether there are unsaved changes
  • What the breadcrumbs should say

The client just needs to tell the server what context it’s in. One header. One hint.

<body data-solid-sidebar="detailed">
// On every HTMX request, inject what we know
document.body.addEventListener('htmx:configRequest', (e) => {
    for (const [key, value] of Object.entries(document.body.dataset)) {
        if (key.startsWith('solid')) {
            e.detail.headers[key] = value;
        }
    }
});
// Server reads it, renders accordingly
func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        headers := make(map[string]string)
        for name, values := range r.Header {
            if strings.HasPrefix(strings.ToLower(name), "solid") {
                headers[strings.ToLower(name)] = values[0]
            }
        }
        ctx := WithSolidHeaders(r.Context(), headers)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

The Squirrel stared. “That’s… that’s just headers.”

🦎 “Poor man’s signals.”

The Idiomorph Gambit

But signals do something headers can’t: they update the UI reactively.

Or do they?

Signal way:
  signal changes → DOM updates automatically

Solid way:
  blur fires → PUT to server → server returns HTML → idiomorph merges

Same result. Different path. The server re-renders the whole editor—breadcrumbs, form, footer—and idiomorph figures out what changed.

// The editor container - morphs as a unit
templ Editor(cred *Credential, crumbs Breadcrumbs, form FormData) {
    <div id="credential-editor" hx-swap="morph">
        @Breadcrumbs(crumbs.WithStatus(form.Status()))
        @Form(form)
        @Footer(form)
    </div>
}

When you blur a field:

  1. PUT fires with the new value
  2. Server saves to GitStore draft
  3. Server re-renders Editor with updated state
  4. Idiomorph merges: focus preserved, only changes applied
  5. Breadcrumb now says “Changed”, footer has “Save” button

No signals. No cleanup. The old DOM is gone. The new DOM is truth.

🐿️ “But what about the edited indicators? The little dots that show which fields changed?”

🦎 “Server knows. Server renders.”

func (h *Handlers) SaveFieldDraft(w http.ResponseWriter, r *http.Request) {
    // Save the draft
    h.store.SaveDraft(id, field, value)
    
    // Re-render the whole editor with new state
    Editor(cred, breadcrumbs, formData).Render(r.Context(), w)
}

The Resizable Revelation

14:00 — The sidebar needed to resize. Drag a divider, change the width. Simple enough.

But the sidebar morphs. New content swaps in. CSS variables on the sidebar element would die with each morph.

🐿️ “State! We need to persist the—”

🦎 “Put it on the parent.”

// Width lives on #app, not #sidebar
app.style.setProperty('--sidebar-user-width', `${width}px`);

The sidebar morphs. The width survives. CSS variables on ancestors outlive their children.

15:00 — But compact mode shows icons. Detailed mode shows full content. The server needs to know which to render.

// When width crosses threshold, update body
document.body.dataset.solidSidebar = width > threshold ? 'detailed' : 'compact';

// Trigger a refresh
htmx.trigger(document.body, 'sidebar:refresh');

The sidebar re-fetches itself. The solidSidebar: detailed header flows through. The server renders the detailed view with stats widgets and full labels.

No signals. Just headers. Poor man’s signals.

The Tally

Signals accumulated:     0
Headers convention:      data-solid-* → solidCamelCase → context
Morph target:           #credential-editor (breadcrumbs + form + footer)  
Width persistence:      CSS variable on #app
Mode detection:         30% viewport threshold
Stats widget:           Visible in detailed mode only

What survived from v3:
  - Morphs (idiomorph)
  - Server-sent events (hx-ext="sse")  
  - Server is truth philosophy

What we left behind:
  - Client signals
  - Component namespacing
  - Signal lifecycle management
  - The pretzel

The Convention

HTML:     data-solid-sidebar="compact"
JS:       document.body.dataset.solidSidebar
Header:   solidSidebar: compact
Go:       GetSolidHeader(ctx, HeaderSidebar)

Adding a new “signal”:

  1. Set data-solid-foo="bar" on body
  2. Read GetSolidHeader(ctx, "solidFoo") in handler
  3. There is no step 3

🦎 “The best abstraction is the one you don’t think about.”

The Moral

We loved v3. We really did. The morphs were beautiful. The signals were elegant. The SSE integration was first-class.

But v3 was built for a world where pages reload. Where signals die naturally. Where the garbage collector is the browser’s refresh button.

Solid lives in a different world. Three rivers. Hours of context. A sidebar that clicks a thousand times.

So we kept what worked:

  • Morphs → idiomorph
  • SSE → hx-ext=“sse”
  • Server truth → even more server truth

And replaced what didn’t:

  • Signals → headers (poor man’s signals)
  • Components → well-known morph targets
  • Client state → what client state?

The Squirrel still twitches when it sees data-solid-*. “It’s not reactive,” it whispers.

🦎 “It’s reactive enough. The server reacts. The DOM updates. The user doesn’t know the difference.”

“But—”

🦎 “Fuckit.”

🐿️☕🦎


Post-credits scene:

The sidebar resizes smoothly. The stats widget appears at 30% width.

Somewhere in the internets, someone asks: “How do I persist state across morphs?”

A lizard appears in the thread.

“Put it on the parent. Or in a header. Or both.”

The questioner is confused. “But what about signals?”

“What signals?”

The lizard vanishes.

The sidebar keeps working.


See also:

The Saga (in which we learn by doing):

The Changelog (what actually shipped):

  • db3835e feat(uimode): Generic solid* header convention
  • 36b5759 feat(shell): Add resizable sidebar with drag divider
  • 33ff380 feat(credential): Add state-aware editor container
  • f527275 feat(credential): Add idiomorph form re-render on blur

The References (standing on shoulders):

  • Idiomorph — The morph algorithm that makes this possible