diff --git a/README.md b/README.md
index a04c685..73639bb 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, 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()`.
+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, TabIndex, AutoFocus, Role), 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 with optional TypeScript declarations, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`.
**Module**: `dappco.re/go/core/html`
**Licence**: EUPL-1.2
diff --git a/docs/architecture.md b/docs/architecture.md
index f018689..794db60 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -93,19 +93,19 @@ Slot letters not present in the variant string are ignored, even if nodes have b
### Deterministic Block IDs
-Each rendered slot receives a `data-block` attribute encoding its position in the layout tree. At the root level, IDs follow the pattern `{slot}-0`:
+Each rendered slot receives a `data-block` attribute encoding its position in the layout tree. At the root level, IDs use the slot letter itself:
```html
-
-...
-
+
+...
+
```
Block IDs are constructed by simple string concatenation (no `fmt.Sprintf`) to keep the `fmt` package out of the WASM import graph.
### Nested Layouts
-`Layout` implements `Node`, so a layout can be placed inside any slot of another layout. At render time, nested layouts are cloned and their internal `path` field is set to the parent's block ID as a prefix. This produces hierarchical paths:
+`Layout` implements `Node`, so a layout can be placed inside any slot of another layout. At render time, nested layouts retain the parent's block ID as a prefix. This produces hierarchical paths:
```go
inner := html.NewLayout("HCF").
@@ -120,7 +120,7 @@ outer := html.NewLayout("HLCRF").
F(html.Raw("foot"))
```
-The inner layout's slots render with prefixed block IDs: `L-0-H-0`, `L-0-C-0`, `L-0-F-0`. At 10 levels of nesting, the deepest block ID becomes `C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0-C-0` (tested in `edge_test.go`).
+The inner layout's slots render with prefixed block IDs: `L.0`, `L.0.1`, `L.0.2`. At 10 levels of nesting, the deepest block ID becomes `C.0.0.0.0.0.0.0.0.0` (tested in `edge_test.go`).
The clone-on-render approach means the original layout is never mutated. This is safe for concurrent use.
@@ -141,9 +141,9 @@ html.NewLayout("HCF").
`ParseBlockID()` in `path.go` extracts the slot letter sequence from a `data-block` attribute value:
```go
-ParseBlockID("L-0-C-0") // returns ['L', 'C']
-ParseBlockID("C-0-C-0-C-0") // returns ['C', 'C', 'C']
-ParseBlockID("H-0") // returns ['H']
+ParseBlockID("L.0.C.0") // returns ['L', 'C']
+ParseBlockID("C.0.C.0.C.0") // returns ['C', 'C', 'C']
+ParseBlockID("H") // returns ['H']
ParseBlockID("") // returns nil
```
diff --git a/docs/history.md b/docs/history.md
index fc6ff0b..0680820 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -95,7 +95,7 @@ The test is skipped under `go test -short` and is guarded with `//go:build !js`.
These are not regressions; they are design choices or deferred work recorded for future consideration.
-1. **Invalid layout variants are silent.** `NewLayout("XYZ")` produces empty output. No error, no warning. Adding validation would require changing the return type of `NewLayout` from `*Layout` to `(*Layout, error)`, which is a breaking API change.
+1. **Invalid layout variants are still non-fatal at render time.** `NewLayout("XYZ")` produces empty output, but `VariantError()` exposes the validation result without changing the `NewLayout` API.
2. **No WASM integration test.** `cmd/wasm/size_test.go` tests binary size only. The `renderToString` behaviour is tested by building and running the WASM binary in a browser, not by an automated test. A `syscall/js`-compatible test harness would be needed.
@@ -103,7 +103,7 @@ These are not regressions; they are design choices or deferred work recorded for
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.
+5. **TypeScript definitions are generated.** `codegen.GenerateTypeScriptDefinitions()` and the `cmd/codegen -types` flag emit `.d.ts` companions for generated Web Components.
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
diff --git a/node_test.go b/node_test.go
index ee534e2..60eeb1d 100644
--- a/node_test.go
+++ b/node_test.go
@@ -259,6 +259,60 @@ func TestElNode_Attr_Good(t *testing.T) {
}
}
+func TestAccessibilityHelpers_Good(t *testing.T) {
+ ctx := NewContext()
+
+ button := Role(
+ AriaLabel(
+ TabIndex(
+ AutoFocus(El("button", Raw("save"))),
+ 3,
+ ),
+ "Save changes",
+ ),
+ "button",
+ )
+
+ got := button.Render(ctx)
+ for _, want := range []string{
+ `aria-label="Save changes"`,
+ `autofocus="autofocus"`,
+ `role="button"`,
+ `tabindex="3"`,
+ ">save",
+ } {
+ if !containsText(got, want) {
+ t.Fatalf("accessibility helpers missing %q in:\n%s", want, got)
+ }
+ }
+
+ img := AltText(El("img"), "Profile photo")
+ if got := img.Render(ctx); got != `
` {
+ t.Fatalf("AltText() = %q, want %q", got, `
`)
+ }
+}
+
+func TestSwitchNode_Good(t *testing.T) {
+ ctx := NewContext()
+ ctx.Locale = "en-GB"
+
+ node := Switch(
+ func(ctx *Context) string { return ctx.Locale },
+ map[string]Node{
+ "en-GB": Raw("hello"),
+ "fr-FR": Raw("bonjour"),
+ },
+ )
+
+ if got := node.Render(ctx); got != "hello" {
+ t.Fatalf("Switch matched case = %q, want %q", got, "hello")
+ }
+
+ if got := Switch(func(*Context) string { return "de-DE" }, map[string]Node{"en-GB": Raw("hello")}).Render(ctx); got != "" {
+ t.Fatalf("Switch missing case = %q, want empty", got)
+ }
+}
+
func TestElNode_AttrEscaping_Good(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)