diff --git a/README.md b/README.md
index 4d7d8f7..507bc63 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# go-html
-HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
+HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
**Module**: `forge.lthn.ai/core/go-html`
**Licence**: EUPL-1.2
diff --git a/bench_test.go b/bench_test.go
index 220363e..7440203 100644
--- a/bench_test.go
+++ b/bench_test.go
@@ -1,7 +1,6 @@
package html
import (
- "fmt"
"testing"
i18n "dappco.re/go/core/i18n"
@@ -100,7 +99,7 @@ func BenchmarkImprint_Small(b *testing.B) {
func BenchmarkImprint_Large(b *testing.B) {
items := make([]string, 20)
for i := range items {
- items[i] = fmt.Sprintf("Item %d was created successfully", i)
+ items[i] = "Item " + itoaText(i) + " was created successfully"
}
page := NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
@@ -207,7 +206,7 @@ func BenchmarkLayout_Nested(b *testing.B) {
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
nodes := make([]Node, 50)
for i := range nodes {
- nodes[i] = El("p", Raw(fmt.Sprintf("paragraph %d", i)))
+ nodes[i] = El("p", Raw("paragraph "+itoaText(i)))
}
layout := NewLayout("HLCRF").
H(Raw("header")).
@@ -242,7 +241,7 @@ func benchEach(b *testing.B, n int) {
items[i] = i
}
node := Each(items, func(i int) Node {
- return El("li", Raw(fmt.Sprintf("item-%d", i)))
+ return El("li", Raw("item-"+itoaText(i)))
})
ctx := NewContext()
diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go
index b72def3..3874c2f 100644
--- a/cmd/codegen/main.go
+++ b/cmd/codegen/main.go
@@ -1,43 +1,181 @@
-// Package main provides a build-time CLI for generating Web Component JS bundles.
-// Reads a JSON slot map from stdin, writes the generated JS to stdout.
+//go:build !js
+
+// Package main provides a build-time CLI for generating Web Component bundles.
+// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout.
//
// Usage:
//
// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js
+// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -types > components.d.ts
+// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js
package main
import (
- "encoding/json"
+ "context"
+ "errors"
+ "flag"
goio "io"
"os"
+ "os/signal"
+ "time"
+ core "dappco.re/go/core"
"dappco.re/go/core/html/codegen"
+ coreio "dappco.re/go/core/io"
log "dappco.re/go/core/log"
)
-func run(r goio.Reader, w goio.Writer) error {
+func generate(data []byte, emitTypes bool) (string, error) {
+ var slots map[string]string
+ if result := core.JSONUnmarshal(data, &slots); !result.OK {
+ err, _ := result.Value.(error)
+ return "", log.E("codegen", "invalid JSON", err)
+ }
+
+ if emitTypes {
+ return codegen.GenerateTypeScriptDefinitions(slots), nil
+ }
+
+ out, err := codegen.GenerateBundle(slots)
+ if err != nil {
+ return "", log.E("codegen", "generate bundle", err)
+ }
+ return out, nil
+}
+
+func run(r goio.Reader, w goio.Writer, emitTypes bool) error {
data, err := goio.ReadAll(r)
if err != nil {
return log.E("codegen", "reading stdin", err)
}
- var slots map[string]string
- if err := json.Unmarshal(data, &slots); err != nil {
- return log.E("codegen", "invalid JSON", err)
+ out, err := generate(data, emitTypes)
+ if err != nil {
+ return err
}
- js, err := codegen.GenerateBundle(slots)
+ _, err = goio.WriteString(w, out)
+ if err != nil {
+ return log.E("codegen", "writing output", err)
+ }
+ return nil
+}
+
+func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error {
+ if inputPath == "" {
+ return log.E("codegen", "watch mode requires -input", nil)
+ }
+ if outputPath == "" {
+ return log.E("codegen", "watch mode requires -output", nil)
+ }
+ if pollInterval <= 0 {
+ pollInterval = 250 * time.Millisecond
+ }
+
+ var lastInput []byte
+ for {
+ input, err := readLocalFile(inputPath)
+ if err != nil {
+ return log.E("codegen", "reading input file", err)
+ }
+
+ if !sameBytes(input, lastInput) {
+ out, err := generate(input, emitTypes)
+ if err != nil {
+ return err
+ }
+ if err := writeLocalFile(outputPath, out); err != nil {
+ return log.E("codegen", "writing output file", err)
+ }
+ lastInput = append(lastInput[:0], input...)
+ }
+
+ select {
+ case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.Canceled) {
+ return nil
+ }
+ return ctx.Err()
+ case <-time.After(pollInterval):
+ }
+ }
+}
+
+func readLocalFile(path string) ([]byte, error) {
+ f, err := coreio.Local.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = f.Close()
+ }()
+
+ return goio.ReadAll(f)
+}
+
+func writeLocalFile(path, content string) error {
+ f, err := coreio.Local.Create(path)
if err != nil {
return err
}
+ defer func() {
+ _ = f.Close()
+ }()
- _, err = goio.WriteString(w, js)
+ _, err = goio.WriteString(f, content)
return err
}
+func sameBytes(a, b []byte) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range len(a) {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
func main() {
- if err := run(os.Stdin, os.Stdout); err != nil {
- log.Error("codegen failed", "err", err)
+ emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes")
+ inputPath := flag.String("input", "", "path to the JSON slot map used by -watch")
+ outputPath := flag.String("output", "", "path to the generated bundle written by -watch")
+ emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript")
+ pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch")
+ flag.Parse()
+
+ if *emitWatch {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stop()
+
+ if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil {
+ log.Error("codegen failed", "scope", "codegen.main", "err", err)
+ os.Exit(1)
+ }
+ return
+ }
+
+ stdin, err := coreio.Local.Open("/dev/stdin")
+ if err != nil {
+ log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err))
+ os.Exit(1)
+ }
+
+ stdout, err := coreio.Local.Create("/dev/stdout")
+ if err != nil {
+ _ = stdin.Close()
+ log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err))
+ os.Exit(1)
+ }
+ defer func() {
+ _ = stdin.Close()
+ _ = stdout.Close()
+ }()
+
+ if err := run(stdin, stdout, *emitTypes); err != nil {
+ log.Error("codegen failed", "scope", "codegen.main", "err", err)
os.Exit(1)
}
}
diff --git a/cmd/codegen/main_test.go b/cmd/codegen/main_test.go
index edacf19..703d0ab 100644
--- a/cmd/codegen/main_test.go
+++ b/cmd/codegen/main_test.go
@@ -1,51 +1,179 @@
+//go:build !js
+
package main
import (
- "bytes"
+ "context"
+ goio "io"
+ "path/filepath"
"strings"
"testing"
+ "time"
+ core "dappco.re/go/core"
+ coreio "dappco.re/go/core/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestRun_Good(t *testing.T) {
- input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`)
- var output bytes.Buffer
+func TestRun_WritesBundle_Good(t *testing.T) {
+ input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
+ output := core.NewBuilder()
- err := run(input, &output)
+ err := run(input, output, false)
require.NoError(t, err)
js := output.String()
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
assert.Contains(t, js, "customElements.define")
- assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
+ assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
}
-func TestRun_Bad_InvalidJSON(t *testing.T) {
- input := strings.NewReader(`not json`)
- var output bytes.Buffer
+func TestRun_InvalidJSON_Bad(t *testing.T) {
+ input := core.NewReader(`not json`)
+ output := core.NewBuilder()
- err := run(input, &output)
+ err := run(input, output, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
-func TestRun_Bad_InvalidTag(t *testing.T) {
- input := strings.NewReader(`{"H":"notag"}`)
- var output bytes.Buffer
+func TestRun_InvalidTag_Bad(t *testing.T) {
+ input := core.NewReader(`{"H":"notag"}`)
+ output := core.NewBuilder()
- err := run(input, &output)
+ err := run(input, output, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "hyphen")
}
-func TestRun_Good_Empty(t *testing.T) {
- input := strings.NewReader(`{}`)
- var output bytes.Buffer
+func TestRun_InvalidTagCharacters_Bad(t *testing.T) {
+ input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`)
+ output := core.NewBuilder()
+
+ err := run(input, output, false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "lowercase hyphenated name")
+}
+
+func TestRun_EmptySlots_Good(t *testing.T) {
+ input := core.NewReader(`{}`)
+ output := core.NewBuilder()
- err := run(input, &output)
+ err := run(input, output, false)
require.NoError(t, err)
assert.Empty(t, output.String())
}
+
+func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) {
+ input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`)
+ output := core.NewBuilder()
+
+ err := run(input, output, true)
+ require.NoError(t, err)
+
+ dts := output.String()
+ assert.Contains(t, dts, "declare global")
+ assert.Contains(t, dts, `"nav-bar": NavBar;`)
+ assert.Contains(t, dts, `"main-content": MainContent;`)
+ assert.Contains(t, dts, "export declare class NavBar extends HTMLElement")
+ assert.Contains(t, dts, "export declare class MainContent extends HTMLElement")
+}
+
+func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) {
+ dir := t.TempDir()
+ inputPath := filepath.Join(dir, "slots.json")
+ outputPath := filepath.Join(dir, "bundle.js")
+
+ require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ done := make(chan error, 1)
+ go func() {
+ done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond)
+ }()
+
+ require.Eventually(t, func() bool {
+ got, err := readTextFile(outputPath)
+ if err != nil {
+ return false
+ }
+ return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent")
+ }, time.Second, 10*time.Millisecond)
+
+ cancel()
+ require.NoError(t, <-done)
+}
+
+func TestRunDaemon_MissingPaths_Bad(t *testing.T) {
+ err := runDaemon(context.Background(), "", "", false, time.Millisecond)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "watch mode requires -input")
+}
+
+func countSubstr(s, substr string) int {
+ if substr == "" {
+ return len(s) + 1
+ }
+
+ count := 0
+ for i := 0; i <= len(s)-len(substr); {
+ j := indexSubstr(s[i:], substr)
+ if j < 0 {
+ return count
+ }
+ count++
+ i += j + len(substr)
+ }
+
+ return count
+}
+
+func indexSubstr(s, substr string) int {
+ if substr == "" {
+ return 0
+ }
+ if len(substr) > len(s) {
+ return -1
+ }
+
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return i
+ }
+ }
+
+ return -1
+}
+
+func writeTextFile(path, content string) error {
+ f, err := coreio.Local.Create(path)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ _ = f.Close()
+ }()
+
+ _, err = goio.WriteString(f, content)
+ return err
+}
+
+func readTextFile(path string) (string, error) {
+ f, err := coreio.Local.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer func() {
+ _ = f.Close()
+ }()
+
+ data, err := goio.ReadAll(f)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go
index a3c0db2..dc74244 100644
--- a/cmd/wasm/main.go
+++ b/cmd/wasm/main.go
@@ -13,15 +13,19 @@ import (
// This is intentional: the WASM module is a rendering engine for trusted content
// produced server-side or by the application's own templates.
func renderToString(_ js.Value, args []js.Value) any {
- if len(args) < 1 {
+ if len(args) < 1 || args[0].Type() != js.TypeString {
return ""
}
variant := args[0].String()
+ if variant == "" {
+ return ""
+ }
+
ctx := html.NewContext()
- if len(args) >= 2 {
- ctx.Locale = args[1].String()
+ if len(args) >= 2 && args[1].Type() == js.TypeString {
+ ctx.SetLocale(args[1].String())
}
layout := html.NewLayout(variant)
diff --git a/cmd/wasm/main_test.go b/cmd/wasm/main_test.go
new file mode 100644
index 0000000..4184caa
--- /dev/null
+++ b/cmd/wasm/main_test.go
@@ -0,0 +1,55 @@
+//go:build js && wasm
+
+package main
+
+import (
+ "testing"
+
+ "syscall/js"
+)
+
+func TestRenderToString_Good(t *testing.T) {
+ gotAny := renderToString(js.Value{}, []js.Value{
+ js.ValueOf("C"),
+ js.ValueOf("en-GB"),
+ js.ValueOf(map[string]any{"C": "hello"}),
+ })
+
+ got, ok := gotAny.(string)
+ if !ok {
+ t.Fatalf("renderToString should return string, got %T", gotAny)
+ }
+
+ want := `hello`
+ if got != want {
+ t.Fatalf("renderToString(...) = %q, want %q", got, want)
+ }
+}
+
+func TestRenderToString_VariantTypeGuard(t *testing.T) {
+ if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" {
+ t.Fatalf("non-string variant should be empty, got %q", got)
+ }
+
+ if got := renderToString(js.Value{}, []js.Value{}); got != "" {
+ t.Fatalf("missing variant should be empty, got %q", got)
+ }
+}
+
+func TestRenderToString_LocaleTypeGuard(t *testing.T) {
+ gotAny := renderToString(js.Value{}, []js.Value{
+ js.ValueOf("C"),
+ js.ValueOf(123),
+ js.ValueOf(map[string]any{"C": "x"}),
+ })
+
+ got, ok := gotAny.(string)
+ if !ok {
+ t.Fatalf("renderToString should return string, got %T", gotAny)
+ }
+
+ want := `x`
+ if got != want {
+ t.Fatalf("renderToString with non-string locale = %q, want %q", got, want)
+ }
+}
diff --git a/cmd/wasm/register.go b/cmd/wasm/register.go
index d88c866..d463465 100644
--- a/cmd/wasm/register.go
+++ b/cmd/wasm/register.go
@@ -3,7 +3,7 @@
package main
import (
- "encoding/json"
+ core "dappco.re/go/core"
"dappco.re/go/core/html/codegen"
log "dappco.re/go/core/log"
@@ -15,7 +15,8 @@ import (
// Use cmd/codegen/ CLI instead for build-time generation.
func buildComponentJS(slotsJSON string) (string, error) {
var slots map[string]string
- if err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil {
+ if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK {
+ err, _ := result.Value.(error)
return "", log.E("buildComponentJS", "unmarshal JSON", err)
}
return codegen.GenerateBundle(slots)
diff --git a/cmd/wasm/register_test.go b/cmd/wasm/register_test.go
index 65f2af2..9365e9e 100644
--- a/cmd/wasm/register_test.go
+++ b/cmd/wasm/register_test.go
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
-func TestBuildComponentJS_Good(t *testing.T) {
+func TestBuildComponentJS_ValidJSON_Good(t *testing.T) {
slotsJSON := `{"H":"nav-bar","C":"main-content"}`
js, err := buildComponentJS(slotsJSON)
require.NoError(t, err)
@@ -18,7 +18,7 @@ func TestBuildComponentJS_Good(t *testing.T) {
assert.Contains(t, js, "customElements.define")
}
-func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) {
+func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) {
_, err := buildComponentJS("not json")
assert.Error(t, err)
}
diff --git a/cmd/wasm/size_test.go b/cmd/wasm/size_test.go
index ed759c1..e8b1d96 100644
--- a/cmd/wasm/size_test.go
+++ b/cmd/wasm/size_test.go
@@ -4,14 +4,13 @@
package main
import (
- "bytes"
"compress/gzip"
- "os"
- "os/exec"
- "path/filepath"
+ "context"
"testing"
+ core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
+ process "dappco.re/go/core/process"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -21,34 +20,44 @@ const (
wasmRawLimit = 3_670_016 // 3.5 MB raw size limit
)
-func TestWASMBinarySize_Good(t *testing.T) {
+func TestCmdWasm_WASMBinarySize_Good(t *testing.T) {
if testing.Short() {
t.Skip("skipping WASM build test in short mode")
}
dir := t.TempDir()
- out := filepath.Join(dir, "gohtml.wasm")
+ out := core.Path(dir, "gohtml.wasm")
- cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, ".")
- cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
- output, err := cmd.CombinedOutput()
+ factory := process.NewService(process.Options{})
+ serviceValue, err := factory(core.New())
+ require.NoError(t, err)
+
+ svc, ok := serviceValue.(*process.Service)
+ require.True(t, ok, "process service factory returned %T", serviceValue)
+
+ output, err := svc.RunWithOptions(context.Background(), process.RunOptions{
+ Command: "go",
+ Args: []string{"build", "-ldflags=-s -w", "-o", out, "."},
+ Dir: ".",
+ Env: []string{"GOOS=js", "GOARCH=wasm"},
+ })
require.NoError(t, err, "WASM build failed: %s", output)
rawStr, err := coreio.Local.Read(out)
require.NoError(t, err)
- raw := []byte(rawStr)
+ rawBytes := []byte(rawStr)
- var buf bytes.Buffer
- gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
+ buf := core.NewBuilder()
+ gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression)
require.NoError(t, err)
- _, err = gz.Write(raw)
+ _, err = gz.Write(rawBytes)
require.NoError(t, err)
require.NoError(t, gz.Close())
- t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(raw), buf.Len())
+ t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len())
assert.Less(t, buf.Len(), wasmGzLimit,
"WASM gzip size %d exceeds 1MB limit", buf.Len())
- assert.Less(t, len(raw), wasmRawLimit,
- "WASM raw size %d exceeds 3MB limit", len(raw))
+ assert.Less(t, len(rawBytes), wasmRawLimit,
+ "WASM raw size %d exceeds 3MB limit", len(rawBytes))
}
diff --git a/codegen/bench_test.go b/codegen/bench_test.go
index 0fdaecd..4a678d9 100644
--- a/codegen/bench_test.go
+++ b/codegen/bench_test.go
@@ -1,3 +1,5 @@
+//go:build !js
+
package codegen
import "testing"
diff --git a/codegen/codegen.go b/codegen/codegen.go
index 8692b0b..2fbcebf 100644
--- a/codegen/codegen.go
+++ b/codegen/codegen.go
@@ -1,13 +1,40 @@
+//go:build !js
+
package codegen
import (
- "fmt"
- "strings"
+ "sort"
"text/template"
+ core "dappco.re/go/core"
log "dappco.re/go/core/log"
)
+// isValidCustomElementTag reports whether tag is a safe custom element name.
+// The generator rejects values that would fail at customElements.define() time.
+func isValidCustomElementTag(tag string) bool {
+ if tag == "" || !core.Contains(tag, "-") {
+ return false
+ }
+
+ if tag[0] < 'a' || tag[0] > 'z' {
+ return false
+ }
+
+ for i := range len(tag) {
+ ch := tag[i]
+ switch {
+ case ch >= 'a' && ch <= 'z':
+ case ch >= '0' && ch <= '9':
+ case ch == '-' || ch == '.' || ch == '_':
+ default:
+ return false
+ }
+ }
+
+ return true
+}
+
// wcTemplate is the Web Component class template.
// Uses closed Shadow DOM for isolation. Content is set via the shadow root's
// DOM API using trusted go-html codegen output (never user input).
@@ -31,12 +58,13 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex
}`))
// GenerateClass produces a JS class definition for a custom element.
+// Usage example: js, err := GenerateClass("nav-bar", "H")
func GenerateClass(tag, slot string) (string, error) {
- if !strings.Contains(tag, "-") {
- return "", log.E("codegen.GenerateClass", "custom element tag must contain a hyphen: "+tag, nil)
+ if !isValidCustomElementTag(tag) {
+ return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
}
- var b strings.Builder
- err := wcTemplate.Execute(&b, struct {
+ b := core.NewBuilder()
+ err := wcTemplate.Execute(b, struct {
ClassName, Tag, Slot string
}{
ClassName: TagToClassName(tag),
@@ -50,16 +78,18 @@ func GenerateClass(tag, slot string) (string, error) {
}
// GenerateRegistration produces the customElements.define() call.
+// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
func GenerateRegistration(tag, className string) string {
- return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
+ return `customElements.define("` + tag + `", ` + className + `);`
}
// TagToClassName converts a kebab-case tag to PascalCase class name.
+// Usage example: className := TagToClassName("nav-bar")
func TagToClassName(tag string) string {
- var b strings.Builder
- for p := range strings.SplitSeq(tag, "-") {
+ b := core.NewBuilder()
+ for _, p := range core.Split(tag, "-") {
if len(p) > 0 {
- b.WriteString(strings.ToUpper(p[:1]))
+ b.WriteString(core.Upper(p[:1]))
b.WriteString(p[1:])
}
}
@@ -68,11 +98,18 @@ func TagToClassName(tag string) string {
// GenerateBundle produces all WC class definitions and registrations
// for a set of HLCRF slot assignments.
+// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
func GenerateBundle(slots map[string]string) (string, error) {
seen := make(map[string]bool)
- var b strings.Builder
+ b := core.NewBuilder()
+ keys := make([]string, 0, len(slots))
+ for slot := range slots {
+ keys = append(keys, slot)
+ }
+ sort.Strings(keys)
- for slot, tag := range slots {
+ for _, slot := range keys {
+ tag := slots[slot]
if seen[tag] {
continue
}
@@ -80,7 +117,7 @@ func GenerateBundle(slots map[string]string) (string, error) {
cls, err := GenerateClass(tag, slot)
if err != nil {
- return "", err
+ return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
}
b.WriteString(cls)
b.WriteByte('\n')
diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go
index 28a6aa2..3fdcdc4 100644
--- a/codegen/codegen_test.go
+++ b/codegen/codegen_test.go
@@ -1,3 +1,5 @@
+//go:build !js
+
package codegen
import (
@@ -8,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
-func TestGenerateClass_Good(t *testing.T) {
+func TestGenerateClass_ValidTag_Good(t *testing.T) {
js, err := GenerateClass("photo-grid", "C")
require.NoError(t, err)
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
@@ -17,19 +19,25 @@ func TestGenerateClass_Good(t *testing.T) {
assert.Contains(t, js, "photo-grid")
}
-func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
+func TestGenerateClass_InvalidTag_Bad(t *testing.T) {
_, err := GenerateClass("invalid", "C")
assert.Error(t, err, "custom element names must contain a hyphen")
+
+ _, err = GenerateClass("Nav-Bar", "C")
+ assert.Error(t, err, "custom element names must be lowercase")
+
+ _, err = GenerateClass("nav bar", "C")
+ assert.Error(t, err, "custom element names must reject spaces")
}
-func TestGenerateRegistration_Good(t *testing.T) {
+func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) {
js := GenerateRegistration("photo-grid", "PhotoGrid")
assert.Contains(t, js, "customElements.define")
assert.Contains(t, js, `"photo-grid"`)
assert.Contains(t, js, "PhotoGrid")
}
-func TestTagToClassName_Good(t *testing.T) {
+func TestTagToClassName_KebabCase_Good(t *testing.T) {
tests := []struct{ tag, want string }{
{"photo-grid", "PhotoGrid"},
{"nav-breadcrumb", "NavBreadcrumb"},
@@ -41,14 +49,108 @@ func TestTagToClassName_Good(t *testing.T) {
}
}
-func TestGenerateBundle_Good(t *testing.T) {
+func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) {
slots := map[string]string{
"H": "nav-bar",
"C": "main-content",
+ "F": "nav-bar",
}
js, err := GenerateBundle(slots)
require.NoError(t, err)
assert.Contains(t, js, "NavBar")
assert.Contains(t, js, "MainContent")
- assert.Equal(t, 2, strings.Count(js, "extends HTMLElement"))
+ assert.Equal(t, 2, countSubstr(js, "extends HTMLElement"))
+ assert.Equal(t, 2, countSubstr(js, "customElements.define"))
+}
+
+func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) {
+ slots := map[string]string{
+ "Z": "zed-panel",
+ "A": "alpha-panel",
+ "M": "main-content",
+ }
+
+ js, err := GenerateBundle(slots)
+ require.NoError(t, err)
+
+ alpha := strings.Index(js, "class AlphaPanel")
+ main := strings.Index(js, "class MainContent")
+ zed := strings.Index(js, "class ZedPanel")
+
+ assert.NotEqual(t, -1, alpha)
+ assert.NotEqual(t, -1, main)
+ assert.NotEqual(t, -1, zed)
+ assert.Less(t, alpha, main)
+ assert.Less(t, main, zed)
+ assert.Equal(t, 3, countSubstr(js, "extends HTMLElement"))
+ assert.Equal(t, 3, countSubstr(js, "customElements.define"))
+}
+
+func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) {
+ slots := map[string]string{
+ "Z": "zed-panel",
+ "A": "alpha-panel",
+ "M": "alpha-panel",
+ }
+
+ dts := GenerateTypeScriptDefinitions(slots)
+
+ assert.Contains(t, dts, `interface HTMLElementTagNameMap`)
+ assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`)
+ assert.Contains(t, dts, `"zed-panel": ZedPanel;`)
+ assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`))
+ assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`))
+ assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`))
+ assert.Contains(t, dts, "export {};")
+ assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`))
+}
+
+func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) {
+ slots := map[string]string{
+ "H": "nav-bar",
+ "C": "Nav-Bar",
+ "F": "nav bar",
+ }
+
+ dts := GenerateTypeScriptDefinitions(slots)
+
+ assert.Contains(t, dts, `"nav-bar": NavBar;`)
+ assert.NotContains(t, dts, "Nav-Bar")
+ assert.NotContains(t, dts, "nav bar")
+ assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`))
+}
+
+func countSubstr(s, substr string) int {
+ if substr == "" {
+ return len(s) + 1
+ }
+
+ count := 0
+ for i := 0; i <= len(s)-len(substr); {
+ j := indexSubstr(s[i:], substr)
+ if j < 0 {
+ return count
+ }
+ count++
+ i += j + len(substr)
+ }
+
+ return count
+}
+
+func indexSubstr(s, substr string) int {
+ if substr == "" {
+ return 0
+ }
+ if len(substr) > len(s) {
+ return -1
+ }
+
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return i
+ }
+ }
+
+ return -1
}
diff --git a/codegen/doc.go b/codegen/doc.go
new file mode 100644
index 0000000..afc9b13
--- /dev/null
+++ b/codegen/doc.go
@@ -0,0 +1,13 @@
+//go:build !js
+
+// SPDX-Licence-Identifier: EUPL-1.2
+
+// Package codegen generates Web Component bundles for go-html slot maps.
+//
+// Use it at build time, or through the cmd/codegen CLI:
+//
+// bundle, err := GenerateBundle(map[string]string{
+// "H": "site-header",
+// "C": "app-main",
+// })
+package codegen
diff --git a/codegen/typescript.go b/codegen/typescript.go
new file mode 100644
index 0000000..035a0e7
--- /dev/null
+++ b/codegen/typescript.go
@@ -0,0 +1,61 @@
+//go:build !js
+
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package codegen
+
+import (
+ "sort"
+
+ core "dappco.re/go/core"
+)
+
+// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for
+// a set of custom elements generated from HLCRF slot assignments.
+// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"})
+func GenerateTypeScriptDefinitions(slots map[string]string) string {
+ seen := make(map[string]bool)
+ declared := make(map[string]bool)
+ b := core.NewBuilder()
+
+ keys := make([]string, 0, len(slots))
+ for slot := range slots {
+ keys = append(keys, slot)
+ }
+ sort.Strings(keys)
+
+ b.WriteString("declare global {\n")
+ b.WriteString(" interface HTMLElementTagNameMap {\n")
+ for _, slot := range keys {
+ tag := slots[slot]
+ if !isValidCustomElementTag(tag) || seen[tag] {
+ continue
+ }
+ seen[tag] = true
+ b.WriteString(" \"")
+ b.WriteString(tag)
+ b.WriteString("\": ")
+ b.WriteString(TagToClassName(tag))
+ b.WriteString(";\n")
+ }
+ b.WriteString(" }\n")
+ b.WriteString("}\n\n")
+
+ for _, slot := range keys {
+ tag := slots[slot]
+ if !seen[tag] || declared[tag] {
+ continue
+ }
+ declared[tag] = true
+ b.WriteString("export declare class ")
+ b.WriteString(TagToClassName(tag))
+ b.WriteString(" extends HTMLElement {\n")
+ b.WriteString(" connectedCallback(): void;\n")
+ b.WriteString(" render(html: string): void;\n")
+ b.WriteString("}\n\n")
+ }
+
+ b.WriteString("export {};\n")
+
+ return b.String()
+}
diff --git a/context.go b/context.go
index 3bc77ef..f5d40e5 100644
--- a/context.go
+++ b/context.go
@@ -1,27 +1,81 @@
package html
-import i18n "dappco.re/go/core/i18n"
+// Translator provides Text() lookups for a rendering context.
+// Usage example: ctx := NewContextWithService(myTranslator)
+//
+// The default server build uses go-i18n. Alternate builds, including WASM,
+// can provide any implementation with the same T() method.
+type Translator interface {
+ T(key string, args ...any) string
+}
// Context carries rendering state through the node tree.
+// Usage example: ctx := NewContext()
type Context struct {
Identity string
Locale string
Entitlements func(feature string) bool
Data map[string]any
- service *i18n.Service
+ service Translator
+}
+
+func applyLocaleToService(svc Translator, locale string) {
+ if svc == nil || locale == "" {
+ return
+ }
+
+ if setter, ok := svc.(interface{ SetLanguage(string) error }); ok {
+ base := locale
+ for i := 0; i < len(base); i++ {
+ if base[i] == '-' || base[i] == '_' {
+ base = base[:i]
+ break
+ }
+ }
+ _ = setter.SetLanguage(base)
+ }
}
// NewContext creates a new rendering context with sensible defaults.
-func NewContext() *Context {
- return &Context{
+// Usage example: html := Render(Text("welcome"), NewContext("en-GB"))
+func NewContext(locale ...string) *Context {
+ ctx := &Context{
Data: make(map[string]any),
}
+ if len(locale) > 0 {
+ ctx.SetLocale(locale[0])
+ }
+ return ctx
}
-// NewContextWithService creates a rendering context backed by a specific i18n service.
-func NewContextWithService(svc *i18n.Service) *Context {
- return &Context{
- Data: make(map[string]any),
- service: svc,
+// NewContextWithService creates a rendering context backed by a specific translator.
+// Usage example: ctx := NewContextWithService(myTranslator, "en-GB")
+func NewContextWithService(svc Translator, locale ...string) *Context {
+ ctx := NewContext(locale...)
+ ctx.SetService(svc)
+ return ctx
+}
+
+// SetService swaps the translator used by the context.
+// Usage example: ctx.SetService(myTranslator)
+func (ctx *Context) SetService(svc Translator) *Context {
+ if ctx == nil {
+ return nil
}
+
+ ctx.service = svc
+ applyLocaleToService(svc, ctx.Locale)
+ return ctx
+}
+
+// SetLocale updates the context locale and reapplies it to the active translator.
+// Usage example: ctx.SetLocale("en-GB")
+func (ctx *Context) SetLocale(locale string) *Context {
+ if ctx == nil {
+ return nil
+ }
+
+ ctx.Locale = locale
+ applyLocaleToService(ctx.service, ctx.Locale)
+ return ctx
}
diff --git a/context_test.go b/context_test.go
new file mode 100644
index 0000000..93519d4
--- /dev/null
+++ b/context_test.go
@@ -0,0 +1,90 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package html
+
+import (
+ "testing"
+
+ i18n "dappco.re/go/core/i18n"
+)
+
+func TestNewContext_OptionalLocale_Good(t *testing.T) {
+ ctx := NewContext("en-GB")
+
+ if ctx == nil {
+ t.Fatal("NewContext returned nil")
+ }
+ if ctx.Locale != "en-GB" {
+ t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB")
+ }
+ if ctx.Data == nil {
+ t.Fatal("NewContext should initialise Data")
+ }
+}
+
+func TestNewContextWithService_OptionalLocale_Good(t *testing.T) {
+ svc, _ := i18n.New()
+ ctx := NewContextWithService(svc, "fr-FR")
+
+ if ctx == nil {
+ t.Fatal("NewContextWithService returned nil")
+ }
+ if ctx.Locale != "fr-FR" {
+ t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR")
+ }
+ if ctx.service == nil {
+ t.Fatal("NewContextWithService should set translator service")
+ }
+}
+
+func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) {
+ svc, _ := i18n.New()
+ ctx := NewContextWithService(svc, "fr-FR")
+
+ got := Text("prompt.yes").Render(ctx)
+ if got != "o" {
+ t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o")
+ }
+}
+
+func TestContext_SetService_AppliesLocale_Good(t *testing.T) {
+ svc, _ := i18n.New()
+ ctx := NewContext("fr-FR")
+
+ if got := ctx.SetService(svc); got != ctx {
+ t.Fatal("SetService should return the same context for chaining")
+ }
+
+ got := Text("prompt.yes").Render(ctx)
+ if got != "o" {
+ t.Fatalf("SetService locale translation = %q, want %q", got, "o")
+ }
+}
+
+func TestContext_SetService_NilContext_Ugly(t *testing.T) {
+ var ctx *Context
+ if got := ctx.SetService(nil); got != nil {
+ t.Fatal("SetService on nil context should return nil")
+ }
+}
+
+func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) {
+ svc, _ := i18n.New()
+ ctx := NewContextWithService(svc)
+
+ if got := ctx.SetLocale("fr-FR"); got != ctx {
+ t.Fatal("SetLocale should return the same context for chaining")
+ }
+
+ got := Text("prompt.yes").Render(ctx)
+ if got != "o" {
+ t.Fatalf("SetLocale translation = %q, want %q", got, "o")
+ }
+}
+
+func TestContext_SetLocale_NilContext_Ugly(t *testing.T) {
+ var ctx *Context
+ if got := ctx.SetLocale("en-GB"); got != nil {
+ t.Fatal("SetLocale on nil context should return nil")
+ }
+}
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..b3c3317
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,12 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+// Package html renders semantic HTML from composable node trees.
+//
+// A typical page combines Layout, El, Text, and Render:
+//
+// page := NewLayout("HCF").
+// H(El("h1", Text("page.title"))).
+// C(El("main", Text("page.body"))).
+// F(El("small", Text("page.footer")))
+// out := Render(page, NewContext())
+package html
diff --git a/docs/architecture.md b/docs/architecture.md
index a78bad6..169dc36 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -17,13 +17,18 @@ type Node interface {
}
```
-All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine constructors plus the `Attr()` and `Render()` helpers:
+All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four accessibility helpers, plus the `Attr()` and `Render()` helpers:
| Constructor | Behaviour |
|-------------|-----------|
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. |
-| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. |
+| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. |
+| `AltText(Node, text)` | Convenience helper that sets `alt` on an element node. |
+| `TabIndex(Node, index)` | Convenience helper that sets `tabindex` on an element node. |
+| `AutoFocus(Node)` | Convenience helper that sets `autofocus` on an element node. |
+| `Role(Node, role)` | Convenience helper that sets `role` on an element node. |
+| `Text(key, ...any)` | Translated text via the active context translator. Server builds fall back to global `go-i18n`; JS builds fall back to the key. Output is always HTML-escaped. |
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
| `If(cond, Node)` | Renders the child only when the condition function returns true. |
| `Unless(cond, Node)` | Renders the child only when the condition function returns false. |
@@ -50,16 +55,16 @@ type Context struct {
Locale string // BCP 47 locale string
Entitlements func(feature string) bool // feature gate callback
Data map[string]any // arbitrary per-request data
- service *i18n.Service // unexported; set via constructor
+ service Translator // unexported; set via constructor
}
```
Two constructors are provided:
- `NewContext()` creates a context with sensible defaults and an empty `Data` map.
-- `NewContextWithService(svc)` creates a context backed by a specific `i18n.Service` instance.
+- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`.
-The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. This prevents callers from setting the service inconsistently after construction.
+The `service` field is intentionally unexported. When nil, server builds fall back to the global `i18n.T()` default while JS builds render the key unchanged. This prevents callers from setting the service inconsistently after construction while keeping the WASM import graph lean.
## HLCRF Layout
@@ -161,6 +166,8 @@ html.NewResponsive().
Each variant renders inside a `
` container. Variants render in insertion order. CSS media queries or JavaScript can target these containers for show/hide logic.
+`VariantSelector(name)` returns a CSS attribute selector for a specific responsive variant, making stylesheet targeting less error-prone than hand-writing the attribute selector repeatedly.
+
`Responsive` implements `Node`, so it can be passed to `Render()` or `Imprint()`. The `Variant()` method accepts `*Layout` specifically, not arbitrary `Node` values.
Each variant maintains independent block ID namespaces -- nesting a layout inside a responsive variant does not conflict with the same layout structure in another variant.
diff --git a/docs/development.md b/docs/development.md
index eb5c476..46cbcc1 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -66,7 +66,7 @@ go test ./cmd/codegen/
go test ./cmd/wasm/
```
-The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself.
+The WASM size gate test (`TestWASMBinarySize_WithinBudget`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself.
### Test Dependencies
@@ -145,6 +145,24 @@ echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \
JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated.
+Pass `-types` to emit ambient TypeScript declarations instead of JavaScript:
+
+```bash
+echo '{"H":"site-header","C":"app-content"}' \
+ | go run ./cmd/codegen/ -types \
+ > components.d.ts
+```
+
+For local development, `-watch` polls an input JSON file and rewrites the
+output file whenever the slot map changes:
+
+```bash
+go run ./cmd/codegen/ \
+ -watch \
+ -input slots.json \
+ -output components.js
+```
+
To test the CLI:
```bash
@@ -278,7 +296,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
### Codegen Tests with Testify
```go
-func TestGenerateClass_Good(t *testing.T) {
+func TestGenerateClass_ValidTag(t *testing.T) {
js, err := GenerateClass("photo-grid", "C")
require.NoError(t, err)
assert.Contains(t, js, "class PhotoGrid extends HTMLElement")
@@ -291,6 +309,6 @@ func TestGenerateClass_Good(t *testing.T) {
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning.
- `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first.
-- `Context.service` is unexported. Custom i18n service injection requires `NewContextWithService()`. There is no way to swap the service after construction.
+- `Context.service` is unexported. Custom translation injection uses `NewContextWithService()`, and `Context.SetService()` can swap the translator later while preserving locale-aware services.
- The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript.
-- `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs.
+- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs.
diff --git a/docs/history.md b/docs/history.md
index 4631ef2..fc6ff0b 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -78,7 +78,7 @@ The fix was applied in three distinct steps:
### Size gate test (`aae5d21`)
-`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
+`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts:
- Gzip size < 1,048,576 bytes (1 MB).
- Raw size < 3,145,728 bytes (3 MB).
@@ -101,11 +101,11 @@ These are not regressions; they are design choices or deferred work recorded for
3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis.
-4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use.
+4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation.
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
-6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
+6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
@@ -114,6 +114,7 @@ These are not regressions; they are design choices or deferred work recorded for
These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items.
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
-- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
+- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
+- **Responsive CSS helpers** — `VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.
diff --git a/docs/index.md b/docs/index.md
index c7b12c4..2d3385f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -39,9 +39,9 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`` (H), `