From f623d3a57dbeeb73465501fbb7257742967716ce Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Thu, 6 Aug 2026 21:41:39 +0000 Subject: [PATCH 1/6] feat(landing): show the finished app first, then explain it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six-step spine asked the reader to travel five steps before seeing what the thing is. This leads with the finished app — a shared greeting wall, live on the page — and its code, then annotates the parts of that code worth a second look. Each section below the fold points back at lines already read rather than adding a new one: the button name is the action, the same form works with scripting off, the HTML rule re-runs in Go, slow work has a pending state, and two Publish calls are the whole difference between "my tabs" and "everyone". Every claim keeps its live app, now as evidence rather than as a step to climb. The hero's Go is 28 lines against a real file of 249, so the page says so in the paragraph under it and links the file. Overclaiming "this is the whole app" is what the old step-1 hero could honestly say about a 20-line greet; the wall cannot. The template, by contrast, is verbatim: 9 lines, with only the server-heartbeat markup dropped because it is explained further down. The hero's two snippets stack full width instead of sitting side by side. Read in sequence rather than compared, and at 1440px a .pair column is ~420px against signatures past 60 characters — every interesting line was clipping. e2e is untouched and passes unchanged. Every test that loads "/" uses scoped embed selectors, .hero, or the iframe sandbox string; the ones with bare input[name=name] selectors navigate to standalone app URLs. All five embed paths, both nojs iframes and .hero survive the reorder. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166MK1arBYbVZq6wfm8EsQZ --- content/assets/landing.css | 12 +- content/index.md | 290 ++++++++++++++++--------------------- 2 files changed, 132 insertions(+), 170 deletions(-) diff --git a/content/assets/landing.css b/content/assets/landing.css index 8124853..50a2cf2 100644 --- a/content/assets/landing.css +++ b/content/assets/landing.css @@ -174,9 +174,10 @@ section { scroll-margin-top: 72px } .close { margin: 28px 0 0 } .note { font-size: 15px; line-height: 1.6; color: var(--body-muted); max-width: 62ch } -.hero { padding: 88px 0 64px } +.hero { padding: 88px 0 40px } .hero .sub { font-size: 18px; line-height: 1.6; color: var(--body); margin: 0; max-width: 56ch } -#whole-app { padding: 56px 0; border-top: 1px solid var(--line) } +/* The app is the hero's payload, not the next topic — no rule between them. */ +.lead-in { padding: 0 0 56px } .intro { padding: 56px 0 24px; border-top: 1px solid var(--line) } .step { padding: 40px 0; border-top: 1px solid var(--line-soft) } .cta { @@ -327,6 +328,13 @@ section { scroll-margin-top: 72px } } .pair > * { min-width: 0 } +/* The hero's two snippets are read in sequence — template, then the Go that + serves it — not compared. Side by side they get ~420px each at 1440px, and + these signatures run past 60 characters, so every interesting line clipped. + Full width fits them without a scrollbar. */ +.stack { display: grid; gap: 16px; margin-bottom: 4px } +.stack > * { min-width: 0 } + /* ══ On the wire ═════════════════════════════════════════════════════════ */ .wire { diff --git a/content/index.md b/content/index.md index b2b83e3..c0122cf 100644 --- a/content/index.md +++ b/content/index.md @@ -27,13 +27,14 @@ layout: landing @@ -51,43 +52,94 @@ layout: landing -
-
Step 1 · Render
-

This is the whole app, running on this page.

-

Type a name and hit Say hi. The submit calls a Go method, the server re-renders the template, and only the changed HTML comes back.

+
+
The app
+

A shared greeting wall, running on this page.

+

Type a name. Your headline updates, your line joins the wall, and so does everyone else's — including anyone else reading this page right now. The code for all of it is directly below.

-
greet · running in this page
+
greet-wall · live, shared with every visitor
-```embed-lvt path="/apps/greet/" upstream="http://localhost:9091" height="130px" +```embed-lvt path="/apps/greet-wall/" upstream="http://localhost:9091" height="230px" ```
-
+
+
+
app.tmpl — the whole template, verbatim
+
<h1>Hello, {{.Name}}</h1>
+<form method="POST">
+  <input name="name" placeholder="Your name" {{.lvt.AriaInvalid "name"}}>
+  {{.lvt.ErrorTag "name"}}
+  <button name="greet">Say hi</button>
+</form>
+<ul>
+  {{range .Wall}}<li><b>{{.Name}}</b> said hi {{.At}}</li>{{end}}
+</ul>
+
-
app.tmpl — the entire template
-
<!DOCTYPE html>
-<html><head>
-  <script defer src="{{lvtClientScriptURL}}"></script>
-</head><body>
-  <h1>Hello, {{.Name}}</h1>
-  <form method="POST">
-    <input name="name" placeholder="Your name">
-    <button name="greet">Say hi</button>
-  </form>
-</body></html>
+
app.go — the four methods that do the work
+
type State struct {
+    Name string      // your headline, synced across your tabs
+    Wall []Greeting  // the shared list, synced across everyone
+}
+func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
+    ctx.Subscribe(ctx.SelfTopic())   // your own tabs
+    ctx.Subscribe("wall")            // every visitor
+    s.Name, s.Wall = a.nameFor(ctx.GroupID()), a.snapshot()
+    return s, nil
+}
+func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
+    name := sanitize(ctx.GetString("name"))
+    if name == "" {
+        return s, lvt.NewFieldError("name", errors.New("Please enter a name"))
+    }
+    a.record(ctx.GroupID(), name)
+    s.Name, s.Wall = name, a.snapshot()
+    ctx.Publish(ctx.SelfTopic(), "Refresh", nil)  // your other tabs
+    ctx.Publish("wall", "WallRefresh", nil)       // everyone else
+    return s, nil
+}
+// A publish just runs an ordinary action on the peers it reaches.
+func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
+    s.Name = a.nameFor(ctx.GroupID()); return s, nil
+}
+func (a *App) WallRefresh(s State, ctx *lvt.Context) (State, error) {
+    s.Wall = a.snapshot(); return s, nil
+}
+
+
+ +

That is the whole interface: a template, four methods, and no JavaScript you had to write. The running demo adds about forty more lines of ordinary Go — sanitize, snapshot, a twenty-line cap and a per-session throttle — none of which is framework API. Read the real file.

+
+ +
+
What's going on
+

The parts of that worth a second look.

+

Everything below points back at lines you have already read. Each one is a live app, so you can check the claim rather than take it.

+
+ +
+
No attributes
+

The button's name is the action.

+

<button name="greet"> calls Greet. That's the whole binding — no hx-post, no onClick, no route to register. Strip the wall away and the same idea is a complete app in twenty lines.

+ +
+
+
greet · the smallest version
+
+ +```embed-lvt path="/apps/greet/" upstream="http://localhost:9091" height="130px" +``` + +
-
app.go — the entire program
-
package main
-import (
-    "net/http"
-    lvt "github.com/livetemplate/livetemplate"
-)
-type State struct{ Name string }
+      
app.go · complete, nothing elided
+
type State struct{ Name string }
 type App struct{}
 func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
     s.Name = ctx.GetString("name")
@@ -101,25 +153,13 @@ func main() {
     
-
-
on the wire · WebSocket
-
▲ action · 40 B {"action":"greet","data":{"name":"Ada"}}
-
▼ diff  · 20 B {"tree":{"0":"Ada"}}
-
- -

That's the whole app, about 20 lines of Go and some standard HTML. Everything else on this page is a small diff on it.

+

There are lvt-* attributes, but only for behavior HTML cannot express — a debounce, a keyboard shortcut, a class toggle. They're an escape hatch, not the interface.

-
-
One app, five more steps
-

Adding the things an app usually ends up needing.

-

Everything below is the same greeting app. It picks up a plain POST fallback, then validation, then a pending state, then live updates over a WebSocket. It stays one Go codebase, and application logic never ends up in two places.

-
- -
-
Step 2 · Works without JavaScript
-

The same app works with JavaScript disabled.

-

When the script loads, the client enhances the submit and patches the headline. When it doesn't, the same <form> does a native POST and the server renders the page. There's no if jsEnabled branch to write. Both cards below run the same app — the right one has scripting switched off.

+
+
No JavaScript
+

The same app works with scripting switched off.

+

One <script> tag is the only difference between the two cards below. With it, the client enhances the submit and patches the headline. Without it, the same <form> does a native POST and the server renders the page. There's no if jsEnabled branch anywhere in the Go.

@@ -137,20 +177,15 @@ func main() {
-
app.tmpl — one form, either transport
-
<!-- the only line that flips the transport -->
-<script defer src="{{lvtClientScriptURL}}"></script>
-<form method="POST">   <!-- JS on → fetch + patch · JS off → native POST -->
-  <input name="name">
-  <button name="greet">Say hi</button>
-</form>
+
app.tmpl · the line that flips the transport
+
<script defer src="{{lvtClientScriptURL}}"></script>
-
-
Step 3 · Validation
-

Validation rules written in HTML, re-checked in Go.

-

Standard attributes like required run again server-side via ctx.ValidateForm(), then you add the rules HTML cannot express. Try an empty submit, or type admin.

+
+
Validation
+

The HTML rule runs again in Go.

+

You saw {{.lvt.AriaInvalid "name"}} and {{.lvt.ErrorTag "name"}} in the template, and a NewFieldError in Greet. That's the pair: standard attributes like required are re-checked server-side by ctx.ValidateForm(), then you add the rules HTML can't express. Try an empty submit, or type admin.

greet-validate · server-checked
@@ -162,27 +197,14 @@ func main() {
-
-
-
app.tmpl · the rule, written once
-
<input name="name" required {{.lvt.AriaInvalid "name"}}>
-{{.lvt.ErrorTag "name"}}
-
-
-
app.go · re-check, then add your own rule
-
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
-    if err := ctx.ValidateForm(); err != nil {
-        return s, err                 // re-runs the HTML rules
-    }
-    name := strings.TrimSpace(ctx.GetString("name"))
-    if strings.EqualFold(name, "admin") {
-        return s, lvt.NewFieldError("name",
-            errors.New(`"admin" is reserved`))
-    }
-    s.Name = name
-    return s, nil
+  
+
app.go · re-check, then add your own rule
+
if err := ctx.ValidateForm(); err != nil {
+    return s, err                          // re-runs the HTML rules
+}
+if strings.EqualFold(name, "admin") {      // a rule HTML can't express
+    return s, lvt.NewFieldError("name", errors.New(`"admin" is reserved`))
 }
-
@@ -192,16 +214,16 @@ func main() {
-
-
Step 4 · Loading state
-

Two ways to show a pending state.

-

The slow work runs on the server, so you can render its pending state with ordinary template conditionals. If you'd rather not touch the Go code at all, there's a button-level attribute for that.

+
+
Pending state
+

Slow work has a pending state you can render.

+

The work runs on the server, so its pending state is an ordinary template conditional. If you'd rather not touch the Go at all, there's a button-level attribute that does it without server state.

A · server-owned, template variables only
-
greet-async · server-owned pending
+
greet-async
```embed-lvt path="/apps/greet-async/" upstream="http://localhost:9091" height="130px" @@ -212,10 +234,7 @@ func main() {
<button {{if .lvt.Pending}}type="button" aria-busy="true"
   disabled{{else}}name="greet"{{end}}>Say hi</button>
lvt.Async(ctx,
-    func(context.Context) (string, error) {
-        time.Sleep(700 * time.Millisecond)
-        return name, nil
-    },
+    func(context.Context) (string, error) { return slowWork() },
     func(s State, name string, _ error) (State, error) {
         s.Name = name
         return s, nil
@@ -226,7 +245,7 @@ func main() {
     
B · button-level escape hatch
-
greet-loading · attribute version
+
greet-loading
```embed-lvt path="/apps/greet-loading/" upstream="http://localhost:9091" height="130px" @@ -237,32 +256,15 @@ func main() {
<button name="greet"
   lvt-el:addClass:on:pending="is-loading"
   lvt-el:removeClass:on:done="is-loading">Say hi</button>
-
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
-    time.Sleep(700 * time.Millisecond)
-    if name := strings.TrimSpace(
-        ctx.GetString("name")); name != "" {
-        s.Name = name
-    }
-    return s, nil
-}

This one keeps pending UI out of server state and works as a single request/response. It's the right one when the spinner is just button chrome rather than something the app cares about.

- -
-
on the wire · A, server-side
-
{"action":"greet","data":{"name":"Ada"}}
-
{"tree":{"1":{"aria-busy":"true","disabled":true,"type":"button"}}}
-
{"tree":{"0":"Ada","1":{"name":"greet"}}}
-
on the wire · B, attribute version
-
{"action":"greet","data":{"name":"Ada"}}   ▼ {"tree":{"0":"Ada"}}
-
-
-
Step 5 · Sync your own tabs
-

Keeping your own tabs in sync with two calls.

-

Subscribe the session to its own topic and publish after the handler runs. The same live session also lets the server push first, without waiting for a click.

+
+
Multi-user
+

Two calls sync your tabs. Changing the topic syncs everyone.

+

This is the part of Greet worth re-reading. ctx.SelfTopic() reaches your own tabs; "wall" reaches every visitor. Same two calls, different topic — that's the entire difference between "keeps my tabs in sync" and "multiplayer".

1 state changes @@ -271,47 +273,7 @@ func main() { 4 patch the browser
-
-
greet-wall · WebSocket on
-
- -```embed-lvt path="/apps/greet-wall/" upstream="http://localhost:9091" height="200px" -``` - -
-
-

Open this page in a second tab, greet in either, and the headline updates in both.

- -
func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
-    ctx.Subscribe(ctx.SelfTopic())                 // your tabs share a topic
-    s.Name = a.name(ctx.GroupID())
-    return s, nil
-}
-func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
-    a.setName(ctx.GroupID(), sanitize(ctx.GetString("name")))
-    ctx.Publish(ctx.SelfTopic(), "Refresh", nil)   // run Refresh on your other tabs
-    return s, nil
-}
-// Refresh is an ordinary action — the publish above runs it on each peer tab.
-func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
-    s.Name = a.name(ctx.GroupID())
-    return s, nil
-}
- -

There's no magic here. The publish just runs your Refresh method on your other tabs. It re-reads the shared data and returns new state, and the framework does the diffing and patching. The server can start the same cycle itself with sess.TriggerAction("ServerRefresh", nil).

- -
-
on the wire · WebSocket
-
▲ this tab   {"action":"greet","data":{"name":"Ada"}}
-
▼ other tab  {"tree":{"0":"Ada"}}
-
▼ server push {"tree":{"3":{"0":"15:04:08"}}} — no ▲; just the changed value goes down
-
-
- -
-
Step 6 · A wall everyone shares
-

Changing the topic makes it cross-user.

-

Swap the self-topic for a shared one, admitted by a small ACL, and the same publish fans out to every visitor. The two cards below are separate sessions, like two different people. Greet in one and the line shows up on both walls.

+

The two cards below are separate sessions, like two different people. Greet in one and the line shows up on both walls — while the headlines stay independent.

@@ -335,25 +297,17 @@ func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
-
-
app.go · the topic is the only difference
-
func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
-    ctx.Subscribe("wall")           // shared, cross-user topic
-    return s, nil
-}
-func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
-    a.append(sanitize(ctx.GetString("name")))
-    ctx.Publish("wall", "WallRefresh", nil)
-    return s, nil
-}
-
app.go · admit the shared topic
lvt.WithTopicACL(
     func(topic, _ string, _ *http.Request) (bool, error) {
         return topic == "wall", nil   // deny-all by default
     })
-

Each card is its own session, so the headlines stay independent, but the wall is global. It's the same two pubsub calls as step 5, with a different topic.

+
+
+
app.go · the server can start the cycle too
+
sess.TriggerAction("ServerRefresh", nil)
+

Same mechanism, no user action — that's the "the server said hi at …" line in the cards above, pushed on a timer.

@@ -361,13 +315,14 @@ func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
on the wire · WebSocket
▲ visitor 1 {"action":"greet","data":{"name":"Ada"}}
▼ visitor 2 {"tree":{"3":[["a",[{"0":"Ada","1":"15:04"}]]]}}
+
▼ server push {"tree":{"3":{"0":"15:04:08"}}} — no ▲; just the changed value goes down
-
+
-
Only the diff goes over the wire
+
Only the diff

Only the changed values go over the wire.

Templates get split into static structure, which is cached, and dynamic values, so a greeting comes back as {"tree":{"0":"Ada"}} instead of a page.

@@ -376,7 +331,6 @@ func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
lvt diff340 B
-
86% smaller per update
From c1ea02dc51baa7dd0df15ee28990dcff37346d86 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Thu, 6 Aug 2026 21:53:13 +0000 Subject: [PATCH 2/6] docs(landing): give the reader a way to check the multi-tab claim "including anyone else reading this page right now" is unverifiable for the usual visitor, who is alone on the page. The lead now links a second tab of the same page, so the claim is something you can test in one click rather than take on trust. --- content/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/index.md b/content/index.md index c0122cf..11611ac 100644 --- a/content/index.md +++ b/content/index.md @@ -55,7 +55,7 @@ layout: landing
The app

A shared greeting wall, running on this page.

-

Type a name. Your headline updates, your line joins the wall, and so does everyone else's — including anyone else reading this page right now. The code for all of it is directly below.

+

Type a name. Your headline updates and your line joins the wall, along with everyone else's — including anyone else reading this page right now. Open this page in a second tab and watch it land there too, with no reload. The code for all of it is directly below.

greet-wall · live, shared with every visitor
From 0ca3a219f3dfaa3454bfebc35f77f11df06cd1b6 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Fri, 7 Aug 2026 00:46:45 +0000 Subject: [PATCH 3/6] fix(landing): make the highlighting visible, and make every callback true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports, both fair. "Still no highlight": the fourth hue was #5A5560, which is R90 G85 B96 — all but neutral. Its luminance distance from plain text was fine, the same as the keyword blue's; what it lacked was chroma, so it read as "dark" rather than as a colour. Names are now plum #6B4A7A and built-in types and literals get their own amber #7D5226, which also stops Go's `string`/`error` reading as string literals. Six hues, each mapping to a distinct token class, all still darker than --lt-meta so the <=13px contrast floor holds. `builtin` had to come out of the string rule too — it was listed in both, and the later one was winning. "The final app doesn't have the snippets the later breakdowns call out": correct, and it was worse than the one instance. Five referenced symbols were absent from the hero — lvtClientScriptURL, ValidateForm, EqualFold, lvt.Pending, lvt.Async — while the section intro promised that everything below pointed back at lines already read. That promise is the entire premise of showing the app first, so: - the hero template gains its