Lofigui Research
Design philosophy, trade-offs, and technical deep-dives.
Philosophy
The original vision: no CSS, no JavaScript
lofigui started from a simple premise: what if a web UI was just print() statements rendered as plain HTML? No CSS framework, no JavaScript, no build step. The reasons:
-
Simplicity — Every dependency is a thing to learn, update, and debug. Plain HTML is the lowest common denominator. A developer who can write
print("hello")can build a UI. -
Deployment — A single binary (Go) or a minimal Python package that serves HTML over HTTP. No node_modules, no bundler, no static asset pipeline. Copy the binary to a server and run it. This matters especially for gokrazy deployments and internal tools where infrastructure is minimal.
-
Understandability — "View Source" shows exactly what the server sent. There is no client-side rendering, no virtual DOM diffing, no hydration step. The browser does what browsers were built to do: render HTML.
The Bulma compromise
Plain HTML is functional but ugly. For internal tools used daily, aesthetics matter enough to justify a CSS framework. Bulma was chosen because:
- It is CSS-only — no JavaScript runtime
- It is a single CDN link — no build step
- It makes tables, forms, and layout look professional with class names alone
This is the first trade-off: we accepted a CDN dependency for better-looking output. The framework still works without Bulma (plain HTML renders fine), but the examples and defaults assume it.
Removing JavaScript: precedent and practice
The UK Government Digital Service removed jQuery from GOV.UK in 2022 — a site serving millions of users. Their reasoning: fewer bytes, fewer failure modes, better accessibility. If GOV.UK can serve a nation without jQuery, an internal tool can certainly manage without React.
lofigui takes this further. The base framework uses zero JavaScript. The browser's native capabilities — HTML rendering, form submission, HTTP Refresh — handle everything in examples 01-08.
Where JavaScript creeps back in
Two features introduce JavaScript, both deliberately:
WASM (examples 03, 04, 07, 08) — Go compiled to WebAssembly requires a small JS loader (wasm_exec.js). This is the price of running the same Go code in the browser without a server. The JS is boilerplate glue, not application logic.
HTMX (examples 09, 10) — A single <script> tag that adds hx-get and hx-trigger attributes to HTML elements. HTMX exists because full-page HTTP Refresh polling has a real usability problem: if you are trying to enter information in a form or click a button, the page refresh interrupts you. The input loses focus, the form resets, the click never registers. For display-only dashboards, polling is fine. For anything interactive, it is maddening.
HTMX solves this by updating only the parts of the page that change, leaving forms and buttons untouched. It is the minimum JavaScript needed to make multi-page dynamic sites usable.
The JavaScript budget
The position is not "no JavaScript ever" but "justify every byte":
| Layer | JS? | Justification |
|---|---|---|
| Base (examples 01-08) | None | Full-page refresh is sufficient |
| HTMX (examples 09-10) | ~14KB | Partial updates make interactive pages usable |
| WASM (examples 03-04) | ~16KB loader | Enables server-free deployment |
No bundler, no npm, no build step. Each JS dependency is a single file loaded from a CDN or embedded.
Print as interface
The fundamental insight is that print() is the most natural programming interface. Every developer learns it first. lofigui preserves that — you print things, they appear on a web page. The abstraction cost is near zero.
Progressive complexity
The examples are ordered deliberately:
- Print and poll (01) — the simplest useful pattern
- Synchronous render (02) — when you don't need async
- WASM (03, 04) — same code, no server
- CRUD (06) — forms and state
- Real-time dashboards (07-09) — SVG, multi-page, HTMX
- Background operations (10) — goroutines, cancellation, progress
Each step adds one concept. You stop at the level of complexity your project needs.
The Bulma lesson applied more broadly
godocs originally started with very simple hand-written CSS. Over time it grew complex and inconsistent — and it was actually smaller to switch to Bulma for a more consistent result. The same principle may apply to charts and other areas: a focused, well-chosen dependency can be simpler than a DIY approach that accumulates complexity over time.
Where does lofigui sit?
lofigui is for single-process, small-audience tools. The sweet spot: 1-10 users, one real object (a machine, a simulation, a long-running process) with a few pages showing different views of it. It is not competing with React or even Streamlit — it is competing with "I'll just use the terminal" or "I'll write a quick bash CGI script".
Charts
The problem
Charts are the next big gap in lofigui. The current approach (example 02) uses Go libraries to produce SVG server-side. The results are functional but poor — limited axis formatting, weak label placement, no interactivity, and mediocre visual quality compared to what users expect from modern dashboards.
This was explored in the gobank chart comparison, which tested three Go SVG renderers against financial data:
Go SVG libraries tested (gobank)
| Library | Strengths | Weaknesses |
|---|---|---|
| Hand-rolled SVG | Full control, no dependencies | Enormous effort for basic features (axis scaling, labels, legends). Every chart type is a fresh implementation. |
| go-analyze/charts | Good chart variety, reasonable defaults | Axis formatting issues (decimal values where percentages expected), limited customisation |
| margaid | Clean line charts | Limited chart types, sparse documentation |
The go-chart library (used in lofigui example 02) has similar issues — it works for simple bar/line charts but struggles with axis formatting, date handling, and multi-series layouts.
Core issue: Go's charting ecosystem is immature compared to JavaScript's. The libraries exist but produce output that looks 10 years behind what a JS library produces with the same data and less code.
The JavaScript charting landscape
If we accept a JS dependency for charts (as we accepted HTMX for interactivity), the question is: which library, and does it drag in framework complexity?
| Library | Size | Framework needed? | CDN single-file? | SVG output? | Notes |
|---|---|---|---|---|---|
| Chart.js | ~65KB | No | Yes | Canvas (not SVG) | Simple API, good defaults, huge community. Canvas means no CSS styling of chart elements. |
| D3.js | ~90KB | No | Yes | Yes (native SVG) | The gold standard. Total control over every pixel. Steep learning curve but unmatched flexibility. |
| Observable Plot | ~50KB (needs D3) | No | Yes | Yes (SVG) | D3's "high-level" layer. Concise API, good defaults, less boilerplate than raw D3. |
| Plotly.js | ~1MB | No | Yes | SVG + Canvas | Feature-rich but heavy. Built on D3. Good for scientific/financial charts. |
| Apache ECharts | ~400KB | No | Yes | Canvas + SVG | Rich interactive charts. Heavy but well-documented. |
| Frappe Charts | ~17KB | No | Yes | SVG | Lightweight, GitHub-inspired aesthetics. Limited chart types. |
| uPlot | ~35KB | No | Yes | Canvas | Extremely fast for time-series. Minimal but performant. |
| Vega-Lite | ~400KB | No | Yes | SVG + Canvas | Declarative grammar-of-graphics. JSON spec, no imperative code. |
What to avoid
The key constraint is: no React, no Svelte, no Angular, no build step. Libraries that require a framework or a bundler are out:
- Recharts — React-only
- Victory — React-only
- Nivo — React-only
- SvelteKit charts — Svelte-only
The lofigui pattern is: server renders HTML, browser displays it. A chart library must work with a <script> tag and a <div> target, nothing more.
Leading candidates for lofigui
D3.js is the most interesting option. It:
- Produces native SVG (inspectable, stylable with CSS, printable)
- Works from a single CDN
<script>tag - Has no framework dependency
- Is the foundation most other libraries build on
- Supports every chart type imaginable
The downside is D3's verbosity — a simple line chart is ~30 lines of JS. But lofigui could generate the D3 JavaScript server-side (Go fmt.Sprintf with data injected into a template), keeping the complexity on the server while the browser just executes the rendering.
Observable Plot is D3's high-level API, worth considering if D3 feels too low-level. Same SVG output, much less code.
Chart.js is the simplest option if SVG isn't required. Canvas output means less flexibility but the API is very approachable.
How it would work in lofigui
The pattern would mirror HTMX — a <script> tag in the template, with the Go server injecting data:
// Server-side: Go generates the chart div + script
lofigui.HTML(fmt.Sprintf(`
<div id="chart-%s"></div>
<script>
// D3 or Chart.js code here, with data from Go
const data = %s;
// ... render chart into #chart-%s
</script>
`, chartID, jsonData, chartID))
This keeps the Go code in control of what to chart. The JS library handles how to render it. No npm, no build step, no framework.
Potential example 12: water tank charts
A natural next example would chart the water tank simulation data — tank level and pump/valve activity over time. This would:
- Demonstrate time-series charting with real simulation data
- Show how to pass Go data to a JS charting library
- Build on the existing water tank examples (07-10)
- Provide a reference pattern for charting in lofigui apps
The chart would update via HTMX (like example 09) — the fragment endpoint returns fresh chart HTML with updated data on each poll.
Technical Research
1. Codebase Overview
lofigui is a lightweight web-UI framework with dual Python and Go implementations. It provides a print-like interface for building server-side rendered HTML applications.
Architecture
The framework has three layers:
-
Buffer Layer (
lofigui.go/context.py,print.py,markdown.py) — Global mutable buffer that accumulates HTML fragments viaPrint(),Markdown(),HTML(),Table()calls. Python uses anasyncio.Queuethat drains into a string buffer; Go uses astrings.Builderdirectly. -
Controller Layer (
controller.go/controller.py) — Wraps a template engine (pongo2 in Go, Jinja2 in Python). Renders templates with the buffer content injected as{{ results | safe }}. -
App Layer (
app.go/app.py) — Manages controller lifecycle, action state (running/stopped), and auto-refresh polling. Implements the "singleton active model" concept — only one background task should run at a time.
Execution Patterns
| Pattern | Flow | Examples |
|---|---|---|
| Async + Polling | GET / resets buffer, starts model in goroutine/background task, redirects to /display. /display includes <meta http-equiv="Refresh"> tag while polling is active. |
01 (Go+Python) |
| Synchronous | Model runs inline, EndAction() called immediately, redirects to /display. |
02 (Go+Python) |
| WASM | Go compiled to WebAssembly, no server-side app. | 03, 04 |
| CRUD | Form POSTs modify state, redirect to GET /. Uses ctrl.StateDict() + ctrl.RenderTemplate() directly. |
06 (Go+Python) |
2. Notification System (Polling/Auto-Refresh)
The "notification system" is a server-side polling mechanism using HTML <meta http-equiv="Refresh"> tags. There is no WebSocket, SSE, or JavaScript-based notification — it's pure HTTP redirect-based polling.
How It Works
- Start:
app.StartAction()setsactionRunning=true,polling=true,PollCount=0. - Poll Cycle: On each
/displayrequest,app.StateDict()(Python) checksself.poll: - If
True: injects<meta http-equiv="Refresh" content="N">into therefreshtemplate variable. Incrementspoll_count. - If
False: setsrefresh="", resetspoll_count=0. - Stop:
app.EndAction()setsactionRunning=false,polling=false. Next/displayrequest renders without the refresh tag — polling stops. - Template: The template must include
{{ refresh | safe }}in the<head>for auto-refresh to work.
Python Implementation Details
App.state_dict()builds the full context dict includingrefresh,polling,poll_count.App.template_response()callsstate_dict()then renders via Jinja2.- The Python path works correctly:
template_response()→state_dict()→ injects refresh meta tag.
Go Implementation Details
App.StateDict()is the method that should build the full context with polling info.Controller.StateDict()only returnsrequestandresults(no polling info).App.HandleDisplay()delegates toctrl.HandleDisplay()which only usesctrl.StateDict().
Startup Bounce (Python only)
The Python App has a startup flag. On the first call to template_response(), if the URL path isn't /, it redirects to /. This prevents a stale /display page from showing when the server restarts. The startup_bounce_count limit of 3 is effectively unreachable because startup is set to False on the first invocation.
3. Task Scheduling Flow
Go Flow (async pattern)
User → GET / → app.HandleRoot(w, r, model, true)
1. RLock → read ctrl, displayURL → RUnlock
2. ctrl.context.Reset()
3. app.StartAction() ← sets actionRunning=true, polling=true
4. go modelFunc(app) ← goroutine launched, no cancellation mechanism
5. Write redirect HTML to /display
Background goroutine:
model(app) {
lofigui.Print("...")
time.Sleep(...)
app.EndAction() ← sets actionRunning=false, polling=false
}
User → GET /display → app.HandleDisplay(w, r)
1. RLock → read ctrl → RUnlock
2. ctrl.HandleDisplay(w, r, nil) ← uses ctrl.StateDict (NOT app.StateDict)
Python Flow (async pattern)
User → GET / → root(background_tasks)
1. lg.reset()
2. background_tasks.add_task(model)
3. app.start_action() ← sets _action_running=True, poll=True
4. Return redirect HTML to /display
Background task:
model() {
lg.print("...")
sleep(...)
app.end_action() ← sets _action_running=False, poll=False
}
User → GET /display → display(request)
1. app.template_response(request, "hello.html")
→ app.state_dict(request) ← includes refresh meta tag if poll=True
→ templates.TemplateResponse(...)
4. Bugs Found
BUG 1 (CRITICAL): Go app.HandleDisplay() never injects polling/refresh state
File: app.go:192-203
func (app *App) HandleDisplay(w http.ResponseWriter, r *http.Request) {
// ...
ctrl.HandleDisplay(w, r, nil) // ← delegates to controller, NOT app
}
ctrl.HandleDisplay() calls ctrl.StateDict(r) which only returns request and results. The refresh, polling, version, controller_name, and poll_count template variables are NEVER populated.
Impact: In the Go async pattern (example 01), {{ refresh | safe }} in the template always renders as empty. The page never auto-refreshes. The user must manually refresh to see progress updates. The entire polling mechanism is non-functional in Go.
Fix: app.HandleDisplay() should use app.StateDict() to build the full context, then pass it to ctrl.HandleDisplay() as extra context, or render the template directly.
BUG 2 (CRITICAL): Go app.StateDict() deadlocks
File: app.go:241-283
func (app *App) StateDict(r *http.Request, extraContext pongo2.Context) pongo2.Context {
app.mu.Lock() // ← acquires write lock (line 242)
// ...
ctx := pongo2.Context{
// ...
"controller_name": app.ControllerName(), // ← line 257
}
ControllerName() at app.go:207-215:
func (app *App) ControllerName() string {
app.mu.RLock() // ← tries to acquire read lock while write lock is held
defer app.mu.RUnlock()
// ...
}
sync.RWMutex is NOT re-entrant. A goroutine holding a write lock that attempts to acquire a read lock on the same mutex will deadlock permanently.
Impact: Any call to app.StateDict() will hang forever. Currently this is masked by BUG 1 (HandleDisplay doesn't call StateDict), but if BUG 1 is fixed naively by calling app.StateDict(), the program will deadlock.
Fix: Either inline the controller name lookup inside StateDict() (use app.controller directly since lock is already held), or refactor to avoid nested locking.
BUG 3 (CRITICAL): No cancellation mechanism for running goroutines/tasks
Files: app.go:166-188 (Go), examples/01_hello_world/python/hello.py:41-51 (Python)
When the user triggers the root endpoint while a model is already running:
Go:
func (app *App) HandleRoot(...) {
// ...
app.StartAction() // resets polling state for the "new" action
go modelFunc(app) // launches NEW goroutine — old one is still running!
// ...
}
Python:
async def root(background_tasks: BackgroundTasks):
lg.reset()
background_tasks.add_task(model) # schedules NEW task — old one still running!
app.start_action()
There is no context.Context, channel, flag, or any other cancellation mechanism. The old goroutine/task:
- Continues running indefinitely
- Continues writing to the shared buffer (which was just reset)
- Eventually calls
EndAction(), prematurely stopping the polling for the NEW action
This is the "tasks that should have been cancelled" bug. The old task's EndAction() call terminates the polling that the new task needs.
Scenario:
T=0: User hits / → goroutine A starts, StartAction()
T=2: User hits / → goroutine B starts, StartAction() (resets state)
T=5: goroutine A finishes → calls EndAction() → STOPS POLLING
T=5: goroutine B is still running but polling is now off
T=8: goroutine B finishes → calls EndAction() (no-op, already stopped)
Result: From T=5 to T=8, goroutine B is running but the page has stopped auto-refreshing.
Fix: The model function signature should accept a context.Context (Go) or a cancellation flag. HandleRoot should cancel the previous context before starting a new one. Model functions must check for cancellation in their loops.
BUG 4 (SIGNIFICANT): Python reset() doesn't clear the asyncio queue
File: lofigui/context.py:103-121
def reset(ctx=None):
if ctx is None:
ctx = _ctx
ctx.buffer = "" # clears buffer string only — queue is untouched!
The print() function puts messages into ctx.queue. buffer() drains the queue into the buffer string. reset() clears the buffer string but leaves the queue intact.
Scenario:
1. lg.print("msg1") → queue: ["<p>msg1</p>"]
2. lg.reset() → buffer="" but queue still has ["<p>msg1</p>"]
3. lg.print("msg2") → queue: ["<p>msg1</p>", "<p>msg2</p>"]
4. lg.buffer() → drains queue → buffer="<p>msg1</p><p>msg2</p>"
msg1 survives the reset and appears in the output.
Impact: When the user restarts an action (hits / again), messages from the previous run that were queued but not yet drained will persist into the new run's output.
Fix: reset() should drain and discard the queue before clearing the buffer:
def reset(ctx=None):
if ctx is None:
ctx = _ctx
# Drain and discard the queue
while not ctx.queue.empty():
try:
ctx.queue.get_nowait()
ctx.queue.task_done()
except asyncio.QueueEmpty:
break
ctx.buffer = ""
BUG 5 (SIGNIFICANT): Example 05 (demo_app.py) is completely broken
File: examples/05_demo_app/demo_app.py
The demo app uses Controller API methods that were removed during the refactoring that moved action management to the App level:
| Line | Call | Status |
|---|---|---|
| 136 | current_controller.is_action_running() |
Method doesn't exist on Controller |
| 141 | current_controller.get_refresh() |
Method doesn't exist on Controller |
| 181 | Controller(template_path="templates/process.html") |
Constructor doesn't accept template_path |
| 186 | current_controller.start_action(refresh_time=2) |
Method doesn't exist on Controller |
| 207 | current_controller.end_action() |
Method doesn't exist on Controller |
Impact: The demo app crashes with AttributeError on any process-related endpoint.
BUG 6 (MODERATE): Go HandleRoot race between reset and goroutine writes
File: app.go:166-188
func (app *App) HandleRoot(...) {
app.mu.RLock()
ctrl := app.controller
app.mu.RUnlock()
// ← window: controller could be replaced here
if resetBuffer {
ctrl.context.Reset() // reset buffer
}
// ← window: old goroutine could write here between reset and new goroutine start
app.StartAction()
go modelFunc(app) // new goroutine starts writing
Between ctrl.context.Reset() and the new goroutine starting, an old goroutine (from a previous HandleRoot call) can write to the buffer. These writes will appear in the new action's output.
Impact: Stale output from previous runs can leak into new runs.
BUG 7 (MODERATE): Go StateDict doesn't defer unlock
File: app.go:242-283
func (app *App) StateDict(...) pongo2.Context {
app.mu.Lock()
// ... 30+ lines of code ...
app.mu.Unlock() // ← not deferred
If any code between Lock and Unlock panics (e.g., ctrl.context.Buffer() on a nil controller context), the mutex is never released, permanently deadlocking all future operations.
Fix: Use defer app.mu.Unlock() immediately after app.mu.Lock().
BUG 8 (MINOR): Python mutable default arguments
File: lofigui/app.py:85, 111
def state_dict(self, request: Request, extra: dict = {}) -> dict:
def template_response(self, request: Request, templateName: str, extra: dict = {}) -> HTMLResponse:
Mutable default arguments are shared across calls. While the code doesn't mutate extra directly in all paths, self.controller.state_dict(extra=extra) at line 105 passes the same default dict object through, which could be mutated by controller implementations.
Fix: Use extra: dict = None and if extra is None: extra = {}.
BUG 9 (MINOR): Python start_demo_process blocks async event loop
File: examples/05_demo_app/demo_app.py:197-200
async def start_demo_process(duration: int = Form(10)):
for i in range(duration):
time.sleep(1) # blocks entire event loop
Using time.sleep() in an async handler blocks all request processing. The auto-refresh requests can't be served while the process runs, defeating the purpose of polling.
BUG 10 (MINOR): Go Context uses sync.Mutex instead of sync.RWMutex
File: lofigui.go:17-19
type Context struct {
buffer strings.Builder
mu sync.Mutex // ← should be sync.RWMutex for concurrent reads
}
Buffer() is a read-only operation but takes an exclusive lock, blocking concurrent readers unnecessarily.
5. Bug Interaction Analysis
Bugs 1, 2, and 3 interact in a particularly insidious way:
- BUG 1 means Go's
app.HandleDisplay()bypassesapp.StateDict(), so the auto-refresh meta tag is never rendered. This masks BUG 2 (the deadlock inStateDict). - If you fix BUG 1 by making
HandleDisplaycallapp.StateDict(), you immediately hit BUG 2 (deadlock). - If you fix BUG 2 AND BUG 1, the auto-refresh now works, but then BUG 3 becomes much more visible — old goroutines calling
EndAction()prematurely kill the new action's polling.
The correct fix order is: BUG 2 → BUG 1 → BUG 3.
Similarly, BUG 4 (Python queue not cleared on reset) and BUG 3 (no cancellation) interact: even if you add cancellation to stop old tasks from calling EndAction(), messages from old tasks still leak through the queue.
6. Root Cause: Missing Cancellation Architecture
The fundamental design issue is that the framework has a "singleton active model" concept (only one action at a time) but no mechanism to enforce it:
- No cancellation propagation: Go goroutines and Python background tasks have no way to learn they've been superseded.
- No action ID/generation counter: There's no way to distinguish which action's
EndAction()call is current. - Global mutable state: The buffer and action flags are global, shared across all requests and goroutines.
A minimal fix would add a generation counter:
type App struct {
// ...
actionGeneration uint64
}
func (app *App) StartAction() uint64 {
app.mu.Lock()
defer app.mu.Unlock()
app.actionGeneration++
app.actionRunning = true
app.polling = true
return app.actionGeneration
}
func (app *App) EndAction(generation uint64) {
app.mu.Lock()
defer app.mu.Unlock()
if generation == app.actionGeneration {
app.actionRunning = false
app.polling = false
}
// else: stale call from superseded goroutine — ignore
}
A fuller fix would use context.Context for cancellation signaling.