wings

package module
v0.16.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 15, 2026 License: MPL-2.0 Imports: 16 Imported by: 0

README

WINGS logo

WINGS

Web IN Go Sphere

Go Reference Go Report Card AGENTS.md Claude Code plugin

Live Demo — try it in your browser, no install needed. Or run locally: clone the repo, cd live-demo && bash build.sh && go run serve.go

or jump to the Quick Start / Full Example

Build reactive Web Components in pure Go — no JavaScript framework required.

WINGS compiles to WebAssembly and gives you custom HTML elements with automatic data binding, conditional rendering, array iteration, two-way form binding, hash-based routing, and parent-child communication — all authored in Go and running natively in the browser.

Why WINGS?

Pure Go Write components, state, and logic entirely in Go. Templates stay in plain HTML.
Reactive Change a value with Set() and the DOM updates automatically — no virtual DOM diffing overhead.
Lightweight Direct DOM manipulation via targeted refs. No framework runtime to download beyond your WASM binary.
Encapsulated Each component lives inside a Shadow DOM with scoped CSS — no style leaks, no naming collisions.
Two-Way Binding The & prefix syncs <input>, <select>, and <textarea> with your Go data map in both directions.
Hash Routing Built-in {{#}} binding and wings.GoTo() for SPA navigation without a router library.
Composable Nest components freely. Parent-to-child data flows via attributes; child-to-parent events flow via @ triggers.
Standard Web Uses native Custom Elements v1 and Shadow DOM — works alongside any existing page or framework.
Internationalized Build-time text extraction + runtime catalog lookup, with plural/gender flexion and locale-aware number/date/measure formatting.
Themeable Global --wings-* design tokens; compose multiple runtime skins (colours, geometry, depth, motion, atmosphere).
Testable in-web Wrap any widget in <w-test> to spy its events and seal a pass/fail; modules declare self-tests via Testable(), and <w-test-report> collects the whole page (auto + visual) into one JSON report.
Zero-toolchain dev A Docker dev container recompiles your wings.wasm and serves it on every save — iterate with no Go toolchain on the host, or run the same loop natively.

What's New

Release highlights — full history in CHANGELOG.md.

v0.16.14

  • AI authoring plugin now covers security — six app-flavored sec-* skills complete the wings-authoring kit. Validating them against a generated component caught and fixed a real runtime leak: native listeners wired with dom.AddEvent are now auto-released when a component disconnects.
  • Cross-tool reach — Cursor, Kiro, and GitHub Copilot rule files now re-point to AGENTS.md, so non-Claude assistants get the same guide.

v0.16.12

  • Continuous fuzzing of the parsers — native Go fuzz targets for the template/flex readers, codec, catalog aligner, signature verification, and number/format parsing, run via go run ./cmd/build fuzz. The first run already caught and fixed a currency-formatting bug.

v0.16.11

  • vulncheck build target — govulncheck over every module, native and GOOS=js; deps and toolchain bumped to clear every finding it reported.

Table of Contents


Migrating from wprana

WINGS was previously published as wprana. The import path is now github.com/luisfurquim/wings and the package identifier is wings.

New code imports it directly:

import "github.com/luisfurquim/wings"

// wings.Register(...), wings.Main(), wings.GoTo(...), etc.

Already have code that calls wprana.Foo(...)? You don't have to touch every call site. Point your module at the new path and alias the import back to wprana:

go get github.com/luisfurquim/wings
import wprana "github.com/luisfurquim/wings"

// every existing wprana.Foo(...) call keeps compiling unchanged

Only the import line changes — the rest of your code stays as-is. You can migrate to the wings. name gradually, or not at all.


Quick Start

WASM binaries cannot be loaded from file:// URLs. The snippet below builds a hello-world component, copies the required runtime files, and starts a tiny Go server so you can open the page in a browser.

Windows users: the tutorial snippets below assume a POSIX shell — run them under WSL (recommended — official Microsoft path, ships with Windows 10/11) or Git Bash (bundled with Git for Windows). Native cmd.exe / PowerShell will not execute them directly.

The project's own build, however, is pure Go and cross-platform: run go run ./cmd/build <target> (targets: lib, example, live-demo, wlate, all, vulncheck) — no sed, openssl, or python3 needed. The build.sh files in each directory are thin wrappers around it, kept for habit.

vulncheck runs govulncheck over every module in the repo, twice each: a native call-graph pass and a GOOS=js package-level pass (js-only packages are invisible to the native pass). It needs network access for the vulnerability DB, so it is not part of all — run it before each release; any finding fails it.

# 1. Create the project
mkdir hello-wings && cd hello-wings
go mod init hello-wings
go get github.com/luisfurquim/wings

# 2. Copy the JS helpers from the wings module
WPRANA=$(go list -m -f '{{.Dir}}' github.com/luisfurquim/wings)
mkdir -p static
cp "$WPRANA/prana_helper.js" static/
# Go 1.24+: lib/wasm/   Go ≤1.23: misc/wasm/
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" static/ 2>/dev/null || \
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" static/

Create static/index.html:

<!DOCTYPE html>
<html>
<head>
   <script src="prana_helper.js"></script>
   <script src="wasm_exec.js"></script>
   <script>
      const go = new Go();
      WebAssembly
         .instantiateStreaming(fetch("wings.wasm"), go.importObject)
         .then(r => go.run(r.instance))
         .catch(console.error);
   </script>
</head>
<body>
   <hello-world></hello-world>
</body>
</html>

Create mod/hello/hello.go:

//go:build js && wasm

package hello

import (
    _ "embed"
    "github.com/luisfurquim/wings"
)

//go:embed hello.html
var htmlContent string

type Hello struct{}

func init() {
    wings.Register("hello-world", htmlContent, "",
        func() wings.PranaMod { return &Hello{} })
}

func (h *Hello) InitData() map[string]any {
    return map[string]any{"greeting": "Hello from Go + WASM!"}
}

func (h *Hello) Render(_ *wings.PranaObj) {}

Create mod/hello/hello.html:

<h1>{{greeting}}</h1>

Create main.go:

//go:build js && wasm

package main

import (
    "github.com/luisfurquim/wings"
    _ "hello-wings/mod/hello"
)

func main() { wings.Main() }

Build and serve:

# 3. Build the WASM binary
GOOS=js GOARCH=wasm go build -o static/wings.wasm .

# 4. Start a minimal dev server (paste into serve.go, then run it)
cat > serve.go.tmp <<'GOFILE'
//go:build ignore

package main

import (
    "fmt"
    "net/http"
)

func main() {
    fs := http.FileServer(http.Dir("static"))
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path[len(r.URL.Path)-5:] == ".wasm" {
            w.Header().Set("Content-Type", "application/wasm")
        }
        fs.ServeHTTP(w, r)
    })
    fmt.Println("Listening on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}
GOFILE
go run serve.go.tmp

Open http://localhost:8080 and you should see "Hello from Go + WASM!".

Dev Container (rebuild-on-save)

For an iterative loop without rebuilding by hand — or without a Go toolchain on your machine at all — WINGS ships a Docker dev environment under dev/docker/. Copy three files into your app's root (Dockerfile, docker-compose.yml, and .env.example.env), then:

docker compose up

Your source stays on the host (bind-mounted), and on every save the container re-lints, recompiles wings.wasm, and serves it on http://localhost:8080 — reload the browser to see the change. The module cache persists across runs.

Under the hood this is the build orchestrator's dev mode, which also runs natively (Go installed, no Docker):

# from your app's root
go run github.com/luisfurquim/wings/cmd/build@latest dev

It is configured entirely through WINGS_* environment variables:

Variable Default Purpose
WINGS_PORT 8080 Dev server port.
WINGS_WEBROOT . Dir with index.html; wings.wasm + JS helpers are written here.
WINGS_MAIN . Main package compiled to wasm (relative to the app root).
WINGS_HTTPD (empty) Custom server command; empty uses the built-in static server.
WINGS_DEFLANG (empty) If set (e.g. pt-BR), runs gen_i18n each build.
WINGS_GENI18N_ARGS (empty) Extra gen_i18n flags.
WINGS_BUILD_TAGS (empty) Extra -tags for go build.
WINGS_WATCH_EXT go,html,css,json File extensions that trigger a rebuild.
WINGS_WATCH_MODE auto auto rebuilds on every save; on-demand only logs changes and rebuilds when you touch REBUILD at the app root.
WINGS_DEBOUNCE_MS 200 Coalesce window (ms) for bursts of saves.

If your app needs a real backend instead of the built-in static server, set WINGS_HTTPD to the command that starts it (e.g. WINGS_HTTPD=go run ./server); build-and-watch keep running alongside it.

When WINGS_DEFLANG is set, the loop also runs gen_i18n on each build (and publishes the signed catalogs into the webroot), and two optional passes can be enabled: WINGS_AUTO_FLEX (fill inflections from baked Unitex dictionaries — list locales in WINGS_DICT_LANGS) and WINGS_AUTO_TRANSLATE (machine/LLM pre-fill, configured via WINGS_TR_*).

To see the loop drive a real i18n-enabled app, run the bundled live-demo in the container with only .env flags (copy its live-demo/ and docs/ folders, no code changes). See dev/docker/README.md for that recipe and the full variable reference.

AI-Assisted Development

If you build WINGS apps with an AI coding assistant, this repo ships a context kit so the assistant writes correct WINGS code instead of guessing from a long README — including the most common mistake (camelCase binding names silently becoming no-ops; WINGS lowercases attribute names, so use snake_case).

  • Any assistant (Codex, Cursor, Copilot, Gemini CLI, Windsurf, Aider, …): the repo-root AGENTS.md is read automatically by tools that support the open AGENTS.md standard. It carries the mental model, the must-know gotchas, and the template-syntax quick reference.
  • Tools with their own rules format: thin wrappers re-point Cursor (.cursor/rules/), Kiro (.kiro/steering/), and GitHub Copilot (.github/copilot-instructions.md) back to AGENTS.md, so each picks it up through its native always-on mechanism — no content is duplicated.
  • Claude Code: install the wings-authoring plugin for deeper, task-triggered skills. The wings repository is itself the marketplace — no public store:
    /plugin marketplace add luisfurquim/wings
    /plugin install wings-authoring@wings
    

The plugin ships skills for components, i18n, skins, built-in widgets, and the build (wings-component, wings-i18n, wings-skins, wings-widgets, wings-build), plus app-flavored security skills that re-aim WINGS's own hardening practices at the app you write (sec-wasm-go, sec-hostile-input, sec-fail-operational, sec-supply-chain, sec-minimal-trusted-code, sec-fuzzing).

Project Setup

A WINGS project has the following structure:

myapp/
├── go.mod
├── main.go                 # WASM entry point
├── mod/
│   └── mywidget/
│       ├── mywidget.go     # Module logic (implements PranaMod)
│       ├── mywidget.html   # Template with binding syntax
│       └── mywidget.css    # Component styles
└── static/
    ├── index.html          # HTML page
    ├── prana_helper.js     # wings JS bridge (from wings package)
    └── wasm_exec.js        # Go WASM runtime

HTML Page

The load order is critical. prana_helper.js must come before wasm_exec.js, and both must come before the WASM binary:

<!DOCTYPE html>
<html>
<head>
   <!-- 1. wings JS bridge (defines window._pranaDef) -->
   <script src="prana_helper.js"></script>

   <!-- 2. Go WASM runtime -->
   <script src="wasm_exec.js"></script>

   <!-- 3. Load and run the WASM binary -->
   <script>
      const go = new Go();
      WebAssembly
         .instantiateStreaming(fetch("wings.wasm"), go.importObject)
         .then(result => go.run(result.instance))
         .catch(err => console.error(err));
   </script>
</head>
<body>
   <my-widget title="Demo"></my-widget>
</body>
</html>

Entry Point (main.go)

//go:build js && wasm

package main

import (
    "github.com/luisfurquim/wings"

    // Side-effect imports: each init() registers a module via wings.Register()
    _ "myapp/mod/mywidget"
)

func main() {
    wings.Main() // Defines all custom elements and blocks forever
}

Creating a Module

Each module implements the PranaMod interface:

type PranaMod interface {
    InitData() map[string]any    // Returns initial component state
    Render(obj *PranaObj)        // Called after connection to DOM
}

Example Module

mod/counter/counter.go

//go:build js && wasm

package counter

import (
    _ "embed"
    "github.com/luisfurquim/wings"
    "github.com/luisfurquim/wings/timer"
)

//go:embed counter.html
var htmlContent string

//go:embed counter.css
var cssContent string

type Counter struct{}

func init() {
    wings.Register(
        "my-counter",       // custom element tag name
        htmlContent,         // embedded HTML template
        cssContent,          // embedded CSS
        func() wings.PranaMod { return &Counter{} },
        "title",             // observed attributes
    )
}

func (c *Counter) InitData() map[string]any {
    return map[string]any{
        "title": "Counter",
        "count": 0,
    }
}

func (c *Counter) Render(obj *wings.PranaObj) {
    // Set up a ticker that increments count every second
    go func() {
        tk := timer.NewTicker(1000)
        defer tk.Stop()
        n := 0
        for range tk.Tick {
            n++
            obj.This.Set("count", n)
        }
    }()
}

mod/counter/counter.html

<div class="counter">
   <h2>{{title}}</h2>
   <p>Count: <span>{{count}}</span></p>
</div>

Template Syntax

Expression Binding

Use {{expression}} to bind data values to the DOM. Expressions are automatically updated when the data changes.

<!-- Simple variable -->
<span>{{title}}</span>

<!-- Nested field access -->
<span>{{user.name}}</span>

<!-- Array access with literal index -->
<span>{{items[0]}}</span>

<!-- Array access with variable index -->
<span>{{items[i].label}}</span>

<!-- Attributes -->
<img src="{{avatar_url}}" alt="{{username}}" />

Hash Fragment Binding

The special reference {{#}} resolves to the current URL hash fragment (i.e. the portion of window.location.hash after the # sign).

<!-- If URL is https://example.com/app#settings, displays "settings" -->
<span>Current view: {{#}}</span>

<!-- Conditional rendering based on hash -->
<div ?#="home">Home content here</div>
<div ?#="settings">Settings panel</div>

wings automatically monitors window.location.hash and triggers a sync on all live component instances whenever the hash changes, so {{#}} references are always up to date.

To change the hash programmatically from Go:

wings.GoTo("settings")   // sets window.location.hash = "settings"

This fires the browser's hashchange event, which in turn updates every {{#}} binding across all components.

Conditional Rendering

Use the ? prefix on an attribute to conditionally show or hide an element. Seven forms are supported:

Boolean (truthiness)

<!-- Shows only when show_details is truthy -->
<div ?show_details>
    <p>Details: {{details}}</p>
</div>

<!-- Shows only when items array has elements -->
<div ?items.length>
    <p>There are items!</p>
</div>

Negated Boolean (?!var)

Shows the element only when the variable is falsy (the logical negation of the truthiness check):

<!-- Shows only when is_loading is falsy -->
<div ?!is_loading>
    <p>Content has loaded.</p>
</div>

<!-- Shows only when items array is empty -->
<div ?!items.length>
    <p>No items found.</p>
</div>

Equality (?var="value")

Shows the element only when the variable's string representation equals the given value:

<!-- Shows only when user_type is "A" (Author) -->
<div ?user_type="A">
    <p>Author-specific content</p>
</div>

<!-- Shows only when status is "active" -->
<span ?status="active" class="badge">Active</span>

Inequality (?var!="value")

Shows the element only when the variable's string representation does not equal the given value:

<!-- Shows for any user_type except "R" (Reader) -->
<div ?user_type!="R">
    <p>Extra profile fields</p>
</div>

<!-- Hides when status is "deleted" -->
<div ?status!="deleted">
    <p>This record is visible</p>
</div>

Prefix (?var^="value")

Shows the element only when the variable's string representation starts with the given value:

<!-- Shows only when url starts with "https" -->
<div ?url^="https">
    <p>Secure connection</p>
</div>

Suffix (?var$="value")

Shows the element only when the variable's string representation ends with the given value:

<!-- Shows only when filename ends with ".pdf" -->
<div ?filename$=".pdf">
    <p>PDF document</p>
</div>

Contains (?var*="value")

Shows the element only when the variable's string representation contains the given value as a substring:

<!-- Shows only when tags contain "urgent" -->
<div ?tags*="urgent">
    <p class="alert">Urgent item</p>
</div>

How operators map to HTML attributes

The browser parses these forms naturally — no special escaping is needed:

Template syntax HTML attr name HTML attr value Operator
?cond ?cond (empty) truthy
?!cond ?!cond (empty) negated truthy
?cond="abc" ?cond abc equality
?cond!="abc" ?cond! abc inequality
?cond^="abc" ?cond^ abc starts with
?cond$="abc" ?cond$ abc ends with
?cond*="abc" ?cond* abc contains

Note: Comparison operators < and > are not supported because they conflict with HTML tag syntax.

Behavior

When the condition is falsy (or the comparison fails), the element is replaced by a comment node. When the condition becomes truthy (or the comparison succeeds), the original element is restored.

Truthiness rules (for the boolean form, same as JavaScript):

  • Falsy: nil, false, 0, "", empty []any{}
  • Truthy: everything else

Comparison rules (for equality/inequality forms):

  • The variable value is converted to its string representation via fmt.Sprintf("%v", value) before comparing with the attribute value.
  • This means numeric values work as expected: ?count="0" matches when count is 0 (int) or "0" (string).

Array Iteration

Use the * prefix to repeat an element for each item in an array.

Single-element iteration (*array:index)

Creates a <span> wrapper around the repeated elements:

<ul>
   <li *items:i>{{items[i].label}}</li>
</ul>

With data:

"items": []any{
    map[string]any{"label": "Alpha"},
    map[string]any{"label": "Beta"},
    map[string]any{"label": "Gamma"},
}

Produces:

<ul>
   <span>
      <li>Alpha</li>
      <li>Beta</li>
      <li>Gamma</li>
   </span>
</ul>

Container iteration (**array:index)

The parent element itself becomes the container (no extra wrapper):

<!--
   Important! With **, the iterator attribute goes on the CONTAINER element
   (here <ul>), not on the repeated child. The first child element (<li>)
   becomes the template that is cloned for each array item.
-->
<ul **items:i>
   <li>{{items[i].label}}</li>
</ul>

Produces:

<ul>
   <li>Alpha</li>
   <li>Beta</li>
   <li>Gamma</li>
</ul>

The index variable (:i) is available in the template. You can use {{i}} to access the current iteration index.

Two-Way Binding

Use the & prefix on an attribute to establish a two-way binding between a form input and a data variable. Changes to the input update the data, and changes to the data update the input.

<input &value="{{username}}" type="text" placeholder="Username" />
<input &value="{{password}}" type="password" />
<select &value="{{selected}}">
    <option value="a">Option A</option>
    <option value="b">Option B</option>
</select>
<textarea &value="{{bio}}"></textarea>

Two-way binding only works with:

  • <input>
  • <select>
  • <textarea>

And requires a pure reference (single {{variable}}), not mixed text like "prefix {{variable}}".

Events (Child to Parent)

Use the @ prefix in the parent's template to bind a child component event to a handler function defined in the parent's data. The child fires the event using obj.Trigger("event_name").

<!-- Parent template: @event_name="handler_name" -->
<my-login @login="on_login" @logout="on_logout"></my-login>

The naming works like this:

  • @login is the event name — this is what the child passes to Trigger
  • "on_login" is the handler function name — looked up in the parent's data map

The child fires the event using only the name without the @ prefix:

// In the CHILD's Render:
obj.Trigger("login")       // matches @login in parent's template
obj.Trigger("logout")      // matches @logout in parent's template

The handler must be a func(...any) (or wings.TriggerHandler) in the parent's data map.

Important: In InitData, the obj parameter is not yet available — it only becomes available in Render. If your handler needs obj (which is almost always the case), use wings.TriggerHandler(nil) as a placeholder in InitData, then set the real handler in Render:

func (app *App) InitData() map[string]any {
    return map[string]any{
        // Placeholder — obj is not available here
        "on_login":  wings.TriggerHandler(nil),
        "on_logout": wings.TriggerHandler(nil),
    }
}

func (app *App) Render(obj *wings.PranaObj) {
    // Now obj is available — define the real handlers
    obj.This.Set("on_login", func(args ...any) {
        obj.This.Set("is_logged", true)
        obj.This.Set("is_anonymous", false)
    })
    obj.This.Set("on_logout", func(args ...any) {
        obj.This.Set("is_logged", false)
        obj.This.Set("is_anonymous", true)
    })
}

You can pass arguments from the child to the parent handler:

obj.Trigger("login", username, token)

Note: @ event attributes are read directly from the DOM at trigger time, so they do not need to be listed in the child's observed attributes. Only attributes whose values change at runtime (like & bindings) need to be observed.

See Parent-Child Communication for a complete example.

Catch-all event channels (@all / @else)

A named binding like @login only routes the login event. Two wildcard channels let a parent observe events without naming each one:

  • @all — the spy. It fires on every event the child triggers, including events that also have a named @handler. It routes last, after the primary handler, so by the time @all runs the DOM and state already reflect the event's effect — ideal for assertions and logging.
  • @else — the rest. It fires only for events that have no named @handler. For any given event, @<event> and @else never both fire (@else means "there was no specific handler").

Because one catch-all serves many events, its handler has a different signature — func(name string, params ...any) — receiving the event name typed as its first argument (a spy needs to know which event fired). A named handler keeps the plain func(...any).

<my-widget
    @save="onSave"          <!-- specific handler -->
    @else="onOther"         <!-- fires for everything except save -->
    @all="onAny">           <!-- fires for everything, including save, last -->
</my-widget>
obj.This.Set("onSave", func(args ...any) { /* args = save's args */ })
obj.This.Set("onOther", func(name string, params ...any) {
    // name = the event name, params = the event's own args
})
obj.This.Set("onAny", func(name string, params ...any) {
    // fires for every event, after the specific/else handler
})

These power the <w-test> harness, which stamps @all on the widget it wraps to capture its whole event stream. Like other @ bindings they are read from the DOM at fire time and need no observed-attribute declaration.

Reactive Data API

The ReactiveData type wraps a map[string]any and triggers automatic DOM synchronization on every mutation.

func (c *MyComponent) Render(obj *wings.PranaObj) {
    // Set a value (triggers DOM sync)
    obj.This.Set("title", "New Title")

    // Get a value
    title := obj.This.Get("title").(string)

    // Delete a key
    obj.This.Delete("old_field")

    // Append to an array
    obj.This.Append("items", map[string]any{"label": "New Item"})

    // Set array element at index
    obj.This.SetAt("items", 0, map[string]any{"label": "Updated"})

    // Delete array element at index
    obj.This.DeleteAt("items", 2)

    // Access the raw map directly (no automatic sync)
    obj.This.M["key"] = "value"
    // Then trigger sync manually:
    obj.This.Sync()
}

Navigation — Hash Fragment

WINGS exposes a package-level function to change the URL hash fragment:

// Navigate to a new view
wings.GoTo("settings")  // -> window.location.hash = "#settings"

// Clear the hash
wings.GoTo("")          // -> window.location.hash = ""

All {{#}} bindings and ?#="value" conditionals update automatically.

How DOM Updates Work

WINGS does not use a Virtual DOM. Instead it relies on direct, targeted DOM manipulation guided by a compile-time reference map.

Reference Extraction

When a component is first connected, WINGS walks the HTML template once and builds a DOMRefNode tree — a lightweight map that records, for every DOM node, which data keys appear in its text content and attributes. This map is stored alongside the component's reactive state and never rebuilt.

Synchronization Cycle

Every mutation through the ReactiveData API (Set, Delete, Append, DeleteAt, SetAt, or Sync) increments a global epoch counter and kicks off a synchronization pass:

  1. Epoch guard — each component tracks the last epoch it was synced at. If the current epoch matches, the sync is skipped, breaking circular propagation between parent and child components.

  2. Tree walk — the engine walks the DOMRefNode tree in parallel with the live DOM tree. For each node it:

    • Text nodes: resolves all {{expression}} segments against the current data context and writes the result to node.data (or element.value for <textarea>).
    • Attributes: resolves each bound attribute's segments and calls setAttribute only when the new value differs from the current one.
    • Conditionals (?): evaluates the condition. If false, the element is replaced by a comment placeholder; if true and currently hidden, the original element is restored from the stored reference.
    • Arrays (* / **): compares the current array length to the number of child nodes. Adds clones of the template for new items, removes excess nodes for deleted items, and recursively syncs each child with its corresponding array element.
    • Two-way bindings (&): updates the input's value property and keeps the stored context pointer current so the onchange handler always writes back to the correct data key.
  3. Propagation — after the local sync, observed attributes on child custom elements are updated via setAttribute, which triggers their own attributeChangedCallback and a downstream sync (subject to the same epoch guard).

Why Not a Virtual DOM?

A virtual DOM diffs an entire tree snapshot to compute the minimum set of mutations. WINGS skips the diff entirely: the reference map already knows which DOM nodes depend on which data keys, so it can jump directly to the affected nodes. This makes updates O(bindings) rather than O(tree size), with no garbage from disposable tree snapshots — an important property in a WASM environment where GC pauses are more noticeable.

Helper Packages

Helper functions are organized into subpackages so applications only import what they actually use, keeping the WASM binary lean.

wings/dom — Events and Queries

import "github.com/luisfurquim/wings/dom"

Register DOM event listeners with automatic preventDefault and stopPropagation support:

func (c *MyComponent) Render(obj *wings.PranaObj) {
    forms := dom.Query(obj.Dom, "form")
    if len(forms) > 0 {
        // Register submit handler with preventDefault
        handlerID := dom.AddEvent(forms[0], "submit",
            func(this js.Value, args []js.Value) any {
                username := obj.This.Get("username").(string)
                password := obj.This.Get("password").(string)
                // ... handle login
                return nil
            },
            true,  // preventDefault
            false, // stopPropagation
        )

        // Later, to remove the handler:
        // dom.RmEvent(handlerID)
    }
}

API:

func dom.AddEvent(el js.Value, eventName string,
    handler func(this js.Value, args []js.Value) any,
    preventDefault, stopPropagation bool) int64

func dom.RmEvent(id int64)

func dom.Query(el js.Value, selector string) []js.Value

wings/timer — Timers

import "github.com/luisfurquim/wings/timer"

// Sleep blocks the current goroutine for ms milliseconds,
// yielding control to the JS event loop.
timer.Sleep(2000)

// NewTicker sends on Tick channel every ms milliseconds.
// Call Stop() to release resources.
tk := timer.NewTicker(1000)
defer tk.Stop()
for range tk.Tick {
    // called every second
}

// SetTimeout schedules fn after delay ms.
// Returns a channel that closes on completion.
done := timer.SetTimeout(func() {
    fmt.Println("fired!")
}, 5000)
<-done // wait for it

// SetInterval schedules fn every interval ms.
// Returns a cancel function.
cancel := timer.SetInterval(func() {
    fmt.Println("tick")
}, 1000)
// later:
cancel()

wings/location — Browser Location

import "github.com/luisfurquim/wings/location"

// Get window.location.href as *url.URL
loc, err := location.Get()

// Get top.location.href as *url.URL (useful inside iframes)
topLoc, err := location.GetTop()

wings.KeyStorage — Storage Interface

The wings.KeyStorage interface defines a backend-agnostic key-value storage API. It accepts arbitrary Go values and relies on an Encoder/Decoder pair for serialization. Any storage backend (localStorage, OPFS, IndexedDB, etc.) can implement this interface:

type KeyStorage interface {
    Set(key string, val any) error
    Get(key string, outval any) error
    Del(key string) error
    Exists(key string) (bool, int64, error)
}

Modules that need persistent storage should accept a wings.KeyStorage instead of a concrete type. This way the application's main() decides which backend to use:

// In a module package:
var Store wings.KeyStorage

// In main():
import "github.com/luisfurquim/wings/localstorage"
import "github.com/luisfurquim/wings/opfs"

// Option A: localStorage backend
myModule.Store = localstorage.NewKV(nil, nil)

// Option B: OPFS backend (recommended for larger/sensitive data)
myModule.Store = opfs.New(nil, nil)

wings/localstorage — LocalStorage

import "github.com/luisfurquim/wings/localstorage"

Access browser localStorage with pluggable serialization.

Encoder / Decoder

Implement these interfaces to choose your encoding strategy (JSON, Gob+base64, etc.):

type Encoder interface {
    Encode(inpval any) string
}

type Decoder interface {
    Decode(buf string, outval any) error
}

If you pass nil for either parameter, a built-in default codec is used. It handles common Go types out of the box:

Type Encode Decode
string passthrough passthrough
[]byte string(v) []byte(s)
bool "true" / "false" strconv.ParseBool
int, int8--int64 strconv.FormatInt strconv.ParseInt
uint, uint8--uint64 strconv.FormatUint strconv.ParseUint
float32, float64 strconv.FormatFloat strconv.ParseFloat

KV is the recommended way to use localStorage. It implements wings.KeyStorage:

// Create with default codec (handles string, int, float, bool, etc.)
kv := localstorage.NewKV(nil, nil)

// Or with a custom encoder/decoder
kv := localstorage.NewKV(myEncoder, myDecoder)

// Store a value
err := kv.Set("username", "Ana")

// Retrieve a value (outval must be a pointer)
var name string
err := kv.Get("username", &name)
if errors.Is(err, localstorage.ErrKeyNotFound) {
    // key does not exist
}

// Check existence and get stored string length
exists, size, err := kv.Exists("username")

// Remove a key
err := kv.Del("username")

LS — Legacy API

LS provides the original API with pluggable Encoder/Decoder. It does not implement wings.KeyStorage (its Set and Del methods do not return errors). New code should use KV instead.

ls := localstorage.New(myEncoder, myDecoder)

// Or with the default codec
ls := localstorage.New(nil, nil)

ls.Set("user", map[string]any{"name": "Ana", "age": 30})

var user map[string]any
err := ls.Get("user", &user)

ls.Del("user")

// Iteration helpers (not available on KV)
n := ls.Len()
name, ok := ls.Key(0)
ls.Clear()

wings/opfs — Origin Private File System

import "github.com/luisfurquim/wings/opfs"

Access the browser's Origin Private File System directly from Go WASM. Files are stored in a sandboxed, origin-scoped filesystem that is invisible to the user and not subject to the same storage limits as localStorage.

opfs.Store implements wings.KeyStorage and uses the same Encoder/Decoder pattern as localstorage.KV. If nil is passed for either parameter, the built-in default codec is used (same type table as localstorage).

// Create with default codec
store := opfs.New(nil, nil)

// Store a value
err := store.Set("my-key", "hello world")

// Retrieve a value (outval must be a pointer)
var val string
err := store.Get("my-key", &val)
if errors.Is(err, opfs.ErrNotFound) {
    // key does not exist
}

// Check existence and get stored size in bytes
exists, size, err := store.Exists("my-key")

// Remove a key (no error if it does not exist)
err := store.Del("my-key")

The store accesses OPFS via the asynchronous File System API (navigator.storage.getDirectory()), called directly through syscall/js. No Service Worker is required.

JavaScript Interop (core)

These functions remain in the core wings package:

// Access the global window object
window := wings.JSGlobal()

// Create a persistent JS callback (must call Release() when done)
fn := wings.JSFunc(func(this js.Value, args []js.Value) any {
    // handle callback
    return nil
})
defer fn.Release()

// Create a one-shot JS callback (auto-releases after first call)
fn := wings.JSFuncOnce(func() {
    // handle callback
})

Customizable Widgets

Modules that implement only PranaMod have fixed CSS. Modules that also implement Customizable allow consuming applications to replace parts of their CSS at runtime — for example, changing the color scheme without touching the layout rules.

Customizable Interface

// CSSPart is a named section of a component's CSS.
type CSSPart struct {
    Name    string
    Content string
}

// Customizable extends PranaMod with CSS customization.
type Customizable interface {
    PranaMod
    ListCSS() []CSSPart
    ReplaceCSS(key string, content string)
}
  • ListCSS() returns the CSS parts in order. The order matters: for example, a "Vars" part defining CSS custom properties must come before a "Design" part that uses var() references.
  • ReplaceCSS(key, content) replaces the named part and updates all live instances immediately via wings.Update().

wings.Update — Dynamic CSS

wings.Update(tagName string, cssContent string)

Replaces the CSS of a registered custom element and updates the <style> tag in the Shadow DOM of every live instance. Called automatically by ReplaceCSS; can also be called directly for full CSS replacement.

Skins — Theming with --wings-* Tokens

A skin is a CSS payload — a block of --wings-* custom-property definitions at :root — paired with a SkinCategory bitmask declaring which design dimensions it touches. Widgets read every token through a var(--wings-X, fallback) reference, so they look correct with no skin active and restyle globally the moment one is applied. There is no per-widget theming step.

import (
    "github.com/luisfurquim/wings"
    _ "github.com/luisfurquim/wings/skins/light" // blank import → init() registers it
    _ "github.com/luisfurquim/wings/skins/glass"
)

func main() {
    if err := wings.ApplySkin("light"); err != nil { /* … */ }
    _ = wings.ApplySkin("glass") // composes on top of light
    wings.Main()
}

Each active skin owns a <style id="wings-skin-NAME" data-wings-skin="NAME"> in document.head; DOM order is activation order, so later skins cascade over earlier ones.

Multi-skin composition

Several skins can be active at once provided their category bitmasks are disjoint (their AND is zero). This lets a complete theme (colours + geometry + depth + …) compose with a focused skin that touches one orthogonal dimension (e.g. glass, which is Atmosphere-only). Activating a skin whose categories overlap an already-active skin returns a *SkinConflictError instead of silently double-setting a token.

There are nine categories (SkinCategory, a uint64 bitmask):

Category What it owns
CategoryIdentity colours, surfaces, text, primary/secondary, borders, button colours, shadow colour
CategoryGeometry corner-radius scale, border width/style
CategoryDepth shadow shapes (offset/blur/spread); colour comes from Identity
CategoryMotion transition durations/easing, hover-lift, active-scale
CategoryInteraction focus-ring (chromatic feedback)
CategoryTypography font family/size/weight (reserved — no built-ins yet)
CategorySpacing padding / gap density
CategoryLighting gradients, glows, gradient-shadow
CategoryAtmosphere glass opacity, surface blur/saturate

The split is principled: a token whose value is a colour belongs to a chromatic category (Identity / Lighting / Interaction); a token whose value is a metric (px, ms, ratio, transform) belongs to a structural/temporal one (Geometry / Spacing / Depth / Motion). Shadows are split deliberately — the shape is Depth, the colour rgba is Identity (--wings-shadow-color-*), and Depth skins compose the final --wings-shadow-* that widgets read.

Convenience masks bundle the bits a typical skin of each kind declares: IdentitySkinCategories (Identity|Lighting|Interaction), GeometrySkinCategories (Geometry|Spacing), DepthSkinCategories, MotionSkinCategories.

Built-in skins (18)

Kind (mask) Skins Mutually exclusive?
Identity (theme) light dark autumn darkblueberry darkforest lightblueberry mushroom vividforest yes — pick one
Geometry sharp classic soft yes — pick one
Depth flat lifted floating yes — pick one
Motion gentle calm brisk yes — pick one
Atmosphere glass composes with anything

A complete look is one from each column, e.g. the live-demo default is light + classic + lifted + calm (optionally + glass). Import each skin you intend to use (blank import) so its init() registers it.

Registering your own skin

//go:embed skin.css
var css string

func init() {
    // Declare exactly the dimensions your CSS defines, so composition and
    // conflict detection work. A chromatic theme uses the convenience mask:
    wings.RegisterSkin("midnight", wings.IdentitySkinCategories, css)
    // A focused skin declares a single bit:
    // wings.RegisterSkin("rounded", wings.GeometrySkinCategories, css)
}

The token contract every skin fills lives in skins/tokens.md, organised one section per category. Define the tokens your declared categories own; widgets supply literal fallbacks for everything else.

Private categories (your own design dimensions)

SkinCategory is a uint64, and its bit space is partitioned like IANA address space. The low bits are framework-owned built-ins (9 today) and grow upward; the high 16 bits (48–63) are permanently reserved for you. A built-in will never be assigned a bit in that range, so a private category you mint today keeps working across WINGS upgrades. Mint one with UserCategory, give it a display name (optional, shown by <skin-switcher>), then use it like any built-in category — it participates in the same disjoint-mask conflict detection:

// A private "Brand" dimension, orthogonal to every built-in category.
var CategoryBrand wings.SkinCategory

func init() {
    b, err := wings.UserCategory(0) // n in [0,16)
    if err != nil {
        log.Fatal(err) // your call — the library returns the error, never panics
    }
    CategoryBrand = b
    _ = wings.RegisterCategoryName(CategoryBrand, "Brand")
    wings.RegisterSkin("acme", CategoryBrand, acmeCSS)
}

An acme skin declared this way composes freely with light + classic + … and only conflicts with another skin that also claims CategoryBrand.

Skin API

Function Purpose
RegisterSkin(name, categories, css) Register a skin (call from init()).
ApplySkin(name) error Activate. Idempotent; returns *SkinNotRegisteredError or *SkinConflictError.
DeactivateSkin(name) error Remove one active skin.
ClearSkins() Deactivate all.
ActiveSkins() []string / ActiveSkin() string All active (activation order) / most-recent.
ActiveCategories() SkinCategory OR of every active skin's mask.
ConflictsWith(categories) []string Active skins that would block categories.
ListSkins() []string / ListSkinInfos() []SkinInfo Registered names / names+categories.
SkinCategoriesOf(name) (SkinCategory, bool) A skin's declared mask.
UserCategory(n) (SkinCategory, error) Mint the n-th private category bit (n in [0,16)); reserved from built-ins.
RegisterCategoryName(bit, name) error Name a private category so it shows in Names()/String()/<skin-switcher>.
CategoryUserMask Mask of all 16 private bits — c & CategoryUserMask != 0 tests for any private category.
OnSkinChange(fn func()) Hook fired after every apply/deactivate/clear (used by <skin-switcher> to stay in sync).

The <skin-switcher> built-in widget exposes all of this as UI — see

Function Purpose
RegisterSkin(name, categories, css) Register a skin (call from init()).
ApplySkin(name) error Activate. Idempotent; returns *SkinNotRegisteredError or *SkinConflictError.
DeactivateSkin(name) error Remove one active skin.
ClearSkins() Deactivate all.
ActiveSkins() []string / ActiveSkin() string All active (activation order) / most-recent.
ActiveCategories() SkinCategory OR of every active skin's mask.
ConflictsWith(categories) []string Active skins that would block categories.
ListSkins() []string / ListSkinInfos() []SkinInfo Registered names / names+categories.
SkinCategoriesOf(name) (SkinCategory, bool) A skin's declared mask.
OnSkinChange(fn func()) Hook fired after every apply/deactivate/clear (used by <skin-switcher> to stay in sync).

The <skin-switcher> built-in widget exposes all of this as UI — see Built-in Widgets below.

Built-in Widgets

wings/widget/combobox — Multi-select Combobox

import _ "github.com/luisfurquim/wings/widget/combobox"

A multi-select combobox with type-ahead filtering, tag display, and keyboard support.

<w-combobox
    options='["Alpha","Beta","Gamma"]'
    placeholder="Type to filter..."
    mode="multi"
    @notinlist="on_notinlist"
    @change="on_change">
</w-combobox>

Attributes:

Attribute Description
options JSON array of strings or [{"label":"...","value":"..."},...] objects
placeholder Input placeholder text (default: "Type to filter...")
mode "multi" (default — tag-based multi-select) or "single" (replaces previous selection, hides tag display, shows label in the input)
value Pre-selected value. In single mode this attribute is authoritative: if it disagrees with the current selection (e.g. the parent reverts it after the user cancels a confirmation dialog), the visible selection re-syncs to match. The re-sync is silent — @change does NOT fire — so a controlled parent can safely roll back without re-entering its own change handler. In multi mode value only seeds the initial selection.

Events (via @):

Event Args Description
@notinlist typed string Enter pressed with text not matching any option
@change []any of selected items Selection changed by user action. Programmatic re-sync via value does not fire this.

Theming. The combobox reads its colours and metrics from the shared --wings-* skin tokens--wings-input-* (field), --wings-list-box-* / --wings-list-item-* (dropdown), --wings-tiny-element-* (tags), --wings-remover-* (tag remove button), plus --wings-radius-*, --wings-shadow-md and --wings-transition-fast. Apply a skin and the combobox follows; no per-widget theming needed. It also opts into the glass skin via --wings-surface-blur/--wings-surface-saturate on the dropdown panel.

The "No results" label is rendered via CSS content: attr(data-i18n), so it is translatable through the normal i18n pipeline.

Per-instance overrides. The widget still implements Customizable with "Vars" and "Design" parts, so a single instance can override CSS without a skin via cb.ReplaceCSS("Vars", …) (see Customizable Widgets).

wings/widget/tabs — Tabbed Container (w-tabs / w-tabbutton / w-tab)

import (
    _ "github.com/luisfurquim/wings/widget/tabs"
    _ "github.com/luisfurquim/wings/widget/tabbutton"
    _ "github.com/luisfurquim/wings/widget/tab"
)

w-tabs is a controlled container: the single source of truth for which panel is visible is the host's active attribute (a w-tab tid, or a positional index). Panels are w-tab children; the w-tabbutton buttons are optional sugar.

Shape 1 — with buttons (simple tabs). Co-locate each button with its panel as direct children (button, panel, button, panel). w-tabs routes the buttons into a strip and a click sets active for you:

<w-tabs mode="panel">
  <w-tabbutton active>Overview</w-tabbutton>
  <w-tab><h2>Overview</h2>…</w-tab>
  <w-tabbutton>Details</w-tabbutton>
  <w-tab>…</w-tab>
</w-tabs>

Shape 2 — headless (controlled). Provide no w-tabbutton. w-tabs adds no chrome and renders its content transparently (passthrough), so your own layout reaches the panels; drive selection by binding active:

<w-tabs mode="detached" active="{{current}}">
  <header>…your own buttons set `current`…</header>
  <w-tab tid="a">…</w-tab>
  <w-tab tid="b">…</w-tab>
</w-tabs>

Modes (mode attribute on w-tabs):

Mode Layout
panel (default) button strip on top (scrolls on overflow), panel below
detached same shape, chip-like free-floating buttons, transparent panels
menu button column on the left, panel on the right
accordion each button is moved into its panel to become the native <summary>; a w-tab marked active starts open, then open/close is the platform's job

Attributes. On w-tabs: mode, active. On w-tabbutton / w-tab: tid (optional identifier for deep-link/programmatic selection — pairing is by DOM adjacency, so it's not required), active (boolean; you may set it on the initial markup to choose the starting tab), and mode (managed — stamped by w-tabs; don't write it yourself).

Event to parent. @change fires after a user-driven (click/keyboard) activation; args[0] is the selected tid (or positional index as a string). It does not fire at init or for programmatic active changes.

Panels keep their DOM across switches (hidden via CSS, not destroyed), so input values, scroll position and embedded widgets survive. w-tabbutton is a deliberately stateless, CSS-only leaf — that's what makes the accordion "move" safe. For one-button-to-one-content disclosure, native <details> is often simpler than accordion.

wings/widget/navbar — Record Navigation Toolbar (w-navbar)

import _ "github.com/luisfurquim/wings/widget/navbar"

A record-navigation toolbar — first / prev-many / prev / position input / next / next-many / last — that forwards actions to the parent as triggers. It keeps no internal state: current position and total are owned by the parent through bound fields.

<w-navbar
    nav_input="{{cur_record}}"
    total_count="{{record_count}}"
    @first="goFirst"     @prevmany="goPrevPage"
    @prev="goPrev"       @next="goNext"
    @nextmany="goNextPage" @last="goLast"
    @change="onSeek">
</w-navbar>

The position input is two-way bound, so typing updates nav_input; @change then fires with the new value (args[0]), and Enter fires it immediately without waiting for blur. w-navbar implements Customizable ("Vars" / "Design").

wings/widget/skinswitcher — Skin Picker (<skin-switcher>)

import _ "github.com/luisfurquim/wings/widget/skinswitcher"

A drop-in UI for the skin system: one checkbox per registered skin (so the user can stack a focused skin like glass on top of a complete theme). It detects conflicts via the category bitmasks and, on selecting a conflicting skin, auto-replaces the colliding active skin rather than blocking. It registers an OnSkinChange hook so its UI stays in sync with programmatic ApplySkin / DeactivateSkin calls. Just drop <skin-switcher> into your markup.

wings/widget/test — In-web Test Harness (w-test)

import _ "github.com/luisfurquim/wings/widget/test"

Wrap any prana widget (the subject) in <w-test> to get an in-web integration test: it stamps the @all spy channel on the subject to capture every event it fires, renders a live event log, and shows a ⏳/✅/❌ seal. This complements the testing tripod — unit tests for pure logic, <w-test> for DOM integration, the human eye for aesthetics — and the tests are visible to everyone, right next to the thing they exercise.

<w-test title="Counter fires changed"
        expect="Typing a new count should fire the changed event"
        check="countChanged">
    <count-input value="{{count}}"></count-input>
</w-test>

The assertion is a Go function registered by name (mirroring RegisterSkin), run on mount, after every captured event, and on the Re-run button:

wings.RegisterCheck("countChanged", func(ctx wings.CheckCtx) (bool, string) {
    for _, e := range ctx.Events {
        if e.Name == "changed" {
            return true, "saw changed"
        }
    }
    return false, "no changed event yet"
})

A CheckFunc receives a CheckCtx{Subject, Dom, Events} — the wrapped element, a node to query, and the ordered event log — and returns (pass bool, detail string). With no check= attribute the test is manual: the seal is a human-toggled ✅/❌ for purely visual checks (e.g. skin aesthetics). The host carries data-wtest-state="pending|pass|fail", so a later headless runner (Playwright) can scrape seals without re-deriving them. The widget's own chrome ("Events", "Re-run") is in English.

wings/widget/testreport — Module Self-tests (Testable() + w-test-report)

import _ "github.com/luisfurquim/wings/widget/testreport"

Instead of (or alongside) hand-wiring <w-test> cards, a module can declare its own integration tests by implementing the optional Testabler interface:

type Testabler interface {
    Testable() map[string]wings.CheckFunc
}

The runtime discovers them per live instance: when an element whose module implements Testabler mounts, its checks are registered against that element; they are dropped when it disconnects. Each check sees a CheckCtx whose Subject/Dom are the live element (Events is empty — event-stream assertions belong in a <w-test> wrapper, which spies via @all).

Declare Testable() in a file gated by the wings_test build tag so the tests compile only into test builds, never production:

//go:build js && wasm && wings_test

func (w *CountInput) Testable() map[string]wings.CheckFunc {
    return map[string]wings.CheckFunc{
        "renders-number-input": func(ctx wings.CheckCtx) (bool, string) {
            inputs := dom.Query(ctx.Subject.Get("shadowRoot"), "#ci-inp")
            if len(inputs) == 0 {
                return false, "no #ci-inp rendered"
            }
            return true, "number input present"
        },
    }
}

Drop <w-test-report> into your markup; its button collects the whole page's test result (wings.RunReport()) — every <w-test> card including the human-judged visual ones, in whatever state the tester left them, plus every Testable() check — shows it as JSON, and fires a report event carrying that JSON:

<w-test-report @report="on_report"></w-test-report>

Each entry is {kind, label, state, detail}, where kind is "w-test" or "testable" and state is "pass", "fail", or "pending". So a tester runs the whole page, judges the visual cards by eye, and delivers one report of what passed and what failed with a single click — no hand-written "these 100 of 500 failed".

Transport is your call. WINGS only produces the report — sending it to a server, writing it to a file, or diffing it in CI is the app's decision, handled in your @report handler. WINGS implements none of that.

To test everything, don't ask the framework to enumerate widgets — that is a composition concern. Build a throwaway test app that imports and mounts all your modules flat (no app hierarchy), compile it with -tags wings_test, and open it only in your dev/CI pipeline — never publish it.

Internationalization (i18n)

WINGS ships with an end-to-end i18n pipeline: a build-time extractor that rewrites HTML templates with stable numeric indices, a per-language catalog loaded at runtime by a zero-config package, a GUI editor for translators, and (optional) morphological dictionaries used to pre-fill plural/gender flexions from a Unitex DELAF source.

Pipeline Overview

            ┌────────────────────────────┐
            │ *.html (mod/**)            │  ── templates with natural text
            └────────────┬───────────────┘     (text nodes + translatable
                         │                      attributes like title, alt,
                         │   cmd/gen_i18n       placeholder, aria-label) +
                         ▼                      flex blocks {{@g %c ~w ...}}
            ┌────────────────────────────────────────────────┐
            │ *.i18n.html                                    │
            │ i18n/<deflang>.json                            │  ── text catalog
            │ i18n/<deflang>.inflections.json  (if any flex) │  ── gender×CLDR
            └────────────┬───────────────────────────────────┘
                         │
              translator ▼ (helpers/wlate GUI — Texto + Inflexões tabs)
            ┌────────────────────────────────────────────────┐
            │ i18n/<lang>.json + <lang>.inflections.json     │
            └────────────┬───────────────────────────────────┘
                         │   runtime
                         ▼
            ┌────────────────────────────┐
            │ wi18n (WASM, side-effect)  │  ── fetches both JSONs in parallel,
            │ wings.Printer   = lookup  │     installs Printer (text index →
            │ wings.SynPrinter = flex   │     string) and SynPrinter (flex
            └────────────────────────────┘     block → locale-correct form)

   ┌──────────────────────────────────────────────────────────────┐
   │ Optional: cmd/dictbuild self-fetches the Unitex DELAF for a  │
   │ locale (gh:UnitexGramLab/unitex-lingua + auto-built          │
   │ UnitexToolLogger from gh:UnitexGramLab/unitex-core) and      │
   │ emits <lang>.db (gob) used by gen_i18n to pre-fill flexions. │
   │ cmd/dictlookup is a CLI inspector for that .db.              │
   └──────────────────────────────────────────────────────────────┘

The runtime side is intentionally tiny: a TextNode whose content is a decimal number gets replaced by table[n] when the catalog is loaded, and left untouched otherwise — so dynamic text produced via {{expression}} passes through unchanged. The same lookup applies to values of the attributes listed in wings.TranslatableAttrs (default: title, placeholder, alt, aria-label, data-i18n).

For plurals and gender agreement — cases where a single translation string cannot reflect the target locale's grammar — WINGS ships a parallel pipeline keyed on inline flex sigils (@var/%var/~word/#N). See Flexion — Plurals & Gender (SynPrinter) below for the full syntax and catalog format.

wings/wi18n — Runtime Lookup

import _ "github.com/luisfurquim/wings/wi18n" — side-effect import only.

On init(), wi18n:

  1. Detects the browser language from navigator.languages[0], falling back to navigator.language, then en-US.
  2. Sets <html lang="…"> accordingly.
  3. Registers itself on wings.InitWG so wings.Main() waits for the catalog to load before defining custom elements.
  4. Fetches <BasePath><lang>.json (with fallback chain: full tag → base language → en-US) relative to the current page. BasePath defaults to i18n/; apps that ship their own catalog next to the project's catalog call wi18n.SetBasePath("<dir>/") from an init() that runs after wi18n's (see the wlate self-i18n setup below for a concrete example).
  5. Decodes the JSON as a []wi18n.Entry array (see schema below); builds the lookup table from each entry's content field.
  6. Replaces wings.Printer with a function that parses the TextNode content (or attribute value, when the attribute is in wings.TranslatableAttrs) as a decimal index and returns table[idx]. Entries whose content is empty fall back to rendering the raw index — a deliberate visual signal for missing translations.

If no catalog can be loaded, wings.Printer stays as the default ByPass and TextNodes render their raw numeric indices.

The bundle loaded at init is also stashed in an in-memory cache keyed by the requested tag, so the first call to wi18n.SetLang(<initial-lang>) later in the session is a cache hit (no re-fetch). See Runtime Locale Switching (SetLang) below.

// Usage: import for side effects, that's it.
import (
    "github.com/luisfurquim/wings"
    _ "github.com/luisfurquim/wings/wi18n"
    _ "myapp/mod/mywidget"
)

func main() { wings.Main() }

// Optional: read the selected language tag
lang := wi18n.Lang()

Catalog schema. The catalog is split across two parallel files: a browser-side data file and a server-side metadata file.

i18n/<lang>.json — shipped to the browser:

[
  { "content": "Dashboard", "revised": true },
  { "content": "", "revised": false, "source": "llm:gemma4" }
]

i18n/<lang>.meta.json — server-only, same length, parallel-indexed:

[
  { "context": "mywidget/mywidget.html:7:42",
    "ctxdetail": "a@mywidget/mywidget.html:7:42<br/>h2@mywidget/mywidget.html:65:17" },
  { "context": "mywidget/mywidget.html:9:5",
    "ctxdetail": "p@mywidget/mywidget.html:9:5" }
]
  • content — source string for the default language; translation for every other language. Empty means "not translated yet".
  • revised — translator-maintained flag, flipped in the wlate GUI once a human has reviewed the entry. Preserved across gen_i18n runs when the underlying source string has not changed.
  • source — optional provenance tag, set automatically when the entry was pre-filled (e.g. "llm:gemma4", "dict:unitex-lingua"). Displayed as a badge in wlate so translators know which entries need human review.
  • context (meta) — first occurrence as <path>:<line>:<col> (forward slashes on every OS).
  • ctxdetail (meta) — every occurrence joined by <br/>, each formatted as <tag>@<path>:<line>:<col>. For attribute values the tag is written as <element>[<attr>] (e.g., button[title]).

Runtime Locale Switching (SetLang)

Once the page has loaded, applications can switch the active locale on the fly without reloading the page or rebuilding the DOM. Form input, list contents, scroll position and component state survive the switch — only the bindings driven by Printer / SynPrinter / FmtPrinter are refreshed in place.

import "github.com/luisfurquim/wings/wi18n"

// from a click handler, language picker, etc.
wi18n.SetLang("en-US", func(err error) {
    if err != nil {
        // No catalog available for "en-US" or any of its fallbacks.
        return
    }
    // Locale switch is complete; wings.Locale is now "en-US"
    // and every visible custom element has been re-translated.
})

SetLang(tag, done) always runs in a goroutine and returns immediately; this is mandatory because the catalog fetch goes through the JS event loop, and a synchronous wait inside an event handler would deadlock the very fetch().then callback we are awaiting. The optional done callback fires (also from the goroutine) once the switch completes or fails. Pass nil if you do not need notification.

How it works:

  • The first call to a given tag fetches <BasePath><lang>.json (and <lang>.inflections.json, if any), parses it, and caches the resulting bundle keyed by the requested tag. Subsequent calls to the same tag reuse the cache, so toggling between languages does not re-fetch.
  • The active text and inflection tables are atomically swapped, then wings.Locale and <html lang="…"> are updated.
  • Every live custom-element instance is then walked: each text node and attribute that the constructor stashed (via _wi18nSrc / _wi18nAttr_* JS expandos on the node) is re-translated, the new binding string is re-parsed, and the affected PranaState is re-synced. Existing *items:i clones, conditional state, two-way bindings and timer goroutines are preserved.

Limitation — placeholder set, not order. Translations may freely reorder {{...}} placeholders to match the target language's word order. A mixed text node (words and placeholders) is indexed whole, with its {{...}} inline, so the catalog string for

pt-BR:  O usuário {{nome}} comprou {{produto}}

is translated as a single unit and the placeholders can move anywhere:

ja:     {{produto}} を {{nome}} が購入しました

At construction and on every SetLang, the runtime sets the node to Printer(index) (the translated string) and then re-parses it for bindings, so any permutation of the same placeholders binds correctly — word-order differences (SVO → SOV, etc.) are fully supported.

What the SetLang walker does not do is create or destroy DOMRefNodes on the fly: if a translation adds a {{...}} placeholder that was absent from the source-language string, or removes one, that delta may render as raw text. In short: keep the set of placeholders identical across locales; their order is free.

Performance caches. Two memoisation layers sit underneath the runtime so the per-switch cost stays low even for large pages:

  • Intl instance cache (wings/wi18n/intl_cache.go). Intl.NumberFormat / Intl.DateTimeFormat instances are expensive to construct (each one parses CLDR locale data behind the syscall/js boundary) and essentially free to call. They are cached keyed by (locale, options) and reused across SetLang calls — so toggling back to a previous locale always hits warm formatters.
  • Parsed-template cache (wings/parse_cache.go). The re-bind walker memoises expr.ParseText by its input string. Many instances of the same custom element produce identical translated strings, so the parse cost is paid once per unique (locale, source string) pair instead of once per node.

Neither cache is invalidated on SetLang: both are keyed on values that stay stable for the lifetime of the catalog.

Worked example. The live-demo/ directory at the repository root ships a single tabbed application that exercises every feature described in this README — including a locale switcher in the header that demonstrates SetLang simultaneously refreshing the basics, flex, fmt and i18n tabs across pt-BR / en-US / es-AR.

cmd/gen_i18n — Build-time Extractor

go run github.com/luisfurquim/wings/cmd/gen_i18n \
    --path ./mod \
    --deflang pt-BR

What it does:

  • Walks the directory tree, processes every *.html (skips files already ending in .i18n.html).
  • For each HTML file, parses the DOM, extracts the natural text from every TextNode and the value of each element attribute whose name is in the translatable attribute set (see flags below), inserts the string into a shared trie keyed by an octal hash of the text, and replaces the node content (or attribute value) with the trie's decimal index.
  • Leaves runtime placeholders verbatim. A text node (or attribute value) that is only {{…}} bindings plus whitespace — e.g. {{count}}, {{%price}}, {{%dist:km}} — carries no words to translate, so it is left untouched and never assigned a catalog index. This keeps the catalog clean and prevents non-deflang locales from rendering a bare index where the binding should be. (Flex blocks such as {{@g %c …}} are the deliberate exception: they are extracted — see Flex-block extraction below.)
  • Skips any element (and its subtree) marked translate="no" — see Opting out below.
  • Writes <file>.i18n.html next to each source template. Embed these at compile time via //go:embed instead of the original HTML. (Note: the parser emits a wrapping <html><head></head><body>…</body></html> — this is harmless; the framework sets the template via <template>.innerHTML, which dissolves those wrappers and keeps the fragment.)
  • Writes two parallel files per language: i18n/<lang>.json (browser bundle — content, revised, optional source) and i18n/<lang>.meta.json (server-only — context, ctxdetail). The deflang .json has content set to the source string for every entry; other <lang>.json files are remapped in place across runs by the change-detection pass below.
  • Preserves translations across source edits. Each run aligns the new source order against the committed deflang catalog (no side-car database — the JSON is the record). A source string still present is matched exactly regardless of where it moved, so its translation and revised flag carry over verbatim; a string that was edited is matched to its closest previous version (Levenshtein similarity, scoped between the surrounding unchanged anchors) and its old translation is reused but flagged revised=false for re-review; genuinely new strings start empty and disappeared ones are dropped.
  • Validates --deflang as a BCP 47 tag via golang.org/x/text/language (falls back to en-US on invalid input).
  • If legacy <lang>.csv files exist without a corresponding <lang>.json, they are converted once (the legacy ! marker, if present, is stripped). This is a one-shot migration; CSV is no longer the on-disk format.

Translatable-attribute flags. Attributes like title, placeholder, alt, aria-label, and data-i18n carry user-visible text just like text nodes do. gen_i18n extracts the values of the following attributes by default:

title, placeholder, alt, aria-label, data-i18n

CSS content: via data-i18n. CSS pseudo-elements (::before / ::after) can render translatable text without any CSS-level i18n machinery. Declare the CSS once:

.breadcrumb::before { content: attr(data-i18n); }

Then put the source text on the element as data-i18n:

<span class="breadcrumb" data-i18n="Início"></span>

gen_i18n assigns a catalog index to the attribute value exactly as it does for text nodes; the runtime Printer translates it at construction time and again on every SetLang() call; the browser picks it up through the standard CSS attr() function. No CSS parsing, no custom properties, no extra runtime code.

Three flags tune this set:

Flag Effect
--attrs <list> Replaces the default list entirely. Comma-separated, case-insensitive.
--add-attrs <list> Appends to the active list (default, or whatever --attrs produced).
--no-attrs <list> Removes from the active list after additions, so it can also drop defaults.

In ctxdetail, attribute occurrences are distinguished from text-node occurrences by the tag format: button[title]@path:line:col vs. button@path:line:col.

Opting out (translate="no")

gen_i18n honors the standard HTML translate attribute. An element marked translate="no" — and everything inside it — is excluded from extraction (both text nodes and translatable attributes); a nested translate="yes" re-enables it. The attribute is build-time only: the wi18n runtime ignores it and still substitutes any numeric index it finds, so a node can hold a literal index under translate="no" and still render live.

Use it for content that must stay verbatim regardless of locale:

<!-- Language autonyms never translate (each shows in its own language) -->
<select translate="no">
  <option value="pt-BR">Português</option>
  <option value="en-US">English</option>
  <option value="es-AR">Español</option>
</select>

<!-- Verbatim prose (e.g. an English API demo) kept out of the catalog -->
<div class="api-notes" translate="no"> … </div>

The combination — build-time skip + runtime substitution — also enables a self-referential demo: a table whose cells contain literal catalog indices under translate="no" won't be re-indexed by gen_i18n, yet the runtime renders each index live (the live-demo's "Tradução" tab does exactly this).

Runtime mirror. At runtime, wings.TranslatableAttrs controls which attributes the engine passes through Printer. Its default matches the default gen_i18n set, and must mirror whatever flags produced the catalog, otherwise attribute values render as raw decimal indices:

// Option 1: assign a fresh slice (full override)
wings.TranslatableAttrs = []string{"title", "placeholder"}

// Option 2: incremental — safe with other packages that may also tweak it
wings.AddTranslatableAttrs("data-tip", "aria-placeholder")
wings.RemoveTranslatableAttrs("title")

The helpers are case-insensitive, trim whitespace, and skip duplicates. Both the assignment and the helper calls must run before wings.Main() finishes initialization (an init() in package main is the canonical spot).

Flex-block extraction. In the same pass, gen_i18n scans every {{...}} binding for flex sigils (@/%/~/#N — see next section). Each distinct block is assigned a numeric rule index, rewritten to its canonical {{@g %c ~word #N}} form in the .i18n.html, and emitted as a row in i18n/<deflang>.inflections.json (with a parallel .inflections.meta.json holding context/ctxdetail). Translator-maintained <lang>.inflections.json files are remapped in place across runs, same as the text catalog.

Auto-fill flags.

Flag Effect
--auto-flex Consult per-language dictionaries (.db files built by cmd/dictbuild) to auto-fill empty inflection cells. Output is tagged source: "dict:unitex-lingua" and flagged for human review.
--dict-dir <dir> Directory holding <lang>.db files. Default: cmd/gen_i18n/dicts under the WINGS module.
--auto-translate Use the LLM/MT backend configured in gen_i18n.json to pre-fill text and flex entries that the dictionary pass could not fill. All LLM output is tagged with the model name and flagged revised: false for human review.

Degenerate-deflang lint. When the deflang has no gender axis (e.g. en-US) but at least one target locale does (e.g. pt-BR), any flex block missing @var would collapse every row into a single gender column — leaving the gendered locale's translator with no way to supply masculine/feminine forms. gen_i18n emits a lint: warning on stderr pointing at the first occurrence of each such block, so the webdev can add the @<var> sigil before shipping.

Flexion — Plurals & Gender (SynPrinter)

Text catalogs work when one source string maps to exactly one translated string. They break down for grammars that inflect: English "1 student / 2 students" has two forms, Portuguese "1 aluno aprovado / 2 alunos aprovados / 1 aluna aprovada / 2 alunas aprovadas" has four, and Arabic has six CLDR plural categories per gender. WINGS's flex pipeline handles this by making the grammar-shaping variables visible to the runtime via inline sigils.

The sigils are selectors, not just morphology. @var selects a row by gender — exactly what Fluent expresses as { $gender -> [male] … [female] … *[other] … } — and %var selects the CLDR plural category for the count. The per-cell table (m.one, f.other, …) is the variant set those selectors choose from, so contextual selection by gender and number is a first-class feature, not a side effect. What WINGS adds on top is optional morphological auto-fill: gen_i18n --auto-flex pre-populates those cells from Unitex DELAF dictionaries (see cmd/dictbuild), so a translator reviews suggestions instead of hand-writing every gender×number form.

Template syntax. Inside a {{...}} binding, four sigils signal a flex block (as opposed to a plain reference):

Sigil Role Example
@var Gender axis — value is the row key (e.g. "m", "f"). Not emitted into the rendered string. {{@genero ...}}
%var Count axis — value is the integer count. Emitted at its position in the rule ({n} placeholder inside cells). {{%qt ...}}
~word Flex marker — a lemma that will be inflected by the translator. Consumed at build time only (gen_i18n uses it to suggest default forms from <lang>.db). {{... ~aluno ~aprovado}}
#N Rule index — injected by gen_i18n during rewriting; the webdev does not write this by hand. {{@genero %qt ~aluno #42}}

Path-based variables. Both @var and %var accept full reference paths — useful when the axis lives inside a struct or array element:

<!-- Gender from a user record, count from the current cart line -->
<p>{{ @user.gender %cart[idx].qty ~aluno aprovado }}</p>

The resolver falls back to the cheap single-level lookup for bare names (@genero, %qt) and routes path-bearing sigils through wings.Solve against the live data context.

Order matters inside the block. %var emits the count value where it appears in the rule, so placement controls the output. For "os 10 alunos" write ~o %qt ~aluno — placing %qt between the article and the noun. A verb that agrees with number must carry its own ~ (e.g. ~ganhou); omitting it leaves a singular verb glued to a plural subject.

Catalog schema. Like the text catalog, the inflections catalog is split across two parallel files.

i18n/<lang>.inflections.json — shipped to the browser:

[
  {
    "label":   "o {n} aluno aprovado ganhou uma bolsa",
    "revised": false,
    "cells": {
      "m.one":   "o {n} aluno aprovado ganhou uma bolsa",
      "m.other": "os {n} alunos aprovados ganharam uma bolsa",
      "f.one":   "a {n} aluna aprovada ganhou uma bolsa",
      "f.other": "as {n} alunas aprovadas ganharam uma bolsa"
    },
    "sources": {
      "m.one": "dict:unitex-lingua",
      "f.one": "llm:gemma4"
    }
  }
]

i18n/<lang>.inflections.meta.json — server-only, parallel-indexed:

[
  {
    "context":   "pages/result.html:12:8",
    "ctxdetail": "caption@pages/result.html:12:8"
  }
]
  • label — translator-facing stem (the non-sigil tokens from the original block), used as a visible placeholder when no matching cell is found.
  • cells — map keyed by <gender>.<cldr-category>. CLDR categories vary per locale (zero, one, two, few, many, other). Locales without a gender axis use a single empty-string gender key (.one, .other). {n} inside a cell is replaced by the numeric count at render time.
  • revised — same semantics as the text catalog flag.
  • sources — optional per-cell provenance map (same tag format as source in the text catalog). Omitted when empty.
  • context / ctxdetail (meta) — same format as the text .meta.json.

Runtime behavior. When wi18n is imported, it fetches <lang>.inflections.json in parallel with the main text catalog and installs wings.SynPrinter — a second printer hook invoked by the syncer on every flex block. SynPrinter resolves the gender and count variables from the live data context, computes the CLDR plural category for the current locale, and looks up cells["<gender>.<cat>"].

The fallback chain handles sparse catalogs:

  1. Explicit <gender>.zero wins when count is exactly 0 (useful in pt-BR where CLDR folds 0 into one).
  2. Empty zero cell → try <gender>.one.
  3. Any other empty cell → try <gender>.other.
  4. Still empty → render the rule Label (the translator-facing stem) as a visible placeholder, rather than blank.

Without wi18n loaded, the default wings.NoFlexSynPrinter renders the rule index as #N — missing inflection support stays obvious on the page instead of silently dropping content.

Programmable Flex — Custom Inflection Engines (CustomFlex)

The built-in @/% selectors plus dictionary auto-fill cover gender and number out of the box, zero-config. When an app needs inflection the catalog can't express — a remote morphology service, a domain-specific declension, a language the dictionaries don't cover — it can supply its own engine. The principle: WINGS shapes flexion at build time and integrates it at runtime; implementing the inflection becomes the app's responsibility. With no engine available the raw lemma is emitted (visible, not a silent drop) — by design.

Additional sigils. Three sigils extend the block grammar for custom engines:

Sigil Role
*var A CustomFlex engine — a candidate to inflect the block's lemmas, elected by Priority(). May also act as a selector.
$var A dynamic value emitted verbatim (not inflected) — e.g. a proper name interleaved in the sentence. Also exposed to the engine as a selector.
~$var A dynamic value to be inflected — a ~word whose text arrives at runtime.

~ means "inflect this", $ means "dynamic value", * means "engine" — they're orthogonal (~$var = ~ + $var). The engine is always * (a $ value is pure data and can't be a motor). Blocks must not be nested.

The contract (core wings package):

type CustomFlex interface {
    // Flex returns the inflected form of word given the context. Selector
    // order is not guaranteed — identify by Name/Sigil, never by position.
    Flex(word string, selectors ...FlexSelector) (string, error)
    String() string // text this token emits at its position ("" = selector only)
}
type Prioritized interface { Priority() uint } // optional; absent ⇒ 0
type FlexSelector struct { Sigil byte; Name string; Value any }

Engine election (one per block). Candidates are the *var / ~$var values that implement CustomFlex; the highest Priority() wins and the others become selectors passed to it. @gender / %count are selectors, never candidates. The built-in catalog is the implicit engine, used only when there is no custom candidate — so the zero-config path is unchanged. A tie between user candidates is an error: it logs and falls back to emitting the raw lemmas.

Build-time split. gen_i18n keys the catalog format on the sigils it sees. A block with only @/%/~word keeps the per-cell phrase catalog above. A block carrying */~$ is split into locale-invariant control metadata (the @gender / %count bindings, the *engine, the #N index) and a per-locale content template (literal text + $var/~$var/~word + the count position) that translators reorder freely — whitespace is preserved token-by-token, so a space-free script like Japanese composes correctly.

Async engines (the critical pattern). Flex is synchronous and runs inside the render epoch — it must not block on fetch (the same deadlock class as SetLang). For a remote source: return the cached form; on a miss return a placeholder (the raw value) and start the request in a goroutine; when it resolves, populate the cache and re-trigger the render (obj.This.Set). The live demo's RemoteFlexer demonstrates this end to end.

Failure modes — all visible, never silent: a tie between user engines, a ~$var left to the catalog-default engine (which has no runtime form for it), or a Flex that returns an error each emit the raw value and log. gen_i18n emits a soft lint: note when it sees ~$ without a * in a block.

By example — built-in vs custom

The live demo runs both kinds of block side by side. Comparing them shows where each one keeps its inflection knowledge.

Built-in (catalog-backed). Gender and number, looked up from a table:

<p>{{ @gender %qt #0 }}</p>
// The component supplies only the *values* the selectors read:
func (w *Demo) InitData() map[string]any {
    return map[string]any{"gender": "f", "qt": 2}
}

There is no inflection code. A translator fills the per-cell table (m.one, m.other, f.one, f.other, …) in <lang>.inflections.json — optionally pre-filled by gen_i18n --auto-flex from dictionaries — and at runtime WINGS picks the cell by gender + CLDR plural category. The forms exist at build time; runtime is an indexed lookup.

Custom (engine-backed). A lemma chosen at runtime, inflected by your code:

<p>{{ %qt *flexer ~$produto }}</p>
// A minimal synchronous engine: pluralise Portuguese by appending "s".
type PtPluralizer struct{}

func (PtPluralizer) String() string { return "" } // contributes no text of its own

func (PtPluralizer) Flex(word string, sel ...wings.FlexSelector) (string, error) {
    n := 1
    for _, s := range sel {
        if s.Sigil == '%' { // the count axis, arriving from %qt
            if v, ok := s.Value.(int); ok {
                n = v
            }
        }
    }
    if n == 1 {
        return word, nil
    }
    return word + "s", nil // toy rule — a real engine would handle irregulars
}

// Optionally implement Priority() uint to outrank other engines / the catalog.

func (w *Demo) InitData() map[string]any {
    return map[string]any{
        "qt":      1,
        "produto": "maçã",         // dynamic: e.g. bound to a <select>
        "flexer":  PtPluralizer{},  // the *flexer engine
    }
}

Here ~$produto is a lemma whose text is dynamic (the user picks it from a dropdown), so it cannot live in a build-time catalog; *flexer inflects it at runtime, and %qt is handed to the engine as a selector.

Built-in (@/% + ~word) Custom (*engine + ~$var)
Who provides the forms translator (catalog cells) + optional dict auto-fill app developer (engine code)
When build time → indexed lookup at runtime runtime
Best for fixed phrases, known lemmas, gender/number dynamic lemmas, remote services, rules a table can't hold
When absent renders the Label stem renders the raw value

The engine above is synchronous. For a backend-driven engine — where Flex cannot block on the network — see the demo's RemoteFlexer: it returns the raw word as a placeholder on a cache miss, fetches in a goroutine, and re-runs the render (obj.This.Set) once the form arrives.

Arbitrary selection (coming from Fluent)

Selection in a flex block is not limited to gender and number. Every participant is handed to the engine as a FlexSelector, so an engine can branch on an arbitrary key — the role Fluent gives an arbitrary selector:

# Fluent
shortcut = { $platform ->
    [macos]  ⌘ + C
   *[other]  Ctrl + C
}

In WINGS that selection lives in a CustomFlex engine. The variants can be a plain JSON table — shaped like the Fluent selector above, though an engine is free to store its data however it likes:

{ "copy": { "macos": "⌘ + C", "*": "Ctrl + C" } }
type Shortcuts struct{ table map[string]map[string]string }

func (s Shortcuts) String() string { return "" }
func (s Shortcuts) Priority() uint  { return 10 }

func (s Shortcuts) Flex(word string, sel ...wings.FlexSelector) (string, error) {
    platform := "*"
    for _, sl := range sel {
        if sl.Sigil == '$' && sl.Name == "platform" { // the $platform selector
            if p, ok := sl.Value.(string); ok && p != "" {
                platform = p
            }
        }
    }
    if v, ok := s.table[word][platform]; ok {
        return v, nil
    }
    return s.table[word]["*"], nil
}
<p>{{ *help Copy on $platform uses ~$action }}</p>

With "platform": "macos", "action": "copy", "help": myShortcuts this renders "Copy on macos uses ⌘ + C". Note that $platform plays two roles at once (like %count): it is emitted in the sentence and handed to the engine as a selector. The surrounding words remain ordinary catalog i18n (translated per locale) while the engine resolves only the variant — so selection composes with translation. And because the same engine inflects ~word/~$var, WINGS unifies selecting a variant with generating one, where Fluent selects among pre-authored variants only. See the live demo's PlatformHelper.

Message reuse (=name / #name)

The last piece Fluent has that a per-block syntax otherwise lacks is one message referencing another — define a phrase once, reuse it in many places so the translator edits a single entry:

# Fluent
-product = { $count } apples
cart    = In your cart: { -product }
receipt = Payment for { -product } confirmed

WINGS expresses this with two sigils that reuse the # you already know:

  • =name defines: it names this block's message. The block still renders in place — naming is additive.
  • #name uses: it renders the named message here. This unifies #: #42 is "the message at index 42" (the literal index gen_i18n injects), and #name is "the message at the index bound to name" — literal vs. variable, the same distinction the rest of WINGS draws.
<!-- define once (and render it here) -->
<p>{{ =cart %count *flexer ~$product }}</p>

<!-- reuse anywhere -->
<p>In your cart: {{ #cart }}.</p>
<p>Payment for {{ #cart }} confirmed.</p>

Names are global per catalog: a #name in any file resolves a =name in any other file, in any order. Resolution happens entirely at build timegen_i18n rewrites #name to the definer's #N and emits a plain runtime block, so the browser sees nothing new and reuse works for both catalog (gender×number) and programmable messages.

A reuse site inherits the definer's control axes and may override them per slot: an @gender or %count declared at the site wins (otherwise the definer's is inherited), and declaring any *engine replaces the inherited engines wholesale. The message content (~word/$var/~$var) always comes from the definition — so each reference resolves its variables in the context where it appears, which is strictly more than Fluent (whose message references are static; only its terms take arguments):

<!-- inherits *flexer and %count from =cart -->
<p>{{ #cart }}</p>

<!-- same message, but counted by a different variable at this site -->
<p>{{ #cart %backorder }}</p>

A literal = glued to a word (price=value) would otherwise read as =value; write == to escape it. A #name with no matching =name is left verbatim and logged — a visible, fixable degradation rather than a silent blank. See the live demo's reuse section in flextab.html.

Locale-Aware Formatting (FmtPrinter)

Text catalogs translate strings and flex blocks resolve plurals/gender. A third axis — values — needs locale-aware treatment too: numbers use different decimal and grouping separators across locales, currencies have their own symbols and fractional digits, dates span a zoo of formats. The FmtPrinter pipeline handles all of these through the same sigil already used for the count axis of flex blocks: %var.

Template syntax. When a {{...}} binding contains exactly one %var (optionally followed by a path tail), it is a format block — the value is resolved from the data context and handed to wings.FmtPrinter, which picks the rendering based on the Go type of the value:

<!-- Plain numeric value (locale separators) -->
<p>Total: {{%count}} unidades</p>

<!-- Nested path -->
<p>Saldo: {{%user.balance}}</p>

<!-- Array element — identical semantics to a plain reference path -->
<td>{{%invoices[idx].total}}</td>

A %var that shares a {{...}} with any of @var, ~word, #N, or other literal tokens is interpreted as a flex block count axis instead (see the previous section) — the lone-%var rule is the only ambiguity cleared at parse time, and it falls on the common case.

Type-directed rendering. wi18n's FmtPrinter dispatches on the value's Go type in this order:

Type Output
nil empty string
int, int64, uint, uint64, etc. Intl.NumberFormat integer (grouping per locale)
float32, float64 Intl.NumberFormat default precision
time.Time Intl.DateTimeFormat (epoch ms bridge)
js.Value holding a JS Date Intl.DateTimeFormat direct
anything implementing wi18n.Numerical v.Format(locale, formatName)(string, error); error stops rendering
anything else fmt.Sprint fallback

wi18n uses the browser's Intl API rather than bundling locale tables (ICU/CLDR) inside the WASM binary — keeps the artifact small and honors the browser's tz database for dates.

Numerical interface — customization without registries. Any Go type that implements the following interface is treated as a first-class formattable value:

type Numerical interface {
    Format(locale, formatName string) (string, error)
}

There is no registration step; satisfying the interface is the registration. formatName comes from the %var:formatName template syntax (empty string when the template uses bare %var).

Returning a non-nil error stops rendering for that binding: FmtPrinter discards the returned string, logs the error with locale/formatName context, and emits an empty string. Returning ("", nil) produces an empty string without triggering the error path. The implementation may log domain-level detail before returning the error; FmtPrinter adds the locale/formatName context on top.

wi18n.Currency — built-in example of Numerical.

type Currency struct {
    Amount int64  // smallest unit (centavos, cents, yen-units)
    Code   string // ISO 4217 (BRL, USD, JPY, BHD)
}

Currency implements Numerical. The amount is stored as a signed int64 in the currency's minor unit — no float rounding surprises on financial data — and the ISO code travels with the value so multi-currency templates work by iterating a []Currency naturally:

// mod/invoice/invoice.go
data := map[string]any{
    "lines": []wi18n.Currency{
        {Amount: 123450, Code: "BRL"}, // R$ 1.234,50 in pt-BR
        {Amount: 9999,   Code: "USD"}, // $99.99 in en-US
    },
}
<!-- mod/invoice/invoice.html -->
<tr *lines:i><td>{{%lines[i]}}</td></tr>

Applications with a single fixed currency typically wrap Currency in a helper:

func BRL(n int64) wi18n.Currency { return wi18n.Currency{Amount: n, Code: "BRL"} }

Or define their own domain type implementing Numerical and delegating to Currency internally — that is exactly the pattern the built-in physical measure packages follow (see Physical Measure Packages).

An ISO 4217 table (embedded in wi18n/currency_iso4217.go) decides the number of fractional digits — most currencies use 2, with documented exceptions for zero-decimal (JPY, KRW, VND, …), three-decimal (BHD, KWD, …), and four-decimal (CLF, UYW) cases.

Fallback behaviour. Without wi18n imported, wings.FmtPrinter stays as the default NoFmtFmtPrinter, which renders values via fmt.Sprint — locale-incorrect but never blank. When wi18n is loaded but the browser's Intl rejects a locale/currency combination (or the environment has no Intl at all), each formatter falls back to a locale-agnostic rendering (strconv, plain decimal point, RFC 3339 for dates) so the page stays readable.

Named formats (%var:formatName). The :formatName suffix is interpreted differently depending on the value type:

  • Numerical types (measure packages, Currency, custom types): formatName is passed as the second argument to Format; the implementation decides what to do with it — measure packages treat it as a unit override ({{%dist:km}}), Currency ignores it.
  • Native scalars (int, float64, etc.) and time.Time / JS Date: fmtPrinter looks up formatName in the current locale's <lang>.fmt.json "named" section and, if found, renders through the corresponding Intl.NumberFormat or Intl.DateTimeFormat ({{%count:compact}}). If the name is not configured, rendering falls back to the default locale-aware behavior and formatName is silently ignored.

The suffix is optional: bare {{%dist}} passes an empty string.

Per-locale <lang>.fmt.json. A file placed next to the text catalog (e.g. i18n/en-US.fmt.json) carries two optional entry shapes loaded automatically by wi18n when the locale is switched:

{
  "km":      { "decimals": 2 },
  "mi":      { "decimals": 3 },
  "compact": { "type": "number", "notation": "compact" },
  "short":   { "type": "date",   "dateStyle": "short"  }
}

Entries with a "type" field are named scalar formats: every key except "type" is forwarded verbatim as an Intl option object. "type": "number" builds an Intl.NumberFormat; "type": "date" builds an Intl.DateTimeFormat. The resulting formatter is cached per (locale, name) and reused across renders.

Entries without "type" are unit-precision overrides ("decimals" key): wi18n.UnitDecimals(locale, unit) returns the value, and measure packages consult it before their built-in default. wi18n.NamedFmt(locale, name) exposes named scalar entries to callers that need the raw Intl options.

wi18n.SetConfig — app-level unit overrides. Call wi18n.SetConfig(jsonBytes) at startup (e.g. from an embedded wings.json) to set per-locale default units for any quantity:

{ "measures": { "pt-BR": { "length": "km" }, "en-US": { "length": "mi" } } }

wi18n.MeasureDefault(quantity, locale) returns the configured unit, or ("", false) when none is set. Measure packages consult this before falling back to their built-in locale heuristics.

Physical Measure Packages

Eight packages under wi18n/ model common physical quantities. Each stores the value in a canonical SI unit and implements wi18n.Numerical (the //go:build js && wasm Format method is in a separate file so the pure-Go math is testable on the host):

Package Type Canonical Unit names
wi18n/length Length{Meters float64} m m km cm mm mi ft yd in nmi league
wi18n/temperature Temperature{Kelvin float64} K k/kelvin c/celsius f/fahrenheit r/rankine
wi18n/speed Speed{MetersPerSecond float64} m/s ms kmh mph kn fps
wi18n/volume Volume{Liters float64} L L mL dL m3 floz pt qt gal galimp
wi18n/weight Weight{Kilograms float64} kg kg g mg t lb oz st
wi18n/area Area{SquareMeters float64} m2 km2 cm2 mm2 ha mi2 ft2 yd2 in2 ac
wi18n/fueleconomy FuelEconomy{LitersPer100km float64} L/100 km l100km mpg mpgimp kml
wi18n/cooking Volume{Liters float64} / Weight{Kilograms float64} L / kg vol: L mL cup tbsp tsp floz · wt: kg g lb oz

Template usage. The data key holds a value of the measure type; the template uses %var (locale default) or %var:unit (explicit unit):

// mod/trip/trip.go
func (t *Trip) InitData() map[string]any {
    return map[string]any{
        "dist": length.Length{Meters: 42195},
        "temp": temperature.Temperature{Kelvin: 310.15},
    }
}
<!-- locale default -->
<td>{{%dist}}</td>

<!-- explicit unit override -->
<td>{{%dist:km}}</td>
<td>{{%dist:mi}}</td>
<td>{{%temp:f}}</td>

Locale defaults. Each package's DefaultUnit(locale) applies common regional conventions: lengthmi for en-US/en-GB/en-LR/my, temperaturef for en-US/en-BS/en-BZ/en-KY, speedmph for the same imperial group, volumegal for en-US, weightlb for en-US, fueleconomympg for en-US and mpgimp for en-GB. All other locales get SI/metric defaults.

Unit name constraints. Unit identifiers contain only ASCII letters and digits — no / or - — so they are safe as %var:unit tokens in templates (kmh not km/h, floz not fl-oz, ms not m/s).

fueleconomy inverse units. mpg, mpgimp, and kml are inversely proportional to LitersPer100km. Format and Convert return a non-nil error when LitersPer100km ≤ 0 for these units (avoids +Inf).

cmd/dictbuild & cmd/dictlookup — Flexion Dictionaries

These two tools convert a Unitex/GramLab DELAF .dic (UTF-16 text) into a compact Go-native lookup structure used to pre-fill plural/gender flexions during the build.

dictbuild has two invocation modes:

  • dictbuild -lang <tag> — fetch mode. Downloads the compiled .bin/.inf for the requested locale from UnitexGramLab/unitex-lingua, shallow-clones UnitexGramLab/unitex-core at a pinned tag (currently v3.3), builds UnitexToolLogger from source with make UNITEXTOOLLOGGERONLY=yes 64BITS=yes, expands the compiled dictionary back to UTF-16 text via Uncompress, and parses the result. Persistent state (cloned tools, compiled binary, cached .bin/.inf) lives under -state-dir (defaults to $XDG_CACHE_HOME/wings/dictbuild). Pass -tool /path/to/UnitexToolLogger to skip the auto-build if you already have the binary. Output: <tag>.db in -out (defaults to .). Linux/macOS only — Windows requires a manual MSVC build.
  • dictbuild [-out <dir>] <input.dic> <tag> — legacy mode for users who already have a UTF-16 DELAF on hand (e.g. produced by Unitex/GramLab desktop or pulled from an internal mirror).

The auto-fetch table covers every language directory in unitex-lingua that ships at least one usable DELAF .bin/.inf pair: de, el, en, es, fi, fr, grc, it, la, mg, nn, no, oge, pl, pt-BR, pt-PT, ru, sr-Cyrl, sr-Latn, th, zh. For each locale the most general/inflected dictionary upstream provides was chosen; some entries are samples or partial dictionaries (el's "30percent", nn's "Dela-sample", grc's demo) because that is all upstream ships. ar (separate noun and verb DELAFs) and ko (empty upstream Dela/) are intentionally not listed. The dictbuild parser is PT-centric in its verb-class aliasing (see aliasHomograph), so <lang>.db for non-Portuguese locales is structurally correct but may need language-specific tweaks on the gen_i18n side to interpret verbal classes.

In both modes the tool applies DELAF-specific filters:

  • +Pr (proper names) → dropped
  • +PRO (enclitic pronoun) → dropped
  • Imperative forms (code Y*) → dropped
  • Finite 1st/2nd person verbal forms → dropped (only 3rd person, infinitive, gerund, and participle are needed for count/gender agreement in templates)

Each entry is decomposed into three axes — Class (tense/mood stem), Genre (m/f/n/empty), Count (s/p/empty). The resulting shape:

type Dict struct {
    Lemmas    map[string]*Lemma   // canonical form → grouped inflections
    FormIndex map[string][]FormRef // surface form → refs back into Lemmas
}
type FormRef struct { Lemma, Class, Genre, Count string }
type Lemma   struct {
    Category string                // "N", "V", "A", "ADV", ...
    Forms    map[string]Inflect    // key = Class+Genre+Count, e.g. "ms", "fp"
}
type Inflect struct { DiffPos int; Suffix string } // DiffPos in runes

dictlookup <file.db> <word> is a human-readable inspector. It prints every FormIndex hit for the queried word, resolves each hit to its parent Lemma, and reconstructs the surface form of every kept inflection so the output needs no post-processing.

$ dictlookup pt-BR.db passou
loaded pt-BR.db: 128430 lemmas, 984712 form entries

FormIndex["passou"] → 1 reference(s):
  [0] Lemma="passar" Class="J" Genre="" Count="s"
      passar (V)
        W:    passar       (infinitive)
        G:    passando     (gerund)
        K:ms  passado      ...
        ...

helpers/wlate — Translation Editor GUI

helpers/wlate/ is a wings-built WASM app designed for translators to review and edit catalogs side-by-side with a reference language.

Features (implemented):

  • Two-panel layout: reference language (read-only) vs. target language (editable), with per-entry "revised" toggle (bordeaux left border = not yet reviewed; gray = reviewed).

  • Two tabs: Texto (plain strings) and Inflexões (gender × CLDR plural category grid, using CSS Grid with display: contents on iteration wrappers).

  • Keyboard shortcuts for navigation and toggling revised state (all rebindable via wings.json).

  • Filter toggle: show only unrevised entries.

  • Unsaved-changes guard (in-app dialog + beforeunload).

  • On-save file creation: if the target catalog doesn't exist, wlate creates it mirroring the reference structure with empty content and revised: false.

  • JSON schemas:

    // i18n/<lang>.json — browser bundle (content + provenance)
    [{"content": "...", "revised": false, "source": "llm:gemma4"}]
    
    // i18n/<lang>.meta.json — server-only, parallel-indexed
    [{"context": "pages/result.html:12:8",
      "ctxdetail": "th@pages/result.html:12:8<br/>button[title]@pages/result.html:40:17"}]
    
    // i18n/<lang>.inflections.json
    // Sigil order matters: %qt emits the number where it appears, so place
    // it AFTER ~o and BEFORE ~aluno for "os 10 alunos" (not "10 os alunos").
    // Verbs that conjugate with number (~ganhou/ganharam) need the ~ too —
    // missing a ~ on a number-agreeing verb causes "10 alunos ganhou" bugs.
    [{"label": "o {n} aluno aprovado ganhou uma bolsa", "revised": false,
      "cells": {"m.one": "...", "f.other": "..."},
      "sources": {"m.one": "dict:unitex-lingua"}}]
    
    // i18n/<lang>.inflections.meta.json — server-only, parallel-indexed
    [{"context": "...", "ctxdetail": "caption"}]
    

Collaboration & review come from your VCS. Translation and inflection catalogs are plain files in your repo tree, so branch / PR / merge / blame / review apply to them exactly as to code — no separate TMS to provision or sync. A translator works on a branch, opens a pull request, and reviewers diff the JSON like any other change. wlate adds the editing ergonomics on top: per-entry approval (revised), per-cell provenance (translator email when OAuth2 is configured), and integrated MT/LLM pre-fill (--auto-translate). The design premise is deliberate — a team that versions its code already has the workflow translations need, and a team that doesn't version its code won't want a heavier localization pipeline either.

Self-i18n. wlate itself is built as a translatable WINGS app — the editor eats its own dog food. Its templates live under helpers/wlate/mod/wlate/, build.sh runs gen_i18n against that tree before compiling the WASM, and the resulting catalogs are published to dist/wlate-i18n/ so they don't collide with the /i18n/* route used for the project being translated. On startup, main.go calls wi18n.SetBasePath("wlate-i18n/") to point the runtime fetch at that directory. Source language is pt-BR; en-US ships fully translated, es-CO ships as a pt-BR copy with revised=false for human review.

Build and run:

cd helpers/wlate
bash build.sh                 # runs gen_i18n, copies catalogs, builds WASM
go run serve.go <project-dir> # starts the mini-server (see below)

server.conf — mini-server configuration

serve.go is both a dev server and a production-capable mini-server. If a file named server.conf sits next to the executable, it is parsed as key=value lines (comments starting with # and blank lines allowed; values may be single- or double-quoted). Without server.conf, the server keeps its original defaults (listens on :8080, plain HTTP, no auth).

Basic keys:

Key Effect
cert=<name> Enables TLS. Loads <name>.crt (or <name>.pem) from the executable's directory; if the file doesn't contain the private key, also loads <name>.key. Fatal on any load error.
listen=<address> Address passed verbatim to net.Listen("tcp", …). Fatal on bind error.
root=<path> Overrides the positional <project-dir> argument.

OAuth2 / OIDC keys (all required together when any is set):

Key Effect
oauth2_issuer=<url> OIDC issuer URL. Discovery via /.well-known/openid-configuration; the provider must expose userinfo_endpoint.
oauth2_client_id=<id> Client ID registered with the provider.
oauth2_client_secret=<s> Client secret.
oauth2_redirect_url=<url> Absolute callback URL; typically https://<host>/oauth2/callback.
oauth2_allowed=<path> Optional. Path to a text file listing one allowed e-mail per line (blank lines and # comments ignored). If omitted, any authenticated user is allowed.

When OAuth2 is configured, every non-/oauth2/* request is gated: GET requests without a valid session cookie redirect to the provider's authorization endpoint; other methods return 401. The callback exchanges the code for an access token, fetches the e-mail from userinfo, checks the allowlist, and issues a wlate_session cookie (HttpOnly, SameSite=Lax, Secure when served over TLS, 12 h TTL). Sessions are stored in-memory. GET /oauth2/logout invalidates the session cookie.

Example server.conf:

# Bind and TLS
listen=:8443
cert=wlate

# Project root (overrides CLI arg)
root=/var/lib/wlate/mytranslations

# Gated access via Google OIDC
oauth2_issuer=https://accounts.google.com
oauth2_client_id=1234567890-abc.apps.googleusercontent.com
oauth2_client_secret=GOCSPX-…
oauth2_redirect_url=https://wlate.example.com/oauth2/callback
oauth2_allowed=/etc/wlate/allowed.txt
# /etc/wlate/allowed.txt — one e-mail per line
translator1@example.com
translator2@example.com

OAuth2 support uses only the Go standard library (no go-oidc / x/oauth2 dependencies).

Component Lifecycle

  1. Registration (init()): Module calls wings.Register() to register the custom element tag, template, CSS, factory function, and observed attributes.

  2. Construction (automatic): When the browser encounters the custom element tag, the constructor creates a shadow root, injects CSS, parses the template, calls InitData() for initial state, and sets up data bindings.

  3. Connection (automatic): When the element is inserted into the DOM, connectedCallback fires and sets the ready flag.

  4. Render (automatic): Once connected, Render(obj) is called with the PranaObj containing:

    • obj.This*ReactiveData for reading/writing state
    • obj.Domjs.Value of the container SPAN in the shadow root
    • obj.Elementjs.Value of the custom element itself
    • obj.Trigger — function to dispatch events to the parent component
  5. Attribute Changes (automatic): When an observed attribute changes, the new value is copied into the data map and a sync is triggered.

  6. Disconnection (automatic): When the element is removed from the DOM, event handlers and bindings are cleaned up.

Parent-Child Communication

Passing Data Down (Attributes)

The parent passes data to children via attributes with {{expression}} bindings:

<!-- Parent template -->
<my-child
    title="{{page_title}}"
    is_logged="{{is_logged}}"
    is_anonymous="{{is_anonymous}}"
></my-child>

Data flows one-way: parent to child. When the parent's data changes, the child's attributes are updated automatically. To communicate from child back to parent, use Triggers.

Dispatching Events Up (Trigger)

Children fire named events that invoke handler functions in the parent. The @ attribute in the parent's template maps event names to handler names:

Parent template (app.html):

<!-- @login maps event "login" to handler function "on_login" -->
<my-login @login="on_login"></my-login>

Parent code (app.go):

func (app *App) InitData() map[string]any {
    return map[string]any{
        // Placeholder — obj not available yet
        "on_login": wings.TriggerHandler(nil),
    }
}

func (app *App) Render(obj *wings.PranaObj) {
    // Real handler with obj in scope
    obj.This.Set("on_login", func(args ...any) {
        obj.This.Set("is_logged", true)
    })
}

Child Render (login.go):

func (lgn *Login) Render(obj *wings.PranaObj) {
    // Trigger uses the event name (without @), not the handler name
    obj.Trigger("login", username)  // matches @login in parent template
}

The flow is: obj.Trigger("login") -> looks up @login attribute on the child element -> finds "on_login" -> resolves on_login in the parent's data map -> calls the function.

Syntax Quick-Reference

Prefix Name Example Description
{{ }} Binding {{user.name}} Display a data value. Updates automatically on change.
{{#}} Hash {{#}} Current URL hash fragment. Updates on hashchange.
? Conditional ?is_admin Show/hide element based on truthiness.
?! Negated ?!is_loading Show element only when value is falsy.
?="val" Equality ?status="active" Show element only when value equals "val".
?!="val" Inequality ?status!="deleted" Show element only when value does not equal "val".
?^="val" Prefix ?url^="https" Show element only when value starts with "val".
?$="val" Suffix ?name$=".pdf" Show element only when value ends with "val".
?*="val" Contains ?tags*="urgent" Show element only when value contains "val".
* Iteration *items:i Repeat element for each array item (wrapped in <span>).
** Iteration (no wrap) **items:i Repeat first child for each item (container stays).
& Two-way &value="{{val}}" Sync <input> / <select> / <textarea> with data.
@ Event @click="on_save" Dispatch child event to parent handler function.
@var Flex gender (i18n) {{@genero ~o %qt ~aluno}} Gender axis in a flexion block. Value keys the <gender>.<category> row.
%var Flex count (i18n) {{%qt ~aluno}} Count axis in a flexion block. Emitted at its position; drives CLDR plural category.
%var (lone) Format (i18n) {{%preco}} Locale-aware formatting. Type-directed: ints/floats, time.Time, wi18n.Currency, or any Numerical.
~word Flex stem (i18n) {{~aluno ~aprovado}} Build-time marker for a word the translator will inflect. Consumed by gen_i18n.
#N Flex rule index (i18n) {{@genero %qt #42}} Auto-assigned by gen_i18n when rewriting .i18n.html. Webdev never writes this.

Important Notes

Attribute Names Are Lowercased

HTML attribute names are always converted to lowercase by the browser. This means template variables used in attributes (?condition, &attr, @event) must use lowercase names only. Use snake_case for multi-word identifiers. The ! suffix used for inequality (?var!="val") is preserved by the browser since only letters are lowercased:

// CORRECT
"is_logged":    false,
"is_anonymous": true,

// WRONG - will not match because browser lowercases attributes
"isLogged":    false,
"isAnonymous": true,
<!-- CORRECT -->
<my-child ?is_logged is_anonymous="{{is_anonymous}}"></my-child>

<!-- WRONG -->
<my-child ?isLogged isAnonymous="{{isAnonymous}}"></my-child>

Note: variables used only in text content ({{camelCase}}) are not affected by this restriction, since they are parsed from text nodes, not attributes. However, for consistency, snake_case is recommended everywhere.

The build orchestrator catches this for you: go run ./cmd/build <target> scans every template and fails the build if a binding name (?cond, *arr, **arr, &attr) contains an uppercase letter, with the offending file:line — so a camelCase binding can never ship as a silent no-op.

Template Root Element

If your template has multiple top-level elements, WINGS automatically wraps them in a <span>. For predictable styling, consider using a single root element:

<!-- Multiple roots (auto-wrapped in span) -->
<header>...</header>
<main>...</main>

<!-- Single root (no wrapper needed) -->
<div>
    <header>...</header>
    <main>...</main>
</div>

Full Example

main.go

//go:build js && wasm

package main

import (
    "github.com/luisfurquim/wings"
    _ "myapp/mod/mywidget"
)

func main() {
    wings.Main()
}

mod/mywidget/mywidget.go

//go:build js && wasm

package mywidget

import (
    _ "embed"
    "syscall/js"

    "github.com/luisfurquim/wings"
    "github.com/luisfurquim/wings/dom"
    "github.com/luisfurquim/wings/timer"
)

//go:embed mywidget.html
var htmlContent string

//go:embed mywidget.css
var cssContent string

type MyWidget struct{}

func init() {
    wings.Register(
        "my-widget",
        htmlContent,
        cssContent,
        func() wings.PranaMod { return &MyWidget{} },
        "title",
    )
}

func (w *MyWidget) InitData() map[string]any {
    return map[string]any{
        "title":      "wings live demo",
        "count":      0,
        "count2":     0,
        "items":      []any{},
        "show_extra": false,
        "extra":      "This is extra content toggled by a boolean conditional.",
        "input_val":  "",
        "mode":       "edit",
    }
}

func (w *MyWidget) Render(obj *wings.PranaObj) {
    // Default to #list page if no hash fragment
    if js.Global().Get("location").Get("hash").String() == "" {
        wings.GoTo("list")
    }

    // Populate items
    obj.This.Set("items", []any{
        map[string]any{"label": "Alpha"},
        map[string]any{"label": "Beta"},
        map[string]any{"label": "Gamma"},
    })

    // Keep input_val in sync on every keystroke
    inputs := dom.Query(obj.Dom, "input[type=\"text\"]")
    if len(inputs) > 0 {
        dom.AddEvent(inputs[0], "input",
            func(this js.Value, args []js.Value) any {
                obj.This.Set("input_val", inputs[0].Get("value").String())
                return nil
            }, false, false)
    }

    // Form submit: add item
    forms := dom.Query(obj.Dom, "form")
    if len(forms) > 0 {
        dom.AddEvent(forms[0], "submit",
            func(this js.Value, args []js.Value) any {
                val := obj.This.Get("input_val").(string)
                if val != "" {
                    obj.This.Append("items", map[string]any{"label": val})
                    obj.This.Set("input_val", "")
                }
                return nil
            }, true, false)
    }

    // Toggle mode button
    toggleBtns := dom.Query(obj.Dom, "#btn-toggle-mode")
    if len(toggleBtns) > 0 {
        dom.AddEvent(toggleBtns[0], "click",
            func(this js.Value, args []js.Value) any {
                mode := obj.This.Get("mode").(string)
                if mode == "edit" {
                    obj.This.Set("mode", "readonly")
                } else {
                    obj.This.Set("mode", "edit")
                }
                return nil
            }, false, false)
    }

    // Toggle extra button
    extraBtns := dom.Query(obj.Dom, "#btn-toggle-extra")
    if len(extraBtns) > 0 {
        dom.AddEvent(extraBtns[0], "click",
            func(this js.Value, args []js.Value) any {
                show := obj.This.Get("show_extra").(bool)
                obj.This.Set("show_extra", !show)
                return nil
            }, false, false)
    }

    // Navigation links
    navList := dom.Query(obj.Dom, "#nav-list")
    if len(navList) > 0 {
        dom.AddEvent(navList[0], "click",
            func(this js.Value, args []js.Value) any {
                wings.GoTo("list")
                return nil
            }, true, false)
    }
    navDash := dom.Query(obj.Dom, "#nav-dash")
    if len(navDash) > 0 {
        dom.AddEvent(navDash[0], "click",
            func(this js.Value, args []js.Value) any {
                wings.GoTo("dashboard")
                return nil
            }, true, false)
    }

    // Page 1 counter: every 2 seconds
    go func() {
        tk := timer.NewTicker(2000)
        defer tk.Stop()
        n := 0
        for range tk.Tick {
            n++
            obj.This.Set("count", n)
        }
    }()

    // Page 2 counter: every 5 seconds
    go func() {
        tk := timer.NewTicker(5000)
        defer tk.Stop()
        n := 0
        for range tk.Tick {
            n++
            obj.This.Set("count2", n)
        }
    }()
}

mod/mywidget/mywidget.html

<div class="widget">
   <h1>{{title}}</h1>

   <!-- Navigation -->
   <nav>
      <a id="nav-list" href="#list">List</a>
      <a id="nav-dash" href="#dashboard">Dashboard</a>
   </nav>

   <!-- Toolbar -->
   <div class="toolbar">
      <button id="btn-toggle-mode">Toggle Mode</button>
      <button id="btn-toggle-extra">Toggle Extra</button>
      <span class="mode-badge">Mode: <strong>{{mode}}</strong></span>
   </div>

   <!-- Page 1: List -->
   <div ?#="list" class="page page-list">
      <h2>Item List</h2>
      <p>Auto-counter (every 2s): <span class="counter">{{count}}</span></p>

      <!-- Boolean conditional -->
      <div ?show_extra class="extra-box">
         <p>{{extra}}</p>
      </div>

      <!-- Negated boolean conditional -->
      <div ?!show_extra class="extra-hint">
         <p>Click "Toggle Extra" to reveal extra content.</p>
      </div>

      <!-- Equality conditional: only in edit mode -->
      <div ?mode="edit">
         <form>
            <input &value="{{input_val}}" type="text" placeholder="Add item..." />
            <input type="submit" value="Add" />
         </form>
      </div>

      <!-- Inequality conditional: hidden when readonly -->
      <div ?mode!="readonly" class="edit-hint">
         <p>Form is visible because mode != "readonly".</p>
      </div>

      <!-- Prefix conditional -->
      <div ?mode^="ed" class="cond-demo">
         <p>Prefix match: mode starts with "ed"</p>
      </div>

      <!-- Suffix conditional -->
      <div ?mode$="it" class="cond-demo">
         <p>Suffix match: mode ends with "it"</p>
      </div>

      <!-- Contains conditional -->
      <div ?mode*="d" class="cond-demo">
         <p>Contains match: mode contains "d"</p>
      </div>

      <ul>
         <li *items:i>{{items[i].label}}</li>
      </ul>
   </div>

   <!-- Page 2: Dashboard -->
   <div ?#="dashboard" class="page page-dash">
      <h2>Dashboard</h2>
      <p>Slow counter (every 5s): <span class="counter counter-lg">{{count2}}</span></p>

      <div class="dash-grid">
         <div class="dash-card">
            <h3>Items</h3>
            <ul>
               <li *items:i>{{items[i].label}}</li>
            </ul>
         </div>
         <div class="dash-card">
            <h3>Status</h3>
            <p>Mode: <strong>{{mode}}</strong></p>
            <p>Extra visible: <strong>{{show_extra}}</strong></p>
            <p>Fast counter: <strong>{{count}}</strong></p>
         </div>
      </div>

      <!-- Boolean conditional on dashboard too -->
      <div ?show_extra class="extra-box">
         <p>{{extra}}</p>
      </div>
   </div>
</div>

mod/mywidget/mywidget.css

.widget {
   max-width: 600px;
   margin: 24px auto;
   padding: 24px;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
   color: #1a1a2e;
   background: #fff;
   border-radius: 12px;
   box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
h1 { margin: 0 0 12px; font-size: 1.4em; color: #16213e; }
nav { display: flex; gap: 8px; margin-bottom: 16px; border-bottom: 2px solid #e8e8e8; padding-bottom: 12px; }
nav a { padding: 6px 16px; border-radius: 6px; text-decoration: none; color: #0f3460; font-weight: 600; background: #e8f0fe; cursor: pointer; }
nav a:hover { background: #c5d9f7; }
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.toolbar button { padding: 6px 14px; border: 1px solid #ccc; border-radius: 6px; background: #f4f4f9; cursor: pointer; }
.toolbar button:hover { background: #dde; }
.mode-badge { font-size: 0.85em; color: #555; }
h2 { margin: 0 0 12px; font-size: 1.15em; color: #0f3460; }
.counter { display: inline-block; background: #0f3460; color: #fff; padding: 2px 10px; border-radius: 4px; font-weight: 700; }
.counter-lg { font-size: 2em; padding: 8px 20px; border-radius: 8px; }
.extra-box { background: #e8f5e9; border-left: 4px solid #4caf50; padding: 8px 12px; border-radius: 4px; margin: 10px 0; }
.extra-hint { background: #fff3e0; border-left: 4px solid #ff9800; padding: 8px 12px; border-radius: 4px; margin: 10px 0; font-style: italic; }
.cond-demo { background: #ede7f6; border-left: 4px solid #7e57c2; padding: 6px 12px; border-radius: 4px; margin: 6px 0; font-size: 0.9em; }
form { display: flex; gap: 8px; margin: 12px 0; }
input[type="text"] { flex: 1; padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; }
input[type="submit"] { padding: 6px 16px; background: #0f3460; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
ul { list-style: none; padding: 0; }
li { padding: 6px 10px; background: #f9f9fb; border-radius: 4px; margin-bottom: 4px; border: 1px solid #eee; }
.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
.dash-card { background: #f4f4f9; border-radius: 8px; padding: 12px; border: 1px solid #e0e0e0; }
.dash-card h3 { margin: 0 0 8px; font-size: 1em; color: #16213e; }

Security

Shadow DOM isolation

Every custom element registered with Register uses an open shadow root (mode: "open") by default. This means any same-page script can access the component's internal DOM via element.shadowRoot.

Relevant advisories: CVE-2019-11730 (Firefox same-origin bypass via shadow DOM adoption), GHSA-wh77-3x4m-4q9g (Lit SSR shadow DOM template injection).

If you need stronger DOM isolation, use RegisterWithOpts and set ComponentOpts.Closed = true:

wings.RegisterWithOpts("my-widget", html, css,
    wings.ComponentOpts{Closed: true},
    func() wings.PranaMod { return &MyWidget{} },
)

With Closed: true the shadow root's mode is "closed" — external scripts get null from element.shadowRoot. The WINGS runtime itself accesses the shadow root via the reference stored at construction time, so all internal features (CSS injection, Update(), data binding) continue to work normally.

Trade-offs of closed mode:

  • Testing tools that query the shadow root via element.shadowRoot will break and must be refactored to use events, attributes, or test IDs.
  • DevTools in some browsers show closed shadow roots with limited visibility.
  • Closed mode is not an absolute security barrier — slotted content, CSS custom properties, and custom events still cross shadow boundaries.

innerHTML invariant

domCreateTemplate(html) sets template.innerHTML internally. The html argument must be a compile-time constant (typically a //go:embed string). Never pass runtime, user-supplied, or server-fetched content to this function — innerHTML is a DOM XSS sink.

WASM memory isolation

wasm_exec.js by default stores the Go runtime object as window.go, which exposes the WASM linear memory buffer to any same-origin script. The index.html templates in this repository wrap the instantiation in an async IIFE so go never reaches window:

(async () => {
    const go = new Go();
    const r = await WebAssembly.instantiateStreaming(fetch("wings.wasm"), go.importObject);
    go.run(r.instance);
})().catch(console.error);

Copy this pattern in your own index.html.

Script integrity (SRI)

build.sh injects integrity="sha384-..." attributes on the <script> tags for prana_helper.js and wasm_exec.js after every build. This prevents a compromised CDN or build pipeline from silently substituting modified helpers.

Catalog signing (ed25519)

gen_i18n can sign every .json catalog with an ed25519 key. The WASM app embeds the matching public key and the wi18n loader verifies each catalog before using it.

Generate a keypair (run once per project):

gen_i18n -genkey -sign-key-password <strong-password>

This writes gen_i18n.ed25519.key (encrypted private key) and gen_i18n.ed25519.pub (public key for embedding).

Sign catalogs on every run:

gen_i18n --path . --deflang en-US \
         -sign-key gen_i18n.ed25519.key \
         -sign-key-password <password>

A .json.sig sidecar is written next to each published catalog: the main <lang>.json, every emitted <lang>.inflections.json, and any hand-authored <lang>.fmt.json found in the i18n directory.

Verify in your WASM app:

//go:embed gen_i18n.ed25519.pub
var catalogPubKey []byte

func main() {
    if err := wi18n.SetCatalogPublicKey(catalogPubKey); err != nil {
        log.Fatal(err)
    }
    wings.Main()
}

Enforcement is opt-in and, once on, strict. With no public key configured, verification is skipped and no .sig sidecar is even fetched (backward compatible). Once a key is configured, every catalog the app loads MUST carry a valid .sig: a missing sidecar is treated as tampering and the catalog is rejected — the loader never falls through to an unsigned, possibly forged catalog. This covers the main <lang>.json and the optional <lang>.inflections.json / <lang>.fmt.json: an optional file that does not exist is fine (nothing to verify), but one that loads must verify.

A rejection surfaces as *wi18n.CatalogSignatureError in the SetLang error callback, so the app can tell tampering apart from ordinary load failures (missing catalog, parse error):

wi18n.SetLang(next, func(err error) {
    var sigErr *wi18n.CatalogSignatureError
    if errors.As(err, &sigErr) {
        // signature policy failure — warn loudly
    }
})

The live-demo's locale-switcher shows the recommended wiring: on error it logs, reverts its <select> to the still-active wings.Locale, and fires an @error trigger so the hosting app can react declaratively (<locale-switcher @error="fnLocaleError"> opening a <w-dialog>).

Third-party data

cmd/dictbuild can download linguistic dictionaries from the UnitexGramLab/unitex-lingua repository. These dictionaries are distributed under the Lesser General Public License for Linguistic Resources (LGPLLR). They are not stored in this repository — they are fetched on demand by dictbuild -lang <tag> and cached locally on the developer's machine. WINGS's source code is not affected by the LGPLLR.

Copyright and per-language modification notices are in cmd/gen_i18n/dicts/NOTICE.md.

If you use gen_i18n -auto-plurals: the generated *.inflections.json files are derivative works of the LGPLLR-licensed dictionaries. Before deploying them on your site, copy NOTICE-TEMPLATE.md into your project's NOTICE file and fill in the URL where your i18n/ directory is accessible. Users who fill plural forms by hand are not affected.

Logo & credits

The WINGS logo (assets/logo.png) is a winged adaptation of the Go gopher. The Go gopher was designed by Renée French and is licensed under the Creative Commons Attribution 3.0 license. This derivative keeps that attribution.

The illustration was produced with AI assistance and is provided for use as the WINGS project mascot only. It is a separate asset from the source code: it is not covered by the project's MPL 2.0 license, and (as an AI-generated work) no independent copyright is claimed over it.

"Go" and the Go gopher are associated with the Go project / Google; WINGS is an independent project and is not affiliated with or endorsed by them.

License

This project is licensed under the Mozilla Public License 2.0.

Documentation

Rendered for js/wasm

Index

Constants

View Source
const (
	TokTxt       = expr.TokTxt
	TokRef       = expr.TokRef
	TokStr       = expr.TokStr
	TokDot       = expr.TokDot
	TokOpen      = expr.TokOpen
	TokClose     = expr.TokClose
	TokNum       = expr.TokNum
	TokIdent     = expr.TokIdent
	TokWSep      = expr.TokWSep
	TokExpr      = expr.TokExpr
	TokAttr      = expr.TokAttr
	TokPctVar    = expr.TokPctVar
	TokAtVar     = expr.TokAtVar
	TokTildeWord = expr.TokTildeWord
	TokFlexIdx   = expr.TokFlexIdx
	TokColon     = expr.TokColon
)

CategoryAll is the OR of every category currently defined. New categories appended above are automatically included.

View Source
const DepthSkinCategories = CategoryDepth

DepthSkinCategories is the bitmask for skins controlling the metric component of shadows (offsets/blur/spread). Built-in: flat, lifted, floating. The colour rgba of every shadow comes from the active Identity skin via `--wings-shadow-color-*`.

View Source
const GeometrySkinCategories = CategoryGeometry | CategorySpacing

GeometrySkinCategories is the bitmask for skins controlling shape and density: corner radius, border width/style, padding, gap. The three built-in geometry skins (sharp / classic / soft) all share this mask.

IdentitySkinCategories is the conventional bitmask for a chromatic theme — one that defines colors, gradients, focus-ring and shadow-color tokens. The eight built-in themes (light, dark, autumn, …) all use this mask, so they are mutually exclusive among themselves but coexist with focused skins covering orthogonal categories (Geometry, Depth, Motion, Spacing, Atmosphere).

View Source
const MotionSkinCategories = CategoryMotion

MotionSkinCategories is the bitmask for skins controlling motion: transition durations/easing, hover-lift, active-scale. Built-in: gentle, calm, brisk.

Variables

View Source
var (
	// ErrUserCategoryRange is returned by UserCategory when the index is
	// outside [0, 16).
	ErrUserCategoryRange = errors.New("wings: user category index out of range [0,16)")
	// ErrNotUserCategory is returned by RegisterCategoryName when the bit is
	// not a single category within the user-reserved range.
	ErrNotUserCategory = errors.New("wings: not a single user-reserved category bit")
)

Errors returned by the user-category API.

View Source
var FmtPrinter func(val any, locale, formatName string) string = NoFmtFmtPrinter

FmtPrinter formats a single value into its locale-appropriate string representation. It is called by the solver for lone-`%var` bindings (FmtBlock), invoked with the resolved value, the current Locale, and the format name extracted from the `%var:formatName` template syntax (empty string when the template uses bare `%var`).

The default is NoFmtFmtPrinter, a locale-agnostic `fmt.Sprint` passthrough. When wi18n is imported, its init installs a type-switching implementation that routes native ints/floats through Intl.NumberFormat, time.Time through Intl.DateTimeFormat, and any value implementing wi18n.Numerical through its Format(locale, formatName) method. A non-nil error from Numerical.Format stops rendering: the binding emits "" and the error is logged with locale/formatName context.

G is the global logger for the package. Recommended levels: 1=errors only, 2=general, 3=detail, 4=light debug, 5=verbose debug, 6=sensitive debug.

InitWG lets side-effect packages (e.g. wi18n) delay Main() until their own asynchronous initialization has finished. Any package that needs to run work before DefineAll() should call InitWG.Add(1) in its init() and InitWG.Done() when its work is complete. Main() calls InitWG.Wait() before defining the custom elements. If nothing registers, Wait() returns immediately.

View Source
var Locale string

Locale is the BCP 47 language tag currently in effect for locale-aware rendering (FmtPrinter, SynPrinter). Empty until a catalog-backed package (e.g. wi18n) detects the browser language and assigns it. Reading an empty Locale is safe — FmtPrinter implementations fall back to a locale-agnostic rendering in that case.

View Source
var NodeAnnotator func(rawIndex string, node js.Value)

NodeAnnotator is called by translateTextNodes and applyStashSweep after each node is translated. rawIndex is the original TextNode content (the decimal integer string written by gen_i18n). node is the DOM node that was translated: a Text node (nodeType==3) for text content, or an Element node (nodeType==1) for attribute translations. The default nil means no annotation. wi18n installs a function here when TranslateCheckHighlight is active.

View Source
var SynPrinter func(toks []RefNode, ctx Ctx) string = NoFlexSynPrinter

SynPrinter resolves a flexion reference block (e.g. `{{@genero %qt #42}}`) at sync time. It receives the raw flex token slice (the inner part of the `{{...}}` block) and the current data context stack, and returns the rendered string for the current locale.

The default is NoFlexSynPrinter, which emits a best-effort literal rendering so pages without wi18n still show something sensible. When wi18n is imported, its init installs a catalog-backed implementation that performs CLDR plural resolution against the loaded inflections catalog.

View Source
var TranslatableAttrs = []string{"title", "placeholder", "alt", "aria-label", "data-i18n", "expect"}

TranslatableAttrs lists the element attributes whose values should be passed through Printer at construction time. Must mirror the effective attribute set gen_i18n was run with; the default matches gen_i18n's default. Apps can assign a new slice directly to override, or call AddTranslatableAttrs / RemoveTranslatableAttrs to tweak the list.

Functions

func ActiveSkin

func ActiveSkin() string

ActiveSkin returns the most recently activated skin, or "" when no skin is active. Kept for compatibility with callers that assume a single active skin (e.g. simple combobox UIs).

func ActiveSkins

func ActiveSkins() []string

ActiveSkins returns the names of currently active skins, in activation order (oldest first).

func AddTranslatableAttrs

func AddTranslatableAttrs(attrs ...string)

AddTranslatableAttrs appends attrs to TranslatableAttrs, skipping those already present. Attribute names are compared case-insensitively.

func ApplySkin

func ApplySkin(name string) error

ApplySkin activates name. If a skin with the same name is already active, ApplySkin is a no-op and returns nil.

Returns *SkinNotRegisteredError if name is unknown, or *SkinConflictError if any active skin shares a category bit with name's declared categories.

func ByPass

func ByPass(in string) string

ByPass is the default Printer: it returns its input unchanged.

func ClearSkin deprecated

func ClearSkin()

ClearSkin is a deprecated alias for ClearSkins, kept so legacy single-skin callers do not break.

Deprecated: use ClearSkins.

func ClearSkins

func ClearSkins()

ClearSkins deactivates every active skin.

func ConflictsWith

func ConflictsWith(categories SkinCategory) []string

ConflictsWith returns the names of active skins whose categories overlap categories (i.e. activating a new skin with categories would fail because of these). The list is in activation order.

func DeactivateSkin

func DeactivateSkin(name string) error

DeactivateSkin removes name from the set of active skins. Returns nil whether or not name was active. Returns *SkinNotRegisteredError only when name is not a registered skin name.

func DefineAll

func DefineAll()

DefineAll defines all custom elements registered via Register(). Must be called once in main() after all modules have been imported.

func GoTo

func GoTo(frag string)

GoTo sets window.location.hash to the given fragment. This triggers the "hashchange" event, which in turn updates hashFragment and syncs all component instances.

func JSFunc

func JSFunc(fn func(this js.Value, args []js.Value) any) js.Func

JSFunc creates a js.Func that remains active until manually released. The caller is responsible for calling Release() when no longer needed.

func JSFuncOnce

func JSFuncOnce(fn func()) js.Func

JSFuncOnce creates a js.Func that auto-releases after being called once. Useful for setTimeout/setInterval callbacks without memory leaks.

func JSGlobal

func JSGlobal() js.Value

JSGlobal returns js.Global() for use in subpackages and modules.

func ListSkins

func ListSkins() []string

ListSkins returns the registered skin names (unsorted).

func Main

func Main()

Main must be called from main() to keep the WASM alive and define the custom elements. Blocks indefinitely.

Before defining the modules, Main() waits on InitWG. This allows side-effect packages (e.g. wi18n) to register asynchronous initialization via InitWG.Add(1) in their init() and InitWG.Done() when ready.

func NoFlexSynPrinter

func NoFlexSynPrinter(toks []RefNode, _ Ctx) string

NoFlexSynPrinter is the default SynPrinter: since no inflections catalog is loaded, it returns the rule index in `#N` form as a visible marker — missing translations stay obvious instead of rendering blank.

func NoFmtFmtPrinter

func NoFmtFmtPrinter(val any, _ string, _ string) string

NoFmtFmtPrinter is the default FmtPrinter. With no locale-aware backend loaded, it renders values via fmt.Sprint so pages still show something reasonable — just not locale-correct.

func OnSkinChange

func OnSkinChange(fn func())

OnSkinChange registers fn to be invoked after every successful ApplySkin / DeactivateSkin / ClearSkins. Used by the skinswitcher widget to keep its UI in sync with programmatic activations.

The callback runs synchronously on the JS goroutine; do not block.

func Printer

func Printer(s string) string

Printer calls the active printer function. Installed packages (e.g. wi18n) replace the default ByPass via SetPrinter; all other callers use this wrapper.

func Register

func Register(tagName, htmlContent, cssContent string, factory ModFactory, observed ...string)

Register registers a Go web component to be defined as a custom element. Must be called from within func init() in the module's package.

tagName     - name of the custom element (e.g. "my-widget")
htmlContent - HTML content of the template (usually via //go:embed)
cssContent  - CSS content of the component (usually via //go:embed)
factory     - function that creates a new instance of PranaMod
observed    - names of attributes to be observed (attributeChangedCallback)

func RegisterCategoryName added in v0.14.3

func RegisterCategoryName(bit SkinCategory, name string) error

RegisterCategoryName assigns a human-readable name to a user-reserved category bit (one returned by UserCategory). The name then appears in SkinCategory.Names()/String() and therefore in the <skin-switcher> UI. Calling it again for the same bit overwrites the previous name. Returns ErrNotUserCategory if bit is not a single bit within the user-reserved range.

func RegisterCheck added in v0.15.6

func RegisterCheck(name string, fn CheckFunc)

RegisterCheck registers a named assertion for <w-test check="name">. Re-registering an existing name overwrites the previous entry and logs a level-1 warning.

func RegisterSkin

func RegisterSkin(name string, categories SkinCategory, css string)

RegisterSkin registers a named skin with its category bitmask and CSS payload. Call from init() in the skin package.

Re-registering an existing name overwrites the previous entry and logs a level-1 warning.

func RegisterWTest added in v0.15.6

func RegisterWTest(self js.Value, probe WTestProbe)

RegisterWTest registers a <w-test> card's probe so it appears in the page report. The entry is dropped automatically when the element disconnects.

func RegisterWithOpts

func RegisterWithOpts(tagName, htmlContent, cssContent string, opts ComponentOpts, factory ModFactory, observed ...string)

RegisterWithOpts is like Register but accepts ComponentOpts for additional configuration (e.g. Closed shadow DOM mode).

func RemoveTranslatableAttrs

func RemoveTranslatableAttrs(attrs ...string)

RemoveTranslatableAttrs deletes attrs from TranslatableAttrs. Attribute names are compared case-insensitively; missing entries are ignored.

func RetranslateAll

func RetranslateAll()

RetranslateAll re-applies the active Printer to every live custom element instance, re-parses the resulting strings into TextSegs / AttrBinding.Segs, and triggers a re-render. It is the runtime entry point used by wi18n.SetLang() to flip the catalog without rebuilding the DOM.

The walk relies on the _wi18nSrc / _wi18nAttr_* expandos planted by translateTextNodes (and propagated by cloneNode → copyTranslateStash). Nodes without a stash are left untouched, so non-i18n text and bindings are preserved verbatim.

func RunCheck added in v0.15.6

func RunCheck(name string, ctx CheckCtx) (pass bool, detail string, found bool)

RunCheck runs the named check against ctx. found is false when no check is registered under name (the <w-test> widget treats that as a configuration error, distinct from a check that ran and failed).

func SetPrinter

func SetPrinter(fn func(string) string, token []byte)

SetPrinter installs fn as the active Printer. token must be the value previously returned by TakePrinterToken(). Panics if the token is invalid. Idempotent: subsequent calls from the same authorized holder are no-ops once the printer is installed (so SetLang() re-calls do not panic).

func Solve

func Solve(tree []RefNode, ctx any, fullCtx Ctx) any

Solve walks the reference tree and resolves the value against ctx. fullCtx is the full context stack, used to resolve sub-expressions (e.g.: the index [fi] in filtered_options[fi]) that may be in different layers from where the root identifier was found.

func SolveAll

func SolveAll(segs []TextSegment, ctx Ctx) string

SolveAll walks all segments, resolves references and concatenates. Searches in ctx (context stack) until a non-nil value is found. Equivalent to the solveAll() from the original JS.

func TakePrinterToken

func TakePrinterToken() []byte

TakePrinterToken returns the one-time authorization token required by SetPrinter. It can only be called once; subsequent calls return nil. Intended for use by github.com/luisfurquim/wings/wi18n only.

func Update

func Update(tagName string, cssContent string)

Update replaces the CSS of an already registered custom element and updates the <style> in the Shadow DOM of all live instances. Must be called by Customizable modules when ReplaceCSS is invoked.

Types

type AttrBinding

type AttrBinding struct {
	Segs      []TextSegment
	ForceSync bool      // attribute with & prefix
	PureRef   []RefNode // non-nil if the binding is a pure reference (two-way binding)
}

AttrBinding stores the bindings of a single attribute.

type CSSPart

type CSSPart struct {
	Name    string
	Content string
}

CSSPart represents a named CSS section of a component. The order of CSSParts matters: Vars must come before Design, because Design may use variables defined in Vars.

type Change

type Change struct {
	Delete *DeleteInfo
}

Change describes a data mutation for optimized sync.

type CheckCtx added in v0.15.6

type CheckCtx struct {
	Subject js.Value     // the slotted element <w-test> wraps
	Dom     js.Value     // node to query (subject's light DOM / shadow root)
	Events  []CheckEvent // events captured so far, in fire order
}

CheckCtx is what a CheckFunc inspects: the wrapped subject element, the DOM node to query against, and the ordered log of events the subject fired.

type CheckEvent added in v0.15.6

type CheckEvent struct {
	Name string
	Args []any
}

CheckEvent is one event the subject fired, as recorded by <w-test>. Name is the event name (e.g. "save"); Args are the trigger arguments minus the event name that the @all channel prepends.

type CheckFunc added in v0.15.6

type CheckFunc func(CheckCtx) (pass bool, detail string)

CheckFunc is a registered assertion. It returns whether the test passes and a short detail string shown next to the seal (the reason on failure, or a note on success).

type CheckResult added in v0.15.6

type CheckResult struct {
	Kind   string `json:"kind"`
	Label  string `json:"label"`
	State  string `json:"state"`
	Detail string `json:"detail,omitempty"`
}

CheckResult is one entry in the page test report produced by RunReport and marshalled to JSON by <w-test-report>: either a <w-test> card (Kind "w-test", including human-judged visual cards) or a module-declared self-test (Kind "testable"). State is "pass", "fail", or "pending" (a visual card not yet judged). Label is the card title, or "tag/check" for a testable.

func RunReport added in v0.15.6

func RunReport() []CheckResult

RunReport collects the full test result for the page: every <w-test> card (including the human-judged visual ones, in whatever state the tester left them) followed by every check declared by mounted Testabler modules. So a tester can run all the tests, judge the visual ones by eye, and deliver one report of what passed and what failed with a single click. wings produces the report only — transporting or persisting it (POST to a server, write a file, diff in CI) is the app's call, via the <w-test-report> "report" event.

type ComponentOpts

type ComponentOpts struct {
	// Closed makes the shadow root use mode:"closed", preventing external scripts
	// from accessing the component's internals via element.shadowRoot.
	// See README.md §Security — CVE-2019-11730, GHSA-wh77-3x4m-4q9g.
	Closed bool
}

ComponentOpts configures optional behavior for a custom element.

type Ctx

type Ctx []any

Ctx is a data context stack used in reference resolution.

type CustomFlex added in v0.15.0

type CustomFlex interface {
	// Flex returns the inflected form of word given the other block
	// participants. The order of selectors is NOT guaranteed — identify each
	// by Name/Sigil, never by position.
	Flex(word string, selectors ...FlexSelector) (string, error)
	// String returns the text this value emits at its own position in the
	// block ("" = pure selector, emits nothing; e.g. a count selector returns
	// the formatted number).
	String() string
}

CustomFlex is the contract for programmable inflection inside a flex block.

The built-in flex pipeline (gender × CLDR-plural cells, filled at build time by gen_i18n) covers the zero-config case. CustomFlex is the opt-in escape hatch for inflection that cannot be precomputed — arbitrary selection axes beyond gender/count, or dynamic values inflected at runtime (possibly via a backend call). A value reachable from the data context through a `*var` (engine/selector) or `~$var` (flexbind) sigil must implement CustomFlex.

Roles inside a block:

  • As the elected engine, Flex is called once per word to inflect (each static `~word`, and each `~$var` value), receiving every other participant as a FlexSelector. The engine is elected by Priority (see Prioritized); the implicit catalog engine wins only when no CustomFlex value is present.
  • As a (losing) selector, the value still contributes String() at its position and is passed to the winning engine, which may consult it.

Contract notes:

  • Flex MUST be synchronous. For async sources (REST, etc.) return a cached value or a placeholder immediately and trigger a re-sync when the real value arrives (the sync model re-runs Flex on the next pass) — blocking on a fetch deadlocks the JS event loop, exactly as for SetLang.
  • Flex MUST read wings.Locale at call time (never cache the locale): a SetLang switches Locale and re-syncs in place.
  • A non-nil error renders the input word verbatim plus a log entry, so a failure stays visible rather than blanking the node.

type Customizable

type Customizable interface {
	PranaMod
	ListCSS() []CSSPart
	ReplaceCSS(key string, content string)
}

Customizable is an optional interface that modules can implement to allow consuming applications to change parts of the CSS. Modules that satisfy only PranaMod have fixed CSS; modules that satisfy Customizable allow replacement of CSS sections (e.g.: swap only the color variables, keeping the layout).

type DOMRefNode

type DOMRefNode struct {
	Type     TokenType               // TokTxt = text node; TokAttr = element
	TextSegs []TextSegment           // text node segments
	Attrs    map[string]*AttrBinding // attribute bindings
	Children map[int]*DOMRefNode     // child bindings (by index)
	ArrayVar string                  // array iteration control variable (empty if none)
	ArrayIdx string                  // array iteration index variable
	NoSpan   bool                    // ** prefix: model is the parent itself
	ModelRef *DOMRefNode             // child template ref for ** (noSpan)
	Cond     string                  // conditional expression (empty if none)
	CondTree []RefNode               // parsed tree of the condition
	CondOp   string                  // conditional operator: "" = truthy, "eq" = equality, "neq" = inequality
	CondVal  string                  // literal value for comparison (used with CondOp "eq" or "neq")
}

DOMRefNode describes the template bindings for a DOM node.

type DeleteInfo

type DeleteInfo struct {
	Target []any // target slice (reference, not copy)
	Index  int
}

DeleteInfo describes the removal of an array element.

type FlexBlock

type FlexBlock = expr.FlexBlock

type FlexSelector added in v0.15.0

type FlexSelector struct {
	Sigil byte
	Name  string
	Value any
}

FlexSelector is one resolved participant of a flex block, handed to CustomFlex.Flex as context. Sigil is the originating marker ('@', '%', '*', or '$'); Name is the variable path as written in the template (e.g. "gender", "user.tier", "cart[i].qty"); Value is the value resolved from the live data context.

type KeyStorage

type KeyStorage interface {
	Set(key string, val any) error
	Get(key string, outval any) error
	Del(key string) error
	Exists(key string) (bool, int64, error)
}

KeyStorage defines a key-value storage backend that accepts arbitrary Go values. Implementations are responsible for serializing values (typically via an Encoder/Decoder pair).

type ModFactory

type ModFactory func() PranaMod

ModFactory creates a new instance of PranaMod.

type NodeState

type NodeState struct {
	// For array iteration plug nodes
	Model  js.Value
	ACtrl  string
	AIndex string
	Tree   []RefNode

	// For conditional nodes (when replaced by a comment)
	CondModel js.Value // the original element (stored while there is a comment)
	CondDaddy js.Value // parent for conditional restoration

	// For component roots
	State     *PranaState
	PRoot     js.Value
	EHandlers map[string]string

	// For bidirectional bindings (indexed by attribute name)
	TwoWay map[string]*TwoWayBinding

	// Shadow root reference — populated for all components; required when the
	// component uses mode:"closed" (element.shadowRoot returns null in that case).
	ShadowRoot js.Value

	// Render lifecycle — cancel stops the waitAndRender goroutine; RenderDone
	// is closed when that goroutine exits, enabling safe async cleanup.
	CancelRender context.CancelFunc
	RenderDone   chan struct{}
}

NodeState stores the Go-side state for DOM nodes managed by prana. It is indexed by the _pranaId field of the JS node.

type ObservedData

type ObservedData struct {
	M map[string]any
	// contains filtered or unexported fields
}

OnChange creates an external observer on a data map. The callback fn is called with ("S"=set/"D"=delete, target, property, value). Equivalent to the prana.onChange() from the original JS. Returns an *ObservedData that encapsulates the data with notification.

func OnChange

func OnChange(data map[string]any, fn func(op string, target map[string]any, property string, value any)) *ObservedData

func (*ObservedData) Delete

func (o *ObservedData) Delete(key string)

func (*ObservedData) Set

func (o *ObservedData) Set(key string, val any)

type PranaMod

type PranaMod interface {
	InitData() map[string]any
	Render(obj *PranaObj)
}

PranaMod is the interface that every Go web component must implement.

  • InitData() returns the initial data map (equivalent to the "return {...}" in JS).
  • Render(obj) is called after connection to the DOM (equivalent to the ready.then(...) in JS).

type PranaObj

type PranaObj struct {
	This    *ReactiveData
	Dom     js.Value // SPAN in the shadow root
	Element js.Value // the custom element itself
	Trigger func(eventName string, args ...any)
}

PranaObj is passed to the module's Render method.

type PranaState

type PranaState struct {
	Data      *ReactiveData
	Refs      *DOMRefNode
	ForceSync bool
	MaySync   bool
	// contains filtered or unexported fields
}

PranaState holds the reactive state of a bound component.

type Prioritized added in v0.15.0

type Prioritized interface {
	Priority() uint
}

Prioritized is an optional companion to CustomFlex. When a flex block holds more than one engine-capable CustomFlex value, the one with the highest Priority is elected the engine. A CustomFlex that does not implement Prioritized counts as priority 0. The implicit catalog engine is below every user value, so any single user engine wins; a tie between user engines is an error (logged, with a verbatim fallback).

type ReactiveData

type ReactiveData struct {
	M map[string]any
	// contains filtered or unexported fields
}

ReactiveData encapsulates the data map with change notification. Mutations via Set/Delete/Append/DeleteAt trigger automatic sync.

func (*ReactiveData) Append

func (r *ReactiveData) Append(key string, val any)

Append adds an element to the array at key and triggers sync.

func (*ReactiveData) Delete

func (r *ReactiveData) Delete(key string)

Delete removes key and triggers sync.

func (*ReactiveData) DeleteAt

func (r *ReactiveData) DeleteAt(key string, idx int)

DeleteAt removes the element at index idx from the array at key and triggers sync.

func (*ReactiveData) Get

func (r *ReactiveData) Get(key string) any

Get returns the value of key.

func (*ReactiveData) Set

func (r *ReactiveData) Set(key string, val any)

Set sets the value of key and triggers sync. For nested objects, pass map[string]any.

func (*ReactiveData) SetAt

func (r *ReactiveData) SetAt(key string, idx int, val any)

SetAt sets the element at index idx of the array at key and triggers sync.

func (*ReactiveData) Sync

func (r *ReactiveData) Sync()

Sync manually triggers a DOM re-synchronization without any specific data change. Useful after direct mutations to r.M.

type RefNode

type RefNode = expr.RefNode

type SkinCategory

type SkinCategory uint64

SkinCategory is a bitmask describing which design dimensions a skin touches. Each skin declares its set of categories at registration time; two skins can be active simultaneously only when their bitmasks are disjoint (the AND is zero). This lets a "complete theme" (e.g. mushroom — cores + geometria + profundidade + …) compose with a focused skin (e.g. glass — apenas atmosfera).

The 64-bit width leaves room for new categories without breaking the API. The bit space is partitioned like IANA address space: the low bits (0–8 used today) are the official, framework-owned categories and grow upward as new built-ins are appended; the high 16 bits (48–63) are permanently reserved for application- or library-defined private categories — see CategoryUserMask, UserCategory and RegisterCategoryName. The middle bits (9–47) are headroom for future built-ins. A built-in category will never be assigned a bit in the user-reserved range, so private categories stay forward-compatible across WINGS upgrades.

const (
	CategoryIdentity    SkinCategory = 1 << iota // colors, surfaces, text, primary, secondary, borders, button colors, shadow-color
	CategoryGeometry                             // radius scale, border width/style
	CategoryDepth                                // shadow shapes (offsets/blur/spread); the colour comes from Identity
	CategoryMotion                               // transition durations/easing, hover-lift, active-scale
	CategoryInteraction                          // focus-ring (chromatic feedback)
	CategoryTypography                           // font-family, font-size, font-weight (reserved)
	CategorySpacing                              // padding/gap density
	CategoryLighting                             // gradients, glows, gradient-shadow
	CategoryAtmosphere                           // glass-opacity, surface-blur, surface-noise
)

Categories. Each bit identifies one design dimension. New categories MUST append at the end so existing bitmasks keep their meaning.

The split between adjacent categories is deliberate: any token whose VALUE is a colour belongs to the chromatic categories (Identity, Lighting, Interaction). Tokens whose value is a metric (px, ms, ratio, transform) belong to the structural/temporal categories (Geometry, Spacing, Depth, Motion). Shadows are split: the shape (offsets+blur+spread) is Depth; the colour rgba is Identity (via `--wings-shadow-color-*`). Widgets read the composed `--wings-shadow-*` produced by Depth skins via `var()`.

const CategoryNone SkinCategory = 0

CategoryNone is the empty bitmask (no categories).

const CategoryUserMask SkinCategory = ((1 << userCategoryCount) - 1) << userCategoryShift

CategoryUserMask is the set of all user-reserved category bits (48–63). Test whether a mask carries any private category with `c & wings.CategoryUserMask != 0`.

func ActiveCategories

func ActiveCategories() SkinCategory

ActiveCategories returns the OR of every active skin's bitmask.

func SkinCategoriesOf

func SkinCategoriesOf(name string) (SkinCategory, bool)

SkinCategoriesOf returns the bitmask declared by name, or (CategoryNone, false) if the skin is not registered.

func UserCategory added in v0.14.3

func UserCategory(n uint) (SkinCategory, error)

UserCategory returns the n-th user-reserved category bit, with n in the range [0, 16). The single-bit mask it returns is guaranteed never to collide with a built-in category, so it is the safe way to mint a private design dimension. Returns ErrUserCategoryRange if n >= 16; the caller decides whether to treat that as fatal.

func init() {
    brand, err := wings.UserCategory(0)
    if err != nil { panic(err) } // the app's call, not the library's
    wings.RegisterCategoryName(brand, "Brand")
    wings.RegisterSkin("acme", brand, acmeCSS)
}

func (SkinCategory) Conflicts

func (c SkinCategory) Conflicts(other SkinCategory) bool

Conflicts reports whether c and other share any bit (i.e. they cannot be active simultaneously).

func (SkinCategory) Has

func (c SkinCategory) Has(other SkinCategory) bool

Has reports whether c contains every bit set in other.

func (SkinCategory) Names

func (c SkinCategory) Names() []string

Names returns the names of the categories present in c, in declaration order. Unknown bits (set but not named) are skipped.

func (SkinCategory) String

func (c SkinCategory) String() string

String returns "Identity|Geometry|…" — the names of the categories present in c joined by "|". Returns "None" when c is empty.

type SkinConflictError

type SkinConflictError struct {
	Name                  string       // the skin that failed to activate
	Categories            SkinCategory // its declared categories
	Conflicts             []string     // names of active skins that share bits
	ConflictingCategories SkinCategory // the OR of all colliding bits
}

SkinConflictError is returned by ApplySkin when activating a skin would overlap one or more categories already covered by an active skin. The caller can inspect Conflicts (the colliding active skin names) and ConflictingCategories (the bits that overlap) to surface a precise message in the UI without re-deriving the comparison.

func (*SkinConflictError) Error

func (e *SkinConflictError) Error() string

type SkinInfo

type SkinInfo struct {
	Name       string
	Categories SkinCategory
}

SkinInfo is the public, immutable view of a registered skin.

func ListSkinInfos

func ListSkinInfos() []SkinInfo

ListSkinInfos returns every registered skin with its declared categories. Unsorted; the caller may sort by Name as needed.

type SkinNotRegisteredError

type SkinNotRegisteredError struct {
	Name string
}

SkinNotRegisteredError is returned by ApplySkin and DeactivateSkin when the named skin has not been registered via RegisterSkin.

func (*SkinNotRegisteredError) Error

func (e *SkinNotRegisteredError) Error() string

type Testabler added in v0.15.6

type Testabler interface {
	Testable() map[string]CheckFunc
}

Testabler is the optional interface a module implements to expose named integration checks about itself. Each check sees a CheckCtx whose Subject and Dom are the live element (Events is empty — event-stream assertions belong in a <w-test> wrapper, which spies via the @all channel).

type TextSegment

type TextSegment = expr.TextSegment

type TokenType

type TokenType = expr.TokenType

type TriggerHandler

type TriggerHandler func(...any)

TriggerHandler is the function type used as a handler for @ events. Use the nil literal (TriggerHandler(nil)) as a placeholder in InitData; define the actual body in Render, where obj is available.

type TwoWayBinding

type TwoWayBinding struct {
	Ref     []RefNode
	CtxPtr  *Ctx    // updated on each sync; handler closure points here
	Handler js.Func // JS handler; must be Released when the element is removed
}

TwoWayBinding holds the state of a bidirectional binding (input/select/textarea).

type WTestProbe added in v0.15.6

type WTestProbe func() (title, state, detail string)

WTestProbe reports a <w-test> card's current title, seal state ("pass", "fail", or "pending"), and detail. The widget registers one via RegisterWTest.

Directories

Path Synopsis
cmd
build command
Command build is the WINGS build orchestrator.
Command build is the WINGS build orchestrator.
dictbuild command
dictbuild converts a Unitex/GramLab DELAF dictionary (UTF-16 text) into a compact two-layer lookup structure (Lemmas + reverse FormIndex) used by gen_i18n at build time to auto-populate the plural/flexion CSVs for the i18n pipeline.
dictbuild converts a Unitex/GramLab DELAF dictionary (UTF-16 text) into a compact two-layer lookup structure (Lemmas + reverse FormIndex) used by gen_i18n at build time to auto-populate the plural/flexion CSVs for the i18n pipeline.
dictlookup command
dictlookup inspects a <lang>.db produced by cmd/dic2tree.
dictlookup inspects a <lang>.db produced by cmd/dic2tree.
gen_i18n command
gen_i18n/translator
Package translator provides a backend-agnostic interface for machine translation of wings i18n catalog entries.
Package translator provides a backend-agnostic interface for machine translation of wings i18n catalog entries.
Package codec provides a default Encoder/Decoder for common Go types.
Package codec provides a default Encoder/Decoder for common Go types.
Package dom provides event management and DOM query helpers for wings.
Package dom provides event management and DOM query helpers for wings.
Package localstorage provides typed access to browser localStorage with pluggable serialization.
Package localstorage provides typed access to browser localStorage with pluggable serialization.
Package location provides helpers for reading browser location URLs.
Package location provides helpers for reading browser location URLs.
Package message provides a generic mechanism for sending messages to and receiving replies from a Service Worker.
Package message provides a generic mechanism for sending messages to and receiving replies from a Service Worker.
Package opfs provides key-value storage backed by the Origin Private File System (OPFS).
Package opfs provides key-value storage backed by the Origin Private File System (OPFS).
skins
autumn
Package autumn provides the "autumn" wings skin.
Package autumn provides the "autumn" wings skin.
brisk
Package brisk provides a fast, expressive Motion skin: short transitions, pronounced hover lift, firm click feedback.
Package brisk provides a fast, expressive Motion skin: short transitions, pronounced hover lift, firm click feedback.
calm
Package calm is the default Motion skin: balanced transitions and hover lift, matching the values previously hard-coded in the eight identity themes.
Package calm is the default Motion skin: balanced transitions and hover lift, matching the values previously hard-coded in the eight identity themes.
classic
Package classic provides the default Geometry/Spacing skin: balanced corner radius and inner spacing, matching the values previously hard-coded in the eight identity themes.
Package classic provides the default Geometry/Spacing skin: balanced corner radius and inner spacing, matching the values previously hard-coded in the eight identity themes.
dark
Package dark provides a dark wings skin.
Package dark provides a dark wings skin.
darkblueberry
Package darkblueberry provides the "darkblueberry" wings skin.
Package darkblueberry provides the "darkblueberry" wings skin.
darkforest
Package darkforest provides the "darkforest" wings skin.
Package darkforest provides the "darkforest" wings skin.
flat
Package flat provides a focused wings Depth skin: minimal, near-flush shadow geometry.
Package flat provides a focused wings Depth skin: minimal, near-flush shadow geometry.
floating
Package floating provides a focused wings Depth skin with diffuse, generous shadow geometry — pairs naturally with `soft` for an "elevated cards" aesthetic.
Package floating provides a focused wings Depth skin with diffuse, generous shadow geometry — pairs naturally with `soft` for an "elevated cards" aesthetic.
gentle
Package gentle provides a slow, restrained Motion skin: long transitions and minimal hover/active feedback.
Package gentle provides a slow, restrained Motion skin: long transitions and minimal hover/active feedback.
glass
Package glass provides a focused wings skin covering only the CategoryAtmosphere dimension — glass-morphism via backdrop-filter blur and translucent surface alpha.
Package glass provides a focused wings skin covering only the CategoryAtmosphere dimension — glass-morphism via backdrop-filter blur and translucent surface alpha.
lifted
Package lifted is the default Depth skin: balanced shadow geometry matching the values previously hard-coded in the eight identity themes.
Package lifted is the default Depth skin: balanced shadow geometry matching the values previously hard-coded in the eight identity themes.
light
Package light provides the baseline "light" wings skin.
Package light provides the baseline "light" wings skin.
lightblueberry
Package lightblueberry provides the "lightblueberry" wings skin.
Package lightblueberry provides the "lightblueberry" wings skin.
mushroom
Package mushroom provides the "mushroom" wings skin.
Package mushroom provides the "mushroom" wings skin.
sharp
Package sharp provides a focused wings skin covering only Geometry and Spacing: minimal radius, tight padding.
Package sharp provides a focused wings skin covering only Geometry and Spacing: minimal radius, tight padding.
soft
Package soft provides a focused wings skin covering only Geometry and Spacing: generous corner radius, airy padding.
Package soft provides a focused wings skin covering only Geometry and Spacing: generous corner radius, airy padding.
vividforest
Package vividforest provides the "vividforest" wings skin.
Package vividforest provides the "vividforest" wings skin.
Package timer provides setTimeout, setInterval, Sleep and Ticker helpers for wings.
Package timer provides setTimeout, setInterval, Sleep and Ticker helpers for wings.
area
Package area provides a locale-aware area type for wings templates.
Package area provides a locale-aware area type for wings templates.
cooking
Package cooking provides locale-aware cooking measurement types for wings templates.
Package cooking provides locale-aware cooking measurement types for wings templates.
fueleconomy
Package fueleconomy provides a locale-aware fuel economy type for wings templates.
Package fueleconomy provides a locale-aware fuel economy type for wings templates.
length
Package length provides a locale-aware length type for wings templates.
Package length provides a locale-aware length type for wings templates.
speed
Package speed provides a locale-aware speed type for wings templates.
Package speed provides a locale-aware speed type for wings templates.
temperature
Package temperature provides a locale-aware temperature type for wings templates.
Package temperature provides a locale-aware temperature type for wings templates.
volume
Package volume provides a locale-aware volume type for wings templates.
Package volume provides a locale-aware volume type for wings templates.
weight
Package weight provides a locale-aware weight/mass type for wings templates.
Package weight provides a locale-aware weight/mass type for wings templates.
widget
combobox
Package combobox provides a w-combobox custom element for wings.
Package combobox provides a w-combobox custom element for wings.
dialog
Package dialog provides a w-dialog custom element for wings.
Package dialog provides a w-dialog custom element for wings.
navbar
Package navbar provides a w-navbar custom element for wings.
Package navbar provides a w-navbar custom element for wings.
skinswitcher
Package skinswitcher provides the <skin-switcher> custom element.
Package skinswitcher provides the <skin-switcher> custom element.
tab
Package tab provides the w-tab custom element for wings.
Package tab provides the w-tab custom element for wings.
tabbutton
Package tabbutton provides the w-tabbutton custom element for wings.
Package tabbutton provides the w-tabbutton custom element for wings.
tabs
Package tabs provides the w-tabs custom element for wings.
Package tabs provides the w-tabs custom element for wings.
test
Package test provides the w-test custom element for wings: an in-web test harness.
Package test provides the w-test custom element for wings: an in-web test harness.
testreport
Package testreport provides the <w-test-report> custom element: a button that collects the full page test report — every <w-test> card (including the human-judged visual ones) plus every check declared by mounted Testabler modules (see wings.RunReport) — renders it as JSON, and fires a "report" trigger carrying that JSON.
Package testreport provides the <w-test-report> custom element: a button that collects the full page test report — every <w-test> card (including the human-judged visual ones) plus every check declared by mounted Testabler modules (see wings.RunReport) — renders it as JSON, and fires a "report" trigger carrying that JSON.
Package wsign is the server-side counterpart to wi18n's catalog signature verification: it generates ed25519 signing keypairs, decrypts the private key, and signs catalog JSON.
Package wsign is the server-side counterpart to wi18n's catalog signature verification: it generates ed25519 signing keypairs, decrypts the private key, and signs catalog JSON.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL