WIP trmnl BYOS

AGENTS.md - Working with the scrn codebase#

This is a Go-based server that renders JSX components to BMP images for e-ink displays (TRMNL devices). It executes JSX components in a JavaScript runtime (goja), calculates layouts using a custom flexbox-like system, and renders to 1-bit BMP images.

Project Overview#

  • Language: Go 1.23.3
  • Module: tangled.org/cdbrdr.com/scrn
  • Purpose: TRMNL-compatible display server that renders JSX layouts to BMP images
  • Display: 800x480, 1-bit color (black/white), outputs BMP format

Essential Commands#

# Build the binary
go build -o scrn

# Run the server (uses examples/display.jsx by default)
go run .

# Run tests
go test ./...
go test ./internal/tree/...  # specific package

# Regenerate pre-compiled JS modules (run after modifying JSX files)
cd internal/module
go generate ./...

# Download dependencies
go mod download
go mod tidy

Project Structure#

.
├── main.go                    # Entry point: loads display.jsx, starts HTTP server on :8081
├── internal/
│   ├── display/
│   │   ├── display.go         # Display struct: manages goja runtime, renders BMP
│   │   └── draw.go            # Rendering: draws nodes to image (uses gofont, masks)
│   ├── handler/
│   │   └── api.go             # HTTP handlers: /api/setup, /api/display, /api/image.bmp
│   ├── tree/
│   │   ├── tree.go            # Node interface, CalculateLayout, style parsing
│   │   ├── node.go            # parseNode: converts JSX output to internal nodes
│   │   ├── module.go          # Module nodes: calls JSX functions recursively
│   │   ├── flex.go            # FlexNode: flexbox layout (horizontal/vertical)
│   │   ├── text.go            # TextNode: text rendering with font size inheritance
│   │   ├── fill.go            # FillNode: solid color fills (black/white/gray)
│   │   ├── img.go             # ImgNode: embedded image rendering
│   │   └── tree_test.go       # Tests using deep.Equal for struct comparison
│   ├── module/
│   │   ├── module.go          # go:embed for JSX files, module loader
│   │   ├── jsx/
│   │   │   └── jsx-runtime.js # JSX transform runtime (h/jsx/jsxs functions)
│   │   ├── scrn/
│   │   │   ├── *.jsx          # Built-in modules (weather, tracker, utils)
│   │   │   └── icons/         # PNG/JPG assets for weather icons
│   │   ├── fetch/
│   │   │   └── fetch.go       # fetch() polyfill for goja (HTTP GET)
│   │   └── cache/
│   │       └── cache.go       # Simple in-memory cache for goja
│   └── transform/
│       └── transform.go       # esbuild wrapper: transforms JSX -> JS (IIFE/CJS)
└── examples/
    └── display.jsx            # Example main component

Architecture Flow#

  1. main.go loads examples/display.jsx and creates a Display
  2. transform converts JSX to JS using esbuild with automatic JSX runtime
  3. display creates a goja runtime, registers modules (fetch, cache, console, url)
  4. JSX components execute and return a tree structure (via jsx-runtime.js)
  5. tree.CalculateLayout parses the tree into typed nodes (FlexNode, TextNode, etc.)
  6. Layout engine calculates bounds using flexbox algorithm with weights/sizes
  7. draw.go renders nodes to image.Paletted (black/white palette)
  8. Output is BMP with TRMNL-specific header patches

JSX/Component System#

Built-in Node Types (from jsx-runtime.js)#

// Flex container - arranges children horizontally or vertically
<flex direction="horizontal|vertical" separator="none|solid|dashed" justify="start|end|center" gap={10}>

// Text node - content from children, supports fontSize
<text fontSize={32}>Hello World</text>

// Fill - solid color block
<fill color="white|black|gray" />

// Image - from embedded assets
<img src="scrn/icons/weather/day/clear.png" />

Style Properties (all nodes)#

  • weight - Flex grow factor (default: 1)
  • size - Fixed pixel size (overrides weight)
  • margin - Outer margin in pixels
  • padding - Inner padding in pixels
  • cornerRadius - For rounded corners (via mask)
  • fontSize - Inherited font size for text (default: 32)

Writing Modules#

Modules are JSX files in internal/module/scrn/:

// Import other modules
import { ProgressBar } from "scrn/utils"

// Export default function - receives props including width/height
export default function Weather({width, height, location}) {
  // Use fetch() to get data
  const response = fetch(`https://api.example.com/data`)

  // Use cache for persistence between renders
  cache.set("key", value)
  const cached = cache.get("key")

  // Return JSX tree
  return <flex direction="vertical">
    <text fontSize={60}>{temp}°C</text>
  </flex>
}

Key Rules#

  • Components must export default a function
  • The function receives width, height, and any props from parent
  • fetch() is synchronous (blocking) in this runtime
  • cache is in-memory only (resets on server restart)
  • Images must be in scrn/icons/ path (embedded at build time)

Code Patterns#

Adding a New Node Type#

  1. Define NodeType constant in tree/tree.go
  2. Create struct in tree/newtype.go with:
    • Type-specific fields
    • Type() NodeType method
    • Bounds() image.Rectangle method
    • GetStyle() NodeStyle method
  3. Add case to node.go's GetNode() switch
  4. Add renderer in draw.go's drawNode() switch

Adding a Built-in Module#

  1. Create .jsx file in internal/module/scrn/
  2. Run go generate ./... in internal/module to compile
  3. Import in display files as import X from "scrn/filename"

Error Handling#

  • Go errors are defined as vars: ErrNoDefaultExport = errors.New("...")
  • Goja (JS) errors use runtime.NewGoError(err)
  • HTTP handlers log errors and return 500 status

Testing#

Tests use github.com/go-test/deep for struct comparison:

func TestSomething(t *testing.T) {
    result, err := CalculateLayout(input, rect, nil)
    expected := &FlexNode{...}

    if diff := deep.Equal(result, expected); diff != nil {
        t.Error(diff)
    }
}

Run with: go test ./internal/tree/...

Important Gotchas#

  1. JSX Compilation: JSX files are transformed at build time via go generate. The generated .js files are embedded and ignored in .gitignore.

  2. Synchronous fetch: The fetch() implementation is blocking/synchronous, not Promise-based like browser fetch.

  3. BMP Header Patching: The BMP output has hardcoded header patches for TRMNL compatibility (bytes 46, 57, 61).

  4. Font Size Inheritance: fontSize is inherited from parent nodes. The default is 32px.

  5. Flex Layout Algorithm:

    • If size is set, it's used as fixed pixels
    • Otherwise weight determines proportional sizing of remaining space
    • Weights are normalized across siblings
  6. Module Resolution: Modules are loaded from node_modules/scrn/* path in the goja runtime, mapped to embedded files.

  7. Goja Runtime: Each Display has its own isolated JS runtime. Modules share the same runtime within a Display.

  8. Image Assets: Only images in internal/module/scrn/icons/ are available. They're embedded at build time via //go:embed.

  9. Color Palette: Only black, white, and gray. Gray is rendered as a checkerboard pattern.

Dependencies to Know#

  • goja: JavaScript runtime for Go (ECMAScript 5.1+)
  • esbuild: Bundles/transforms JSX (used as Go library)
  • gin: HTTP web framework
  • gofont: Font rendering for Go images
  • mapstructure: Decodes map[string]any into structs (for JSX props)

API Endpoints (TRMNL-compatible)#

  • GET /api/setup - Returns device registration info
  • GET /api/display - Returns display metadata with image URL
  • GET /api/image.bmp - Returns rendered BMP image
  • POST /api/log - Receives device logs

The server mimics TRMNL's cloud API for local e-ink display testing.