diff --git a/.ai/README.md b/.ai/README.md
new file mode 100644
index 0000000..d21038a
--- /dev/null
+++ b/.ai/README.md
@@ -0,0 +1,33 @@
+# Treibstoff — LLM Instruction Files
+
+This directory contains self-contained instruction files for LLM-assisted
+development with treibstoff. Each file can be used as prompt input without
+needing additional context.
+
+## Usage
+
+Copy the content of the relevant file(s) into your LLM prompt to provide
+context for code generation, review, or explanation tasks.
+
+For general understanding, start with `overview.md`. For specific tasks,
+pick the topic file that matches your need.
+
+## Files
+
+| File | Description |
+|------|-------------|
+| [overview.md](overview.md) | Architecture overview — module map, inheritance hierarchy, all public API members |
+| [create-widget.md](create-widget.md) | How to create custom widgets — Widget, HTMLWidget, SVGContext, lifecycle |
+| [property-binding.md](property-binding.md) | Reactive property system — all 9 property types, auto-handlers, cascading |
+| [event-handling.md](event-handling.md) | Events, listeners, keyboard state — on/off/trigger, create_listener, KeyState |
+| [drag-and-drop.md](drag-and-drop.md) | Native HTML5 DnD — DnD class, cross-instance coordination, evt.source |
+| [motion-tracking.md](motion-tracking.md) | Drag, resize, selection — Motion class, scope variants, down/move/up |
+| [template-parsing.md](template-parsing.md) | Template compilation — compile_template, t-elem, t-prop, t-val, t-type |
+| [ssr-integration.md](ssr-integration.md) | SSR via HTML attributes — all ajax:* attributes, 7 patterns |
+| [ssr-programmatic.md](ssr-programmatic.md) | Programmatic Ajax API — ajax.action, ajax.trigger, ajax.overlay, ajax.register |
+| [build-forms.md](build-forms.md) | Form building — Form, FormInput, FormField, FormSelect, validation |
+| [overlays-dialogs.md](overlays-dialogs.md) | Overlays and dialogs — Overlay, Dialog, Message, show_dialog, show_error |
+| [svg-graphics.md](svg-graphics.md) | SVG graphics — SVGContext, svg_elem, svg_attrs, two-layer pattern |
+| [http-requests.md](http-requests.md) | HTTP requests — http_request, HTTPRequest, spinner, error handling |
+| [websocket-realtime.md](websocket-realtime.md) | WebSocket — Websocket class, events, JSON messaging, heartbeat |
+| [testing.md](testing.md) | Writing QUnit tests — test patterns, DOM fixtures, mocks, assertions |
diff --git a/.ai/build-forms.md b/.ai/build-forms.md
new file mode 100644
index 0000000..52a4df7
--- /dev/null
+++ b/.ai/build-forms.md
@@ -0,0 +1,351 @@
+# Building Forms
+
+This guide explains how to build forms with treibstoff's form system, including
+validation, remote data fetching, and Ajax submission.
+
+## Context
+
+Treibstoff provides a form abstraction layer on top of DOM form elements. The
+`Form` class manages form-level operations, while `FormInput`, `FormField`,
+`FormSelect`, `FormCheckbox`, and `FormRemoteSelect` wrap individual elements
+with reactive getters/setters and event handling.
+
+## Key API
+
+| Class/Function | Purpose |
+|----------------|---------|
+| `ts.Form` | Form container — initialize, lookup, field visibility |
+| `ts.FormInput` | Input wrapper — value, disabled state |
+| `ts.FormField` | Field wrapper — visibility, error state, reset |
+| `ts.FormCheckbox` | Checkbox wrapper — checked state |
+| `ts.FormSelect` | Select wrapper — options, clear |
+| `ts.FormRemoteSelect` | Select with server-side option fetching |
+| `ts.lookup_form_elem` | Find form element by naming convention |
+
+## Naming Convention
+
+Form elements are found by ID following this pattern:
+
+- Form: `#form-{name}`
+- Input: `#input-{form.name}-{field.name}`
+- Field: `#field-{form.name}-{field.name}`
+
+```html
+
+```
+
+## Pattern 1: Basic Form with Fields
+
+```javascript
+import ts from 'treibstoff';
+
+class UserForm extends ts.Form {
+ constructor(opts) {
+ super(opts);
+ this.email = new ts.FormField({
+ form: this,
+ name: 'email',
+ input: ts.FormInput
+ });
+ this.role = new ts.FormField({
+ form: this,
+ name: 'role',
+ input: ts.FormSelect
+ });
+ }
+}
+
+// Initialize from DOM context (e.g. in an ajax.register callback)
+ts.Form.initialize($('#content'), UserForm, 'user');
+
+// Later, look up the form instance
+let form = ts.Form.instance('user');
+form.email.input.value = 'user@example.com';
+```
+
+## Pattern 2: Checkbox Fields
+
+```html
+
+```
+
+```javascript
+class SettingsForm extends ts.Form {
+ constructor(opts) {
+ super(opts);
+ this.notifications = new ts.FormField({
+ form: this,
+ name: 'notifications',
+ input: ts.FormCheckbox
+ });
+ }
+}
+
+// Read/set checkbox state
+let form = ts.Form.instance('settings');
+form.notifications.input.checked = true;
+let isChecked = form.notifications.input.checked;
+```
+
+## Pattern 3: Remote Select (Server-Fetched Options)
+
+```javascript
+class ProjectForm extends ts.Form {
+ constructor(opts) {
+ super(opts);
+ this.category = new ts.FormField({
+ form: this,
+ name: 'category',
+ input: new ts.FormRemoteSelect({
+ form: this,
+ name: 'category',
+ vocab: '/api/categories.json'
+ })
+ });
+ }
+
+ load_categories(filter) {
+ // Fetches JSON from /api/categories.json?q=filter
+ // Server must return array of [value, label] pairs
+ this.category.input.fetch({q: filter});
+ }
+}
+```
+
+The server must return a JSON array of `[value, label]` pairs:
+```json
+[["cat1", "Category 1"], ["cat2", "Category 2"]]
+```
+
+## Pattern 4: Select with Programmatic Options
+
+```javascript
+let form = ts.Form.instance('project');
+// Set options (array of [value, label] pairs or Option objects)
+form.category.input.options = [
+ ['opt1', 'Option 1'],
+ ['opt2', 'Option 2']
+];
+
+// Clear all options
+form.category.input.clear();
+
+// Read current value
+let selected = form.category.input.value;
+```
+
+## Pattern 5: Field Visibility and Error State
+
+```javascript
+let form = ts.Form.instance('user');
+
+// Hide a field
+form.email.visible = false;
+form.email.hidden = true; // equivalent
+
+// Show a field
+form.email.visible = true;
+
+// Bulk visibility
+form.set_field_visibility([form.email, form.role], false);
+
+// Error state
+form.email.has_error = true; // adds 'has-error' class
+form.email.has_error = false; // removes it
+
+// Reset field (clear value, remove error, remove help text)
+form.email.reset();
+form.email.reset('default@example.com'); // reset with default value
+```
+
+## Pattern 6: Change Events
+
+`FormSelect` and `FormCheckbox` use the `changeListener` mixin, which triggers
+`on_change` when the user interacts with the element.
+
+```javascript
+class FilterForm extends ts.Form {
+ constructor(opts) {
+ super(opts);
+ this.status = new ts.FormField({
+ form: this,
+ name: 'status',
+ input: ts.FormSelect
+ });
+ // Listen for selection changes
+ this.status.input.on('on_change', function(inst, evt) {
+ console.log('Status changed to:', inst.value);
+ });
+ }
+}
+```
+
+## Pattern 7: Template-Based Form Inputs
+
+For forms built with `compile_template`, use `t-prop` attributes to create
+`InputProperty` bindings:
+
+```javascript
+class InlineForm extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ ts.compile_template(this, `
+
+
+
+
+ `, opts.container);
+ // this.name is now an InputProperty
+ // this.count is an InputProperty with number extraction
+ }
+
+ on_name(val) {
+ console.log('Name changed to:', val);
+ }
+
+ on_count(val) {
+ console.log('Count changed to:', val); // val is a number
+ }
+}
+```
+
+## Pattern 8: Ajax Form Submission
+
+Forms marked with `class="ajax"` or `ajax:form="true"` are submitted via a
+hidden iframe. The server response calls `ts.ajax.form()` to update the DOM.
+
+```html
+
+```
+
+The server-side handler processes the form and returns an HTML page in the
+iframe that calls:
+
+```html
+
+```
+
+## Complete Example
+
+```html
+
+```
+
+```javascript
+class TaskForm extends ts.Form {
+ constructor(opts) {
+ super(opts);
+ this.title = new ts.FormField({
+ form: this, name: 'title', input: ts.FormInput
+ });
+ this.priority = new ts.FormField({
+ form: this, name: 'priority', input: ts.FormSelect
+ });
+ this.done = new ts.FormField({
+ form: this, name: 'done', input: ts.FormCheckbox
+ });
+
+ this.priority.input.on('on_change', this.on_priority_change.bind(this));
+ }
+
+ on_priority_change(inst, evt) {
+ if (inst.value === 'high') {
+ this.title.elem.addClass('text-danger');
+ } else {
+ this.title.elem.removeClass('text-danger');
+ }
+ }
+
+ validate() {
+ let valid = true;
+ if (!this.title.input.value.trim()) {
+ this.title.has_error = true;
+ valid = false;
+ }
+ return valid;
+ }
+
+ reset_all() {
+ this.title.reset();
+ this.priority.input.value = 'medium';
+ this.done.input.checked = false;
+ }
+}
+
+$(function() {
+ ts.ajax.register(function(context) {
+ ts.Form.initialize(context, TaskForm, 'task');
+ }, true);
+});
+```
+
+## Pitfalls
+
+1. **Element IDs must follow the naming convention** (`#form-{name}`,
+ `#input-{form}-{field}`, `#field-{form}-{field}`). If elements aren't found,
+ pass them explicitly via `opts.elem`.
+
+2. **`FormField` wraps both the field container and the input.** Access the
+ input via `field.input`. The field itself provides visibility and error state.
+
+3. **`FormField` accepts an input class or instance.** Pass `ts.FormInput` (class)
+ and it creates the instance. Pass `new ts.FormRemoteSelect(...)` (instance)
+ for pre-configured inputs.
+
+4. **`FormRemoteSelect.fetch()` is async.** The select options are populated
+ when the HTTP request completes.
+
+5. **`Form.initialize()` silently returns if the form element is not found**
+ in the given context. This is by design — forms may not be present on
+ every page.
diff --git a/.ai/create-widget.md b/.ai/create-widget.md
new file mode 100644
index 0000000..60d2c4c
--- /dev/null
+++ b/.ai/create-widget.md
@@ -0,0 +1,235 @@
+# Creating Custom Widgets
+
+This guide explains how to create custom widgets using treibstoff's widget system.
+
+## Context
+
+Treibstoff provides a widget hierarchy based on `Widget` → `HTMLWidget` → `SVGContext`.
+Widgets support parent/child relationships, ancestor lookup via `acquire()`, and
+integrate with the property binding and motion tracking systems.
+
+## Key API
+
+| Class | Purpose |
+|-------|---------|
+| `ts.Widget` | Base widget with parent/child hierarchy and `acquire()` |
+| `ts.HTMLWidget` | Widget wrapping a DOM element with CSS properties (x, y, width, height) |
+| `ts.SVGContext` | Widget wrapping an SVG element with SVG helper methods |
+| `ts.Property` | Observable property — triggers `on_name(val)` on change |
+| `ts.compile_template` | Parse HTML template and wire DOM references onto widget |
+
+## Pattern: Widget Lifecycle
+
+The standard lifecycle is: **constructor → compile() → bind() → destroy()**.
+
+```javascript
+import ts from 'treibstoff';
+
+class MyWidget extends ts.Widget {
+ constructor(opts) {
+ // 1. Establish hierarchy
+ super({parent: opts.parent});
+
+ // 2. Acquire context from ancestors (e.g. SVGContext)
+ this.ctx = this.acquire(ts.SVGContext);
+
+ // 3. Create reactive properties
+ new ts.DataProperty(this, 'x', {val: opts.data.x});
+ new ts.Property(this, 'selected', false);
+
+ // 4. Build DOM/SVG
+ this.compile();
+
+ // 5. Attach event listeners
+ this.bind();
+ }
+
+ compile() {
+ this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem);
+ this.bg = this.ctx.svg_elem('rect', {
+ x: 0, y: 0, width: 100, height: 50, fill: '#eee'
+ }, this.elem);
+ }
+
+ bind() {
+ // set_scope comes from Motion (inherited by Widget)
+ this.set_scope(this.elem, this.ctx.elem);
+ }
+
+ // Auto-called when 'x' property changes
+ on_x(val) {
+ this.ctx.svg_attrs(this.elem, {
+ transform: `translate(${val} 0)`
+ });
+ }
+
+ // Auto-called when 'selected' property changes
+ on_selected(val) {
+ this.ctx.svg_attrs(this.bg, {
+ fill: val ? '#cdf' : '#eee'
+ });
+ }
+
+ destroy() {
+ this.elem.remove();
+ }
+}
+```
+
+## Complete Example: HTML Widget with Template
+
+```javascript
+import ts from 'treibstoff';
+
+class Panel extends ts.HTMLWidget {
+ constructor(opts) {
+ super({
+ parent: opts.parent,
+ elem: opts.elem // jQuery wrapped DOM element
+ });
+
+ // Reactive properties bound to CSS
+ new ts.CSSProperty(this, 'opacity', {val: 1});
+
+ // Compile inner template
+ ts.compile_template(this, `
+
+
+ `, opts.elem);
+
+ // After compile_template:
+ // this.header → DOM reference
+ // this.title_elem → DOM reference
+ // this.body → DOM reference
+ // this.close_btn → ButtonProperty
+ }
+
+ on_close_click() {
+ this.opacity = 0;
+ this.trigger('on_close');
+ }
+}
+
+// Usage:
+let panel = new Panel({
+ parent: app,
+ elem: $('#my-panel')
+});
+panel.on('on_close', function(inst) {
+ console.log('Panel closed');
+});
+```
+
+## Complete Example: SVG Widget with Drag
+
+```javascript
+import ts from 'treibstoff';
+
+class DraggableNode extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ this.ctx = this.acquire(ts.SVGContext);
+
+ new ts.Property(this, 'x', opts.x || 0);
+ new ts.Property(this, 'y', opts.y || 0);
+
+ this.compile();
+ this.bind();
+ }
+
+ compile() {
+ this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem);
+ this.ctx.svg_elem('rect', {
+ width: 80, height: 40, rx: 4, fill: '#4a90d9'
+ }, this.elem);
+ }
+
+ bind() {
+ // mousedown on element, mousemove within SVG context
+ this.set_scope(this.elem, this.ctx.elem);
+ }
+
+ // Motion handlers (inherited from Widget → Motion)
+ down(evt) {
+ this._start_x = this.x;
+ this._start_y = this.y;
+ }
+
+ move(evt) {
+ let dx = evt.pageX - evt.prev_pos.x;
+ let dy = evt.pageY - evt.prev_pos.y;
+ this.x = this._start_x + dx;
+ this.y = this._start_y + dy;
+ }
+
+ on_x(val) {
+ this._update_position();
+ }
+
+ on_y(val) {
+ this._update_position();
+ }
+
+ _update_position() {
+ this.ctx.svg_attrs(this.elem, {
+ transform: `translate(${this.x} ${this.y})`
+ });
+ }
+}
+```
+
+## Pattern: acquire() for Context Lookup
+
+`acquire(ClassName)` traverses the widget's ancestors and returns the first
+instance of the given class. This is the standard way to access shared context.
+
+```javascript
+class ToolbarButton extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ // Find the nearest SVGContext ancestor
+ this.ctx = this.acquire(ts.SVGContext);
+ // Find a custom application-level ancestor
+ this.app = this.acquire(Application);
+ }
+}
+```
+
+## Pattern: Adding/Removing Children
+
+```javascript
+let parent = new ts.Widget({parent: null});
+let child = new ts.Widget({parent: null});
+
+parent.add_widget(child);
+// child.parent === parent
+// parent.children includes child
+
+parent.remove_widget(child);
+// child.parent === null
+```
+
+## Pitfalls
+
+1. **Always pass `{parent: ...}` to the Widget constructor.** The parent can be
+ `null` for root widgets.
+
+2. **Call `super(opts)` before accessing `this`.** The parent property is set up
+ in the Widget constructor.
+
+3. **`acquire()` returns `null` if no ancestor matches.** Always check the
+ result if the context might not exist.
+
+4. **`set_scope()` cannot be called during a motion sequence.** It throws if
+ called while mousedown is active.
+
+5. **HTMLWidget expects a jQuery-wrapped element** in `opts.elem`.
+
+6. **SVGContext creates its own `` element** from `opts.name`. The parent
+ widget must have an `elem` property (jQuery-wrapped) for the SVG to attach to.
+
+7. **Widget extends Motion**, so every widget can track mouse events via
+ `set_scope()`, `down()`, `move()`, `up()` — even if you don't need drag.
diff --git a/.ai/drag-and-drop.md b/.ai/drag-and-drop.md
new file mode 100644
index 0000000..996e329
--- /dev/null
+++ b/.ai/drag-and-drop.md
@@ -0,0 +1,277 @@
+# Drag and Drop (Native HTML5 DnD)
+
+This guide explains how to implement drag-and-drop interactions using
+treibstoff's `DnD` class.
+
+## Context
+
+The `DnD` class wraps the native HTML5 Drag and Drop API into treibstoff's
+event system. It is the DnD counterpart to `Motion` (which handles mouse
+tracking). Where `Motion` uses `down/move/up` phases, `DnD` uses
+`dragstart/dragover/dragleave/drop/dragend` phases.
+
+Each draggable widget creates its own `DnD` instance. Cross-instance
+coordination (knowing *what* is being dragged when a drop occurs) is handled
+via the class-level `DnD._drag_source` reference.
+
+## Key API
+
+| Member | Description |
+|--------|-------------|
+| `set_scope(drag, drop)` | Bind drag events on `drag` element, drop events on `drop` element |
+| `reset_scope()` | Unbind all event handlers and clear scopes |
+| `DnD._drag_source` | Class-level reference to the DnD instance currently being dragged |
+| `trigger('dragstart', evt)` | Fired when drag begins |
+| `trigger('dragover', evt)` | Fired when dragged over drop target (`evt.source` = drag source) |
+| `trigger('dragleave', evt)` | Fired when drag leaves drop target |
+| `trigger('drop', evt)` | Fired on drop (`evt.source` = drag source) |
+| `trigger('dragend', evt)` | Fired when drag ends (cleanup) |
+
+## DnD Lifecycle
+
+```
+User starts dragging element
+ → _dragstart: sets DnD._drag_source = this, calls dataTransfer.setData
+ → triggers 'dragstart' event
+
+Dragged over drop target (repeated)
+ → _dragover: calls preventDefault() (required to allow drop)
+ → sets evt.source = DnD._drag_source
+ → triggers 'dragover' event
+
+Drag leaves drop target
+ → triggers 'dragleave' event
+
+Drop on target
+ → _drop: calls preventDefault()
+ → sets evt.source = DnD._drag_source
+ → triggers 'drop' event
+
+Drag ends (on the dragged element)
+ → _dragend: clears DnD._drag_source = null
+ → triggers 'dragend' event
+```
+
+**`evt.source`** is set on `dragover` and `drop` events. It references the
+`DnD` instance that initiated the drag, enabling cross-instance communication.
+
+## Comparison with Motion
+
+| Motion | DnD | Purpose |
+|--------|-----|---------|
+| `set_scope(down, move)` | `set_scope(drag, drop)` | Bind scopes |
+| `reset_state()` | `reset_scope()` | Cleanup |
+| `trigger('down', evt)` | `trigger('dragstart', evt)` | Interaction begins |
+| `trigger('move', evt)` | `trigger('dragover', evt)` | Interaction continues |
+| `trigger('up', evt)` | `trigger('drop', evt)` | Interaction ends |
+| `evt.motion` (was it a drag?) | `evt.source` (who is being dragged?) | Event annotation |
+| Mouse-based, custom tracking | Native HTML5 DnD API | Underlying mechanism |
+
+## Pattern 1: Single Element (Drag and Drop on Same Element)
+
+```javascript
+import ts from 'treibstoff';
+
+let elem = $('#my-item');
+let dnd = new ts.DnD();
+dnd.set_scope(elem, elem);
+
+dnd.on('dragstart', function(inst, evt) {
+ evt.originalEvent.dataTransfer.effectAllowed = 'move';
+ elem.addClass('dragging');
+});
+
+dnd.on('dragover', function(inst, evt) {
+ elem.addClass('drag-over');
+});
+
+dnd.on('dragleave', function(inst, evt) {
+ elem.removeClass('drag-over');
+});
+
+dnd.on('drop', function(inst, evt) {
+ elem.removeClass('drag-over');
+ console.log('Dropped! Source:', evt.source);
+});
+
+dnd.on('dragend', function(inst, evt) {
+ elem.removeClass('dragging');
+});
+```
+
+## Pattern 2: Separate Drag Handle and Drop Zone
+
+Use different elements for drag and drop scopes, e.g. a header as drag
+handle and the entire card as drop zone.
+
+```javascript
+let header = group.find('.card-header');
+let card = group.find('.card');
+
+let dnd = new ts.DnD();
+dnd.set_scope(header, card);
+
+dnd.on('dragstart', function(inst, evt) {
+ evt.originalEvent.dataTransfer.effectAllowed = 'move';
+ card.addClass('dragging');
+});
+
+dnd.on('drop', function(inst, evt) {
+ card.removeClass('drag-over');
+ // evt.source is the DnD instance of whatever was dragged
+ handle_drop(evt.source);
+});
+
+dnd.on('dragend', function(inst, evt) {
+ card.removeClass('dragging');
+});
+```
+
+## Pattern 3: Cross-Instance Drag and Drop
+
+Each widget has its own DnD instance. When item A is dragged onto item B,
+B's drop handler receives A's DnD instance via `evt.source`.
+
+```javascript
+class SortableItem extends ts.Events {
+ constructor(elem) {
+ super();
+ this.elem = elem;
+ this.dnd = new ts.DnD();
+ this.dnd.set_scope(elem, elem);
+
+ this.dnd.on('dragstart', (inst, evt) => {
+ evt.originalEvent.dataTransfer.effectAllowed = 'move';
+ this.elem.addClass('dragging');
+ });
+
+ this.dnd.on('dragover', (inst, evt) => {
+ this.elem.addClass('drag-over');
+ });
+
+ this.dnd.on('dragleave', (inst, evt) => {
+ this.elem.removeClass('drag-over');
+ });
+
+ this.dnd.on('drop', (inst, evt) => {
+ this.elem.removeClass('drag-over');
+ // evt.source is the DnD instance of the dragged item
+ // Use a map to resolve DnD instance → widget data
+ this.trigger('on_drop_received', evt.source);
+ });
+
+ this.dnd.on('dragend', (inst, evt) => {
+ this.elem.removeClass('dragging');
+ });
+ }
+
+ destroy() {
+ this.dnd.reset_scope();
+ }
+}
+```
+
+The parent/orchestrator maintains a `Map` to resolve which widget
+a DnD instance belongs to:
+
+```javascript
+class SortableList extends ts.Events {
+ constructor() {
+ super();
+ this._dnd_map = new Map();
+ this.items = [];
+ }
+
+ add_item(data, container) {
+ let item = new SortableItem(/* ... */);
+ this._dnd_map.set(item.dnd, {id: data.id, type: 'item'});
+
+ item.on('on_drop_received', (inst, source_dnd) => {
+ let drag_data = this._dnd_map.get(source_dnd);
+ if (drag_data) {
+ this.handle_reorder(drag_data, data);
+ }
+ });
+
+ this.items.push(item);
+ }
+}
+```
+
+## Pattern 4: Default Handler Methods
+
+Like Motion's `down()`/`move()`/`up()` default handlers, DnD supports
+instance methods as default handlers:
+
+```javascript
+let dnd = new ts.DnD();
+dnd.dragstart = function(evt) {
+ console.log('drag started');
+};
+dnd.drop = function(evt) {
+ console.log('dropped, source:', evt.source);
+};
+dnd.set_scope(drag_elem, drop_elem);
+```
+
+## Scope Variants
+
+| Drag Scope | Drop Scope | Use Case |
+|------------|------------|----------|
+| `elem` | `elem` | Sortable item (drag and drop on same element) |
+| `header` | `card` | Group card with drag handle |
+| `elem` | `null` | Drag-only (source element, no drop target) |
+| `null` | `elem` | Drop-only (target element, not draggable itself) |
+
+## Event Flow: Item A Dropped on Item B
+
+```
+User drags Item A onto Item B:
+ → Item A: DnD._dragstart()
+ → DnD._drag_source = item_a.dnd
+ → item_a.dnd triggers 'dragstart'
+ → Item A adds .dragging class
+
+ → Item B: DnD._dragover()
+ → evt.source = item_a.dnd (from DnD._drag_source)
+ → item_b.dnd triggers 'dragover'
+ → Item B adds .drag-over class
+
+ → Item B: DnD._drop()
+ → evt.source = item_a.dnd
+ → item_b.dnd triggers 'drop'
+ → Item B handler: trigger('on_drop_received', evt.source)
+ → Parent resolves source via _dnd_map, executes reorder
+
+ → Item A: DnD._dragend()
+ → DnD._drag_source = null
+ → item_a.dnd triggers 'dragend'
+ → Item A removes .dragging class
+```
+
+## Pitfalls
+
+1. **`set_scope()` resets the previous scope** automatically. Calling it again
+ re-binds to the new elements (same behavior as `Motion.set_scope()`).
+
+2. **`reset_scope()` is safe to call** without a prior `set_scope()`. No error
+ is thrown.
+
+3. **Firefox requires `dataTransfer.setData()`** in the `dragstart` handler.
+ The DnD class handles this automatically with an empty string.
+
+4. **`preventDefault()` is required** in `dragover` to allow drops. The DnD
+ class calls it automatically on `evt.originalEvent`.
+
+5. **`evt.source` is only set on `dragover` and `drop` events.** It is not
+ available on `dragstart`, `dragleave`, or `dragend`.
+
+6. **`DnD._drag_source` is cleared on `dragend`.** If you need the source
+ reference after dragend, capture it during the `drop` event.
+
+7. **`set_scope(drag, null)` sets up drag-only** (no drop target). Useful for
+ source-only elements. Similarly, `set_scope(null, drop)` sets up a
+ drop-only target.
+
+8. **`reset_scope()` removes the `draggable` attribute** from the drag element.
+ This is different from Motion where no DOM attributes are involved.
diff --git a/.ai/event-handling.md b/.ai/event-handling.md
new file mode 100644
index 0000000..42368b8
--- /dev/null
+++ b/.ai/event-handling.md
@@ -0,0 +1,353 @@
+# Event Handling
+
+This guide explains how to use treibstoff's event system, DOM listeners,
+keyboard state tracking, and event suppression.
+
+## Context
+
+Nearly every class in treibstoff extends `Events`, which provides a pub/sub
+event dispatcher. On top of this, `create_listener` creates classes that bridge
+DOM events to treibstoff events, and `KeyState` tracks keyboard modifier keys.
+
+## Key API
+
+| Class/Function | Purpose |
+|----------------|---------|
+| `ts.Events` | Base pub/sub dispatcher — `on()`, `off()`, `trigger()` |
+| `ts.create_listener(event, base)` | Factory for DOM event listener classes |
+| `ts.ClickListener` | Pre-built click listener class |
+| `ts.clickListener` | Pre-built click listener mixin |
+| `ts.ChangeListener` | Pre-built change listener class |
+| `ts.changeListener` | Pre-built change listener mixin |
+| `ts.KeyState` | Keyboard modifier state tracker |
+
+## Pattern 1: Basic Events
+
+```javascript
+import ts from 'treibstoff';
+
+class Model extends ts.Events {
+ constructor() {
+ super();
+ }
+
+ save() {
+ // do save work...
+ this.trigger('on_save', {id: 42});
+ }
+}
+
+let model = new Model();
+
+// Subscribe
+let handler = function(inst, data) {
+ console.log('Saved:', data.id); // inst = model, data = {id: 42}
+};
+model.on('on_save', handler);
+
+// Trigger
+model.save(); // logs: "Saved: 42"
+
+// Unsubscribe specific handler
+model.off('on_save', handler);
+
+// Unsubscribe all handlers for event
+model.off('on_save');
+```
+
+**Key points:**
+- `on()` returns `this` for chaining: `obj.on('a', fn1).on('b', fn2)`
+- `off()` returns `this` for chaining
+- Duplicate subscribers are silently ignored (same function registered twice)
+- `trigger(event, ...args)` passes all extra arguments to subscribers
+- Subscribers receive `(instance, ...args)` — the emitting instance is always first
+
+## Pattern 2: Default Event Handlers
+
+If a method with the event name exists on the instance, it's called first.
+
+```javascript
+class Widget extends ts.Events {
+ constructor() {
+ super();
+ }
+
+ // Called when trigger('on_update', val) fires
+ on_update(val) {
+ console.log('Default handler:', val);
+ }
+}
+
+let w = new Widget();
+w.on('on_update', function(inst, val) {
+ console.log('External handler:', val);
+});
+w.trigger('on_update', 'data');
+// logs: "Default handler: data"
+// logs: "External handler: data"
+```
+
+## Pattern 3: Bind from Options
+
+Shortcut for subscribing to events from a constructor options object.
+
+```javascript
+class Overlay extends ts.Events {
+ constructor(opts) {
+ super();
+ this.bind_from_options(['on_open', 'on_close'], opts);
+ }
+}
+
+let ol = new Overlay({
+ on_open: function(inst) { console.log('opened'); },
+ on_close: function(inst) { console.log('closed'); }
+});
+ol.trigger('on_open'); // logs: "opened"
+```
+
+## Pattern 4: Event Suppression
+
+Batch operations without triggering events, then fire a single summary event.
+
+```javascript
+class DataStore extends ts.Events {
+ constructor() {
+ super();
+ }
+
+ reload(data) {
+ this.suppress_events(() => {
+ this.delete_all(); // no events fired
+ this._data = data;
+ this.create_all(); // no events fired
+ });
+ this.trigger('data_changed', data); // single event after batch
+ }
+}
+```
+
+During `suppress_events(fn)`, all calls to `trigger()` are no-ops.
+
+## Pattern 5: Click Listener (Base Class)
+
+```javascript
+class ToggleButton extends ts.ClickListener {
+ constructor(elem) {
+ // elem must be a jQuery-wrapped DOM element
+ super({elem: elem});
+ this.active = false;
+ }
+
+ on_click(evt) {
+ this.active = !this.active;
+ this.elem.toggleClass('active', this.active);
+ }
+
+ destroy() {
+ // Unbinds the DOM event listener
+ super.destroy();
+ }
+}
+
+let btn = new ToggleButton($('#my-button'));
+```
+
+**How it works:**
+1. Constructor binds `click` event on `elem`
+2. On click, triggers `on_click` as a treibstoff event
+3. `on_click(evt)` method is called as the default handler
+4. External subscribers can also listen: `btn.on('on_click', fn)`
+5. `destroy()` unbinds the DOM event
+
+## Pattern 6: Listener as Mixin
+
+Use when your class already has a base class that extends `Events`.
+
+```javascript
+// Create a click listener mixin
+let clickListener = Base => ts.create_listener('click', Base);
+
+class InteractiveInput extends clickListener(ts.FormInput) {
+ // FormInput extends Events, so this works
+ on_click(evt) {
+ this.elem.select();
+ }
+}
+```
+
+The pre-built mixins are:
+- `ts.clickListener(Base)` — click events
+- `ts.changeListener(Base)` — change events
+
+## Pattern 7: Custom Listener
+
+```javascript
+// Create a custom listener for any DOM event
+let DblClickListener = ts.create_listener('dblclick');
+
+class EditableLabel extends DblClickListener {
+ constructor(elem) {
+ super({elem: elem});
+ }
+
+ on_dblclick(evt) {
+ this.elem.attr('contenteditable', 'true');
+ this.elem.focus();
+ }
+}
+
+// As a mixin:
+let dblclickListener = Base => ts.create_listener('dblclick', Base);
+```
+
+## Pattern 8: KeyState (Keyboard Modifiers)
+
+Track modifier keys (Ctrl, Shift, Alt, Enter, Escape, Delete) globally.
+
+```javascript
+class Editor extends ts.Events {
+ constructor() {
+ super();
+ // Optional filter: return true to suppress the event
+ this.key_state = new ts.KeyState(function(evt) {
+ // Don't track keys when a text input is focused
+ return evt.target.tagName === 'INPUT';
+ });
+
+ this.key_state.on('keydown', this.on_keydown.bind(this));
+ this.key_state.on('keyup', this.on_keyup.bind(this));
+ }
+
+ on_keydown(key_state, evt) {
+ if (key_state.ctrl && evt.keyCode === 65) {
+ // Ctrl+A: select all
+ evt.preventDefault();
+ this.select_all();
+ }
+ if (key_state.delete) {
+ this.delete_selected();
+ }
+ if (key_state.esc) {
+ this.deselect_all();
+ }
+ }
+
+ on_keyup(key_state, evt) {
+ // modifier released
+ }
+
+ destroy() {
+ this.key_state.unload(); // remove window keydown/keyup listeners
+ }
+}
+```
+
+**Available modifier properties:**
+| Property | Key Code | Key |
+|----------|----------|-----|
+| `ctrl` | 17 | Control |
+| `shift` | 16 | Shift |
+| `alt` | 18 | Alt |
+| `enter` | 13 | Enter |
+| `esc` | 27 | Escape |
+| `delete` | 46 | Delete |
+
+These are boolean flags — `true` while the key is held down, `false` when released.
+
+## Pattern 9: Lifecycle Integration with Ajax
+
+Listeners automatically call `ts.ajax.attach(this, elem)` in their constructor.
+When the DOM element is replaced by an Ajax operation, `destroy()` is called
+automatically.
+
+```javascript
+class AutoCleanWidget extends ts.ClickListener {
+ constructor(elem) {
+ super({elem: elem});
+ // ts.ajax.attach(this, elem) is called internally
+ this.tooltip = new ExternalTooltip(elem[0]);
+ }
+
+ on_click(evt) {
+ this.tooltip.toggle();
+ }
+
+ destroy() {
+ this.tooltip.dispose();
+ super.destroy();
+ }
+}
+
+// Register via ajax.register for automatic lifecycle management
+$(function() {
+ ts.ajax.register(function(context) {
+ $('.my-widget', context).each(function() {
+ new AutoCleanWidget($(this));
+ });
+ }, true);
+});
+```
+
+## Complete Example
+
+```javascript
+import ts from 'treibstoff';
+
+class InteractivePanel extends ts.ClickListener {
+ constructor(elem) {
+ super({elem: elem});
+ this.key_state = new ts.KeyState();
+ this.key_state.on('keydown', this.on_keydown.bind(this));
+ }
+
+ on_click(evt) {
+ if (this.key_state.ctrl) {
+ // Ctrl+Click: add to selection
+ this.trigger('on_multi_select');
+ } else {
+ // Normal click: single select
+ this.trigger('on_select');
+ }
+ }
+
+ on_keydown(ks, evt) {
+ if (ks.esc) {
+ this.trigger('on_deselect');
+ }
+ }
+
+ destroy() {
+ this.key_state.unload();
+ super.destroy();
+ }
+}
+
+let panel = new InteractivePanel($('#panel'));
+panel.on('on_select', (inst) => console.log('Selected'));
+panel.on('on_multi_select', (inst) => console.log('Multi-selected'));
+panel.on('on_deselect', (inst) => console.log('Deselected'));
+```
+
+## Pitfalls
+
+1. **`create_listener` requires the base class to extend `Events`** (or be
+ `Events` itself). It throws if the base doesn't inherit from `Events`.
+
+2. **Listeners require `this.elem`** (a jQuery-wrapped element). It must be
+ set before the listener constructor runs — either via `opts.elem` or by the
+ superclass.
+
+3. **Always call `destroy()`** (or `super.destroy()` in subclasses) to unbind
+ DOM events. Failing to do so causes memory leaks.
+
+4. **KeyState binds to `window`.** Only one KeyState should be active at a time
+ (per logical keyboard context). Call `unload()` when done.
+
+5. **`suppress_events()` is synchronous.** If the callback throws, events
+ remain suppressed. The implementation resets the flag after the function
+ returns.
+
+6. **Event subscriber functions receive `(instance, ...args)`** — the emitting
+ object is always the first argument. Default method handlers on the instance
+ receive only `(...args)` (no instance prefix, since `this` is available).
diff --git a/.ai/http-requests.md b/.ai/http-requests.md
new file mode 100644
index 0000000..21d33c2
--- /dev/null
+++ b/.ai/http-requests.md
@@ -0,0 +1,259 @@
+# HTTP Requests
+
+This guide explains how to make HTTP requests using treibstoff's `HTTPRequest`
+class and `http_request` convenience function.
+
+## Context
+
+Treibstoff wraps jQuery's `$.ajax` with automatic spinner management, error
+display, 403 redirect handling, and URL query parameter parsing. The
+`http_request` function is the primary API for application code.
+
+## Key API
+
+| Class/Function | Purpose |
+|----------------|---------|
+| `ts.http_request(opts)` | Execute HTTP request (convenience function) |
+| `ts.HTTPRequest` | Request class with spinner and error handling |
+| `ts.spinner` | Loading spinner singleton |
+
+## Pattern 1: Basic GET Request
+
+```javascript
+ts.http_request({
+ url: '/api/items',
+ type: 'json',
+ success: function(data, status, request) {
+ console.log('Items:', data);
+ }
+});
+```
+
+**Defaults:**
+- `type`: `'html'`
+- `method`: `'GET'`
+- `cache`: `false`
+- Spinner is shown during request
+- On error: `ts.show_error()` with status code
+
+## Pattern 2: POST Request
+
+```javascript
+ts.http_request({
+ url: '/api/items/42',
+ method: 'POST',
+ type: 'json',
+ params: {
+ title: 'Updated Title',
+ status: 'active'
+ },
+ success: function(data, status, request) {
+ console.log('Updated:', data);
+ }
+});
+```
+
+## Pattern 3: URL with Query Parameters
+
+Query parameters in the URL are automatically parsed and merged with `params`.
+If the same key exists in both, `params` takes precedence.
+
+```javascript
+ts.http_request({
+ url: '/api/items?page=1&size=10',
+ type: 'json',
+ params: {
+ size: 25 // overrides size=10 from URL
+ },
+ success: function(data) {
+ // request sent to /api/items with params: {page: '1', size: 25}
+ }
+});
+```
+
+## Pattern 4: Custom Error Handling
+
+```javascript
+ts.http_request({
+ url: '/api/items/42',
+ type: 'json',
+ success: function(data) {
+ console.log('OK:', data);
+ },
+ error: function(request, status, error) {
+ if (status === 404) {
+ console.log('Item not found');
+ } else {
+ ts.show_error(`Request failed: ${status} ${error}`);
+ }
+ }
+});
+```
+
+**Default error behavior:**
+- HTTP 403 → Redirect to `/login`
+- Other errors → `ts.show_error()` with status and message
+- Request abort (status 0) → Silently ignored
+
+## Pattern 5: Without Spinner
+
+```javascript
+ts.http_request({
+ url: '/api/heartbeat',
+ type: 'json',
+ spinner: null, // disable spinner
+ success: function(data) {
+ // background request without UI feedback
+ }
+});
+```
+
+## Pattern 6: Custom 403 Redirect
+
+```javascript
+ts.http_request({
+ url: '/api/admin/settings',
+ type: 'json',
+ default_403: '/unauthorized', // custom redirect path
+ success: function(data) {
+ console.log('Settings:', data);
+ }
+});
+```
+
+## Pattern 7: Using HTTPRequest Class Directly
+
+For multiple requests sharing the same configuration:
+
+```javascript
+let request = new ts.HTTPRequest({
+ spinner: ts.spinner,
+ default_403: '/login'
+});
+
+request.execute({
+ url: '/api/items',
+ type: 'json',
+ success: function(data) {
+ console.log(data);
+ }
+});
+
+request.execute({
+ url: '/api/categories',
+ type: 'json',
+ success: function(data) {
+ console.log(data);
+ }
+});
+```
+
+## Pattern 8: Spinner Management
+
+The spinner tracks a display count — multiple concurrent requests are handled
+properly.
+
+```javascript
+// Manual spinner control
+ts.spinner.show(); // count: 1 → spinner appears
+ts.spinner.show(); // count: 2 → still showing
+ts.spinner.hide(); // count: 1 → still showing
+ts.spinner.hide(); // count: 0 → spinner disappears
+
+// Force hide (resets count)
+ts.spinner.hide(true); // count: 0, spinner removed immediately
+```
+
+## Complete Example
+
+```javascript
+import ts from 'treibstoff';
+
+class ItemService {
+ constructor(base_url) {
+ this.base_url = base_url;
+ }
+
+ list(params, callback) {
+ ts.http_request({
+ url: this.base_url,
+ type: 'json',
+ params: params,
+ success: callback
+ });
+ }
+
+ get(id, callback) {
+ ts.http_request({
+ url: `${this.base_url}/${id}`,
+ type: 'json',
+ success: callback,
+ error: function(req, status, error) {
+ if (status === 404) {
+ ts.show_warning('Item not found.');
+ } else {
+ ts.show_error(`Failed to load item: ${error}`);
+ }
+ }
+ });
+ }
+
+ save(id, data, callback) {
+ ts.http_request({
+ url: `${this.base_url}/${id}`,
+ method: 'POST',
+ type: 'json',
+ params: data,
+ success: function(response) {
+ ts.show_info('Item saved.');
+ callback(response);
+ }
+ });
+ }
+
+ delete(id, callback) {
+ ts.show_dialog({
+ title: 'Delete Item',
+ message: 'Are you sure?',
+ on_confirm: function() {
+ ts.http_request({
+ url: `${this.base_url}/${id}`,
+ method: 'POST',
+ type: 'json',
+ params: {action: 'delete'},
+ success: function(response) {
+ ts.show_info('Item deleted.');
+ callback(response);
+ }
+ });
+ }.bind(this)
+ });
+ }
+}
+
+let items = new ItemService('/api/items');
+items.list({page: 1}, function(data) {
+ console.log('Items:', data);
+});
+```
+
+## Pitfalls
+
+1. **Query parameters in the URL are parsed automatically.** Don't manually
+ parse them — pass the full URL with query string and use `params` for
+ additional/overriding parameters.
+
+2. **The spinner is shown/hidden automatically** for every request. If you
+ don't want it, pass `spinner: null`.
+
+3. **Error handler receives `(request, status, error)`** where `status` is
+ the numeric HTTP status code (not the jQuery status string).
+
+4. **Requests with status 0 (aborted) are silently ignored** — the error
+ callback is not called and the spinner is force-hidden.
+
+5. **`http_request` creates a new `HTTPRequest` instance per call.** For
+ shared configuration, instantiate `HTTPRequest` directly.
+
+6. **Caching is disabled by default** (`cache: false`). Set `cache: true`
+ for cacheable GET requests.
diff --git a/.ai/motion-tracking.md b/.ai/motion-tracking.md
new file mode 100644
index 0000000..35b1ee3
--- /dev/null
+++ b/.ai/motion-tracking.md
@@ -0,0 +1,307 @@
+# Motion Tracking (Drag, Resize, Select)
+
+This guide explains how to implement drag, resize, and selection interactions
+using treibstoff's `Motion` class.
+
+## Context
+
+The `Motion` class tracks mouse interactions in three phases: `down` (mousedown),
+`move` (mousemove), and `up` (mouseup). It separates the "down scope" (where
+mousedown is detected) from the "move scope" (where mousemove is tracked),
+enabling flexible interaction patterns like grab handles, resize corners, and
+rubber-band selection.
+
+`Widget` extends `Motion`, so every widget can use motion tracking without
+additional setup.
+
+## Key API
+
+| Member | Description |
+|--------|-------------|
+| `set_scope(down, move)` | Set DOM scopes for motion tracking |
+| `reset_state()` | Reset internal motion state |
+| `trigger('down', evt)` | Fired on mousedown |
+| `trigger('move', evt)` | Fired on mousemove (evt has `prev_pos` and `motion`) |
+| `trigger('up', evt)` | Fired on mouseup (evt has `motion` flag) |
+
+## Motion Lifecycle
+
+```
+mousedown on down_scope
+ → _mousedown: stores initial position, binds mousemove on move_scope
+ → triggers 'down' event
+
+mousemove on move_scope (repeated)
+ → _mousemove: sets evt.prev_pos and evt.motion
+ → triggers 'move' event
+ → updates prev_pos
+
+mouseup on document
+ → _mouseup: unbinds mousemove, sets evt.motion flag
+ → triggers 'up' event
+ → reset_state()
+```
+
+**`evt.motion`** is `false` if the mouse didn't actually move (just a click),
+`true` if it did. Use this to distinguish clicks from drags.
+
+**`evt.prev_pos`** contains `{x, y}` from the previous move event (or the
+mousedown position for the first move). Use it to calculate deltas.
+
+## Pattern 1: Simple Drag
+
+```javascript
+import ts from 'treibstoff';
+
+class DraggableBox extends ts.Motion {
+ constructor(elem) {
+ super();
+ this._elem = elem;
+ this._offset = {x: 0, y: 0};
+ // mousedown and mousemove on the same element
+ this.set_scope(elem, elem);
+ }
+
+ down(evt) {
+ this._offset = {
+ x: evt.pageX - parseInt(this._elem.style.left || 0),
+ y: evt.pageY - parseInt(this._elem.style.top || 0)
+ };
+ }
+
+ move(evt) {
+ this._elem.style.left = (evt.pageX - this._offset.x) + 'px';
+ this._elem.style.top = (evt.pageY - this._offset.y) + 'px';
+ }
+
+ up(evt) {
+ if (evt.motion) {
+ console.log('Dragged to:', this._elem.style.left, this._elem.style.top);
+ } else {
+ console.log('Clicked (no drag)');
+ }
+ }
+}
+```
+
+## Pattern 2: Resize Handle
+
+Use a narrow down scope (the grab handle) and a wider move scope (the document)
+so the mouse can move freely during resize.
+
+```javascript
+class Resizer extends ts.Motion {
+ constructor(handle, target) {
+ super();
+ this._handle = handle;
+ this._target = target;
+ this._initial_w = 0;
+ this._initial_h = 0;
+ // mousedown on the handle, mousemove on the entire document
+ this.set_scope(handle, document);
+ }
+
+ down(evt) {
+ this._initial_w = this._target.offsetWidth;
+ this._initial_h = this._target.offsetHeight;
+ this._start_x = evt.pageX;
+ this._start_y = evt.pageY;
+ }
+
+ move(evt) {
+ let dx = evt.pageX - this._start_x;
+ let dy = evt.pageY - this._start_y;
+ this._target.style.width = Math.max(50, this._initial_w + dx) + 'px';
+ this._target.style.height = Math.max(50, this._initial_h + dy) + 'px';
+ }
+
+ up(evt) {
+ if (evt.motion) {
+ console.log('Resized to:', this._target.style.width, this._target.style.height);
+ }
+ }
+}
+```
+
+## Pattern 3: Rubber-Band Selection
+
+Down on the background element, move within a container scope.
+
+```javascript
+class BoxSelect extends ts.Motion {
+ constructor(container) {
+ super();
+ this._container = container;
+ this._rect = null;
+ // mousedown on container, mousemove within container
+ this.set_scope(container, container);
+ }
+
+ down(evt) {
+ this._start = {x: evt.pageX, y: evt.pageY};
+ this._rect = document.createElement('div');
+ this._rect.className = 'selection-rect';
+ this._container.appendChild(this._rect);
+ }
+
+ move(evt) {
+ let x = Math.min(this._start.x, evt.pageX);
+ let y = Math.min(this._start.y, evt.pageY);
+ let w = Math.abs(evt.pageX - this._start.x);
+ let h = Math.abs(evt.pageY - this._start.y);
+ Object.assign(this._rect.style, {
+ left: x + 'px', top: y + 'px',
+ width: w + 'px', height: h + 'px'
+ });
+ }
+
+ up(evt) {
+ if (this._rect) {
+ this._container.removeChild(this._rect);
+ this._rect = null;
+ }
+ if (evt.motion) {
+ // Calculate selection area and find items within
+ console.log('Selection from', this._start, 'to', {x: evt.pageX, y: evt.pageY});
+ }
+ }
+}
+```
+
+## Pattern 4: SVG Widget Drag
+
+When used inside a Widget hierarchy with SVGContext:
+
+```javascript
+class DraggableNode extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ this.ctx = this.acquire(ts.SVGContext);
+
+ new ts.Property(this, 'x', opts.x || 0);
+ new ts.Property(this, 'y', opts.y || 0);
+
+ this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem);
+ this.ctx.svg_elem('rect', {
+ width: 80, height: 40, fill: '#4a90d9'
+ }, this.elem);
+
+ // mousedown on this element, mousemove within SVG viewport
+ this.set_scope(this.elem, this.ctx.elem);
+ }
+
+ down(evt) {
+ this._drag_start = {x: evt.pageX, y: evt.pageY};
+ this._pos_start = {x: this.x, y: this.y};
+ }
+
+ move(evt) {
+ let dx = evt.pageX - this._drag_start.x;
+ let dy = evt.pageY - this._drag_start.y;
+ this.x = this._pos_start.x + dx;
+ this.y = this._pos_start.y + dy;
+ }
+
+ on_x(val) { this._update_transform(); }
+ on_y(val) { this._update_transform(); }
+
+ _update_transform() {
+ this.ctx.svg_attrs(this.elem, {
+ transform: `translate(${this.x} ${this.y})`
+ });
+ }
+}
+```
+
+## Pattern 5: Using Motion Events Instead of Methods
+
+Instead of overriding `down/move/up` methods, you can subscribe to events:
+
+```javascript
+let motion = new ts.Motion();
+motion.set_scope(downElem, moveElem);
+
+motion.on('down', function(inst, evt) {
+ console.log('mousedown at', evt.pageX, evt.pageY);
+});
+
+motion.on('move', function(inst, evt) {
+ let dx = evt.pageX - evt.prev_pos.x;
+ let dy = evt.pageY - evt.prev_pos.y;
+ console.log('delta:', dx, dy);
+});
+
+motion.on('up', function(inst, evt) {
+ console.log('mouseup, was drag:', evt.motion);
+});
+```
+
+## Scope Variants
+
+| Down Scope | Move Scope | Use Case |
+|------------|------------|----------|
+| `elem` | `elem` | Simple drag within the element |
+| `handle` | `document` | Resize via grab handle (wide movement range) |
+| `elem` | `container` | Drag within a bounded area (e.g. SVG viewport) |
+| `background` | `background` | Rubber-band selection on canvas |
+
+## Complete Example: Pan and Zoom
+
+```javascript
+class Pane extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ this.ctx = this.acquire(ts.SVGContext);
+ this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem);
+
+ // Pan: drag on SVG background
+ this.set_scope(this.ctx.elem, this.ctx.elem);
+ }
+
+ down(evt) {
+ this._pan_start = {
+ x: this.ctx.xyz.x,
+ y: this.ctx.xyz.y
+ };
+ }
+
+ move(evt) {
+ let dx = evt.pageX - evt.prev_pos.x;
+ let dy = evt.pageY - evt.prev_pos.y;
+ this.ctx.xyz.x = this._pan_start.x + dx;
+ this.ctx.xyz.y = this._pan_start.y + dy;
+ this._apply_transform();
+ }
+
+ _apply_transform() {
+ let xyz = this.ctx.xyz;
+ this.ctx.svg_attrs(this.elem, {
+ transform: `translate(${xyz.x} ${xyz.y}) scale(${xyz.z})`
+ });
+ }
+}
+```
+
+## Pitfalls
+
+1. **`set_scope()` cannot be called during a motion sequence.** It throws
+ `'Attempt to set motion scope while handling'` if mousedown is active.
+
+2. **`set_scope()` unbinds the previous scope** automatically. Calling it
+ again re-binds to the new elements.
+
+3. **`mouseup` is always bound to `document`**, not to the move scope. This
+ ensures the up event is captured even if the mouse leaves the move scope.
+
+4. **`evt.stopPropagation()` is called** in all three handlers. This prevents
+ parent elements from receiving the same mouse events.
+
+5. **If `move` scope is `null`**, only `down` and `up` are tracked (no
+ `move` events). Useful for click-only detection with motion awareness.
+
+6. **`evt.prev_pos`** is only available in `move` events. In `down`, use
+ `evt.pageX/pageY` directly. In `up`, `evt.motion` tells you if movement
+ occurred.
+
+7. **Widget extends Motion**, so `set_scope()`, `down()`, `move()`, `up()`
+ are available on all widgets without explicit inheritance.
diff --git a/.ai/overlays-dialogs.md b/.ai/overlays-dialogs.md
new file mode 100644
index 0000000..9dcbb8d
--- /dev/null
+++ b/.ai/overlays-dialogs.md
@@ -0,0 +1,266 @@
+# Overlays, Dialogs, and Messages
+
+This guide explains how to create modal overlays, confirmation dialogs, and
+message popups using treibstoff.
+
+## Context
+
+Treibstoff provides a Bootstrap-compatible modal overlay system with stacking
+support (multiple overlays at once), lifecycle events, and Ajax integration.
+
+## Key API
+
+| Class/Function | Purpose |
+|----------------|---------|
+| `ts.Overlay` | Base modal overlay with header, body, footer |
+| `ts.Message` | Overlay with message content and close button |
+| `ts.Dialog` | Confirmation dialog with OK/Cancel buttons |
+| `ts.show_message(opts)` | Show a message overlay |
+| `ts.show_info(message)` | Show an info message |
+| `ts.show_warning(message)` | Show a warning message |
+| `ts.show_error(message)` | Show an error message |
+| `ts.show_dialog(opts)` | Show a confirmation dialog |
+| `ts.get_overlay(uid)` | Get an open overlay by its UID |
+| `ts.ajax.overlay(opts)` | Load server content into an overlay |
+
+## Pattern 1: Quick Messages
+
+```javascript
+// Info message
+ts.show_info('Operation completed successfully.');
+
+// Warning
+ts.show_warning('This action cannot be undone.');
+
+// Error
+ts.show_error('Error: Connection timeout ');
+
+// Custom message with title and flavor
+ts.show_message({
+ title: 'Import Results',
+ message: '42 records imported.
3 records skipped.
',
+ flavor: 'info',
+ css: 'modal-lg' // optional: Bootstrap size class
+});
+```
+
+**Flavors:** `'info'`, `'warning'`, `'error'` — applied as CSS class on the modal.
+
+## Pattern 2: Confirmation Dialog
+
+```javascript
+ts.show_dialog({
+ title: 'Confirm Delete',
+ message: 'Are you sure you want to delete this item?',
+ on_confirm: function(inst) {
+ // inst is the Dialog instance
+ console.log('User confirmed');
+ delete_item(42);
+ }
+});
+```
+
+The dialog shows OK and Cancel buttons. OK triggers `on_confirm` and closes.
+Cancel just closes.
+
+## Pattern 3: Custom Overlay
+
+```javascript
+let overlay = new ts.Overlay({
+ uid: 'my-overlay', // optional, auto-generated if omitted
+ title: 'Custom Overlay',
+ content: 'Loading...
',
+ flavor: 'info',
+ css: 'my-custom-class',
+ container: $('#overlay-container'), // defaults to $('body')
+ on_open: function(inst) {
+ console.log('Overlay opened');
+ },
+ on_close: function(inst) {
+ console.log('Overlay closed');
+ }
+});
+
+overlay.open();
+
+// Later:
+overlay.close();
+```
+
+**Overlay DOM structure after `open()`:**
+```html
+
+```
+
+## Pattern 4: Programmatic Content Update
+
+```javascript
+let overlay = new ts.Overlay({title: 'Loading...'});
+overlay.open();
+
+// Update body content
+overlay.body.html('New content loaded
');
+
+// Add footer buttons via compile_template
+ts.compile_template(overlay, `
+ Save
+`, overlay.footer);
+
+overlay.on_save = function() {
+ console.log('Save clicked');
+ overlay.close();
+};
+```
+
+## Pattern 5: Stacked Overlays
+
+Overlays automatically stack with increasing z-index.
+
+```javascript
+let first = new ts.Overlay({title: 'First'});
+first.open();
+
+let second = new ts.Overlay({title: 'Second'});
+second.open();
+// second appears above first (z-index is higher)
+
+second.close();
+// first is still visible
+first.close();
+```
+
+## Pattern 6: Ajax Overlay (Server Content)
+
+Load content from the server into an overlay:
+
+```javascript
+let overlay = ts.ajax.overlay({
+ action: 'editform',
+ target: 'http://example.com/items/42/edit',
+ title: 'Edit Item',
+ css: 'overlay-form',
+ on_close: function(inst) {
+ // Refresh the item list after edit
+ ts.ajax.trigger({
+ name: 'contextchanged',
+ selector: '#item-list',
+ target: '/items'
+ });
+ }
+});
+
+// Overlay UID for later reference
+let uid = overlay.uid;
+```
+
+**Close by UID:**
+```javascript
+ts.ajax.overlay({close: true, uid: uid});
+```
+
+## Pattern 7: Lookup Open Overlay
+
+```javascript
+let overlay = ts.get_overlay('my-overlay');
+if (overlay) {
+ overlay.body.html('Updated content
');
+} else {
+ console.log('Overlay not found or not open');
+}
+```
+
+## Pattern 8: Custom Dialog Subclass
+
+```javascript
+class ConfirmWithInput extends ts.Dialog {
+ constructor(opts) {
+ super(opts);
+ this.input = $(' ');
+ this.body.append(this.input);
+ }
+
+ on_ok_btn_click() {
+ let value = this.input.val();
+ this.close();
+ this.trigger('on_confirm', value);
+ }
+}
+
+let dialog = new ConfirmWithInput({
+ title: 'Enter Name',
+ message: 'Please provide a name:',
+ on_confirm: function(inst, value) {
+ console.log('Name entered:', value);
+ }
+});
+dialog.open();
+```
+
+## Complete Example
+
+```javascript
+import ts from 'treibstoff';
+
+function deleteItemWithConfirm(itemId) {
+ ts.show_dialog({
+ title: 'Delete Item',
+ message: `Are you sure you want to delete item #${itemId}?`,
+ on_confirm: function() {
+ ts.http_request({
+ url: `/api/items/${itemId}`,
+ method: 'POST',
+ type: 'json',
+ params: {action: 'delete'},
+ success: function(data) {
+ ts.show_info('Item deleted successfully.');
+ ts.ajax.action({
+ name: 'itemlist',
+ selector: '#item-list',
+ mode: 'inner',
+ url: '/items',
+ params: {}
+ });
+ }
+ });
+ }
+ });
+}
+```
+
+## Pitfalls
+
+1. **Overlays use `compile_template` internally.** The overlay's `wrapper`,
+ `backdrop`, `elem`, `body`, `footer` are all available as properties after
+ construction.
+
+2. **`close()` calls `ajax_destroy` on the wrapper** before removing it from
+ the DOM. This properly cleans up any Ajax-bound widgets inside the overlay.
+
+3. **`show_message/info/warning/error` auto-focus** the first button on open.
+
+4. **The `body` class `modal-open`** is added when an overlay opens and removed
+ when the last visible overlay closes. This prevents body scrolling.
+
+5. **`get_overlay(uid)` returns `null`** if the element doesn't exist or has
+ no overlay data. Always null-check the result.
+
+6. **Dialog's OK button handler** fires `on_confirm` after closing. Subscribe
+ to `on_confirm` via the constructor options, not via a method override.
+
+7. **Overlay UIDs** can be specified or auto-generated. When using Ajax overlays,
+ the UID is sent as `ajax.overlay-uid` parameter to the server.
diff --git a/.ai/overview.md b/.ai/overview.md
new file mode 100644
index 0000000..3e5febd
--- /dev/null
+++ b/.ai/overview.md
@@ -0,0 +1,235 @@
+# Treibstoff — Architecture Overview
+
+Treibstoff is a JavaScript utility library for building browser-based applications.
+It provides reactive properties, widget hierarchies, event handling, SVG graphics,
+server-side rendering (SSR) via Ajax, forms, overlays, HTTP requests, WebSocket
+communication, and template parsing.
+
+All classes are exported through a single namespace object (`ts`):
+
+```javascript
+import ts from 'treibstoff';
+```
+
+## Module Map
+
+```
+src/
+├── treibstoff.js # Main aggregator — re-exports all public API members
+├── events.js # Events class — pub/sub event dispatcher (base for most classes)
+├── properties.js # 9 property types: Property, BoundProperty, CSSProperty,
+│ # AttrProperty, InputProperty, DataProperty, TextProperty,
+│ # SVGProperty, ButtonProperty
+├── widget.js # Widget (parent/child hierarchy), HTMLWidget, SVGContext,
+│ # Button, Collapsible, Visibility
+├── listener.js # create_listener() factory, ClickListener, ChangeListener
+├── motion.js # Motion class — mousedown/move/up tracking
+├── clock.js # ClockFrameEvent, ClockTimeoutEvent, ClockIntervalEvent, Clock
+├── keystate.js # KeyState — keyboard modifier tracking (ctrl, shift, alt, etc.)
+├── form.js # Form, FormInput, FormField, FormCheckbox, FormSelect,
+│ # FormRemoteSelect, lookup_form_elem
+├── overlay.js # Overlay, Dialog, Message, show_dialog, show_message,
+│ # show_info, show_warning, show_error, get_overlay
+├── spinner.js # LoadingSpinner (singleton: spinner)
+├── request.js # HTTPRequest (singleton: http_request)
+├── parser.js # Parser, TemplateParser, HTMLParser, SVGParser,
+│ # compile_template, compile_svg, extract_number
+├── utils.js # ~20 utility functions (URL parsing, cookies, SVG, DOM, etc.)
+├── websocket.js # Websocket class + state constants
+├── bootstrap.js # Bootstrap 5 cleanup integration
+└── ssr/ # Server-Side Rendering / Ajax system
+ ├── ajax.js # Ajax class (main orchestrator)
+ ├── util.js # AjaxUtil, AjaxOperation base classes
+ ├── action.js # AjaxAction — server action execution
+ ├── dispatcher.js # AjaxDispatcher — DOM attribute event handler
+ ├── event.js # AjaxEvent — event triggering
+ ├── form.js # AjaxForm — form submission
+ ├── handle.js # AjaxHandle — DOM manipulation & continuation
+ ├── overlay.js # AjaxOverlay — overlay operations
+ ├── parser.js # AjaxParser — parse ajax: attributes from DOM
+ ├── path.js # AjaxPath — browser history management
+ └── destroy.js # AjaxDestroy — DOM cleanup on replacement
+```
+
+## Inheritance Hierarchy
+
+```
+Events
+├── Property
+│ └── BoundProperty
+│ ├── CSSProperty
+│ ├── AttrProperty
+│ ├── TextProperty
+│ ├── DataProperty
+│ ├── InputProperty
+│ ├── SVGProperty
+│ └── ButtonProperty
+├── Motion
+│ └── Widget
+│ ├── HTMLWidget
+│ │ └── SVGContext
+│ └── Button (via ClickListener)
+├── Visibility
+│ └── FormField
+├── KeyState
+├── Overlay
+│ └── Message
+│ └── Dialog
+├── LoadingSpinner
+├── HTTPRequest
+├── Websocket
+├── Clock
+├── ClockFrameEvent / ClockTimeoutEvent / ClockIntervalEvent
+├── FormInput
+│ ├── FormSelect (via changeListener mixin)
+│ │ └── FormRemoteSelect
+│ └── FormCheckbox (via changeListener mixin)
+├── AjaxUtil
+│ ├── AjaxOperation
+│ │ ├── AjaxAction
+│ │ │ └── AjaxOverlay
+│ │ ├── AjaxEvent
+│ │ └── AjaxPath
+│ ├── AjaxHandle
+│ ├── AjaxDispatcher
+│ └── Ajax
+├── Collapsible (plain class, no Events base)
+└── Form (plain class, no Events base)
+```
+
+## Key Design Patterns
+
+1. **Everything extends Events** — Nearly all classes inherit from `Events`,
+ providing `on()`, `off()`, `trigger()`, and `suppress_events()`.
+
+2. **Property binding** — `new ts.Property(this, 'name', default)` defines a
+ getter/setter on the instance. When the value changes, `on_name(val)` is
+ triggered automatically.
+
+3. **Widget hierarchy** — `super({parent: p})` establishes parent-child
+ relationships. `this.acquire(ClassName)` traverses ancestors to find a
+ specific type.
+
+4. **Lifecycle** — constructor → compile() → bind() → update() → destroy().
+
+5. **Singletons** — `ts.spinner`, `ts.http_request`, `ts.ajax`, `ts.clock`.
+
+6. **SSR/Ajax** — HTML attributes (`ajax:bind`, `ajax:action`, `ajax:event`,
+ `ajax:overlay`, `ajax:path`) declaratively bind server interactions.
+ The `AjaxParser` walks the DOM, the `AjaxDispatcher` intercepts events,
+ and operation classes (`AjaxAction`, `AjaxEvent`, `AjaxOverlay`, `AjaxPath`)
+ execute the work.
+
+7. **Template compilation** — `ts.compile_template(widget, html, container)`
+ parses HTML with `t-elem`, `t-prop`, `t-val`, `t-type` attributes to
+ auto-wire DOM references and properties onto a widget instance.
+
+## Public API Members
+
+### Classes
+
+| Class | Module | Description |
+|-------|--------|-------------|
+| `Events` | events.js | Pub/sub event dispatcher |
+| `Property` | properties.js | Observable property |
+| `BoundProperty` | properties.js | Property bound to DOM context |
+| `CSSProperty` | properties.js | Syncs to CSS style |
+| `AttrProperty` | properties.js | Syncs to HTML attribute |
+| `TextProperty` | properties.js | Syncs to textContent |
+| `DataProperty` | properties.js | Syncs to data object |
+| `InputProperty` | properties.js | Syncs to input element value |
+| `SVGProperty` | properties.js | Syncs to SVG attribute |
+| `ButtonProperty` | properties.js | Syncs to button, fires click/down/up |
+| `Widget` | widget.js | Parent/child hierarchy |
+| `HTMLWidget` | widget.js | Widget wrapping a DOM element |
+| `SVGContext` | widget.js | Widget wrapping an SVG element |
+| `Button` | widget.js | Selectable button widget |
+| `Visibility` | widget.js | Show/hide element |
+| `Collapsible` | widget.js | Collapse/expand element |
+| `Motion` | motion.js | Mouse motion tracking |
+| `KeyState` | keystate.js | Keyboard modifier tracking |
+| `Clock` | clock.js | Clock event factory |
+| `ClockFrameEvent` | clock.js | requestAnimationFrame wrapper |
+| `ClockTimeoutEvent` | clock.js | setTimeout wrapper |
+| `ClockIntervalEvent` | clock.js | setInterval wrapper |
+| `Overlay` | overlay.js | Modal overlay |
+| `Message` | overlay.js | Message overlay |
+| `Dialog` | overlay.js | Confirmation dialog |
+| `Form` | form.js | Form container |
+| `FormInput` | form.js | Form input wrapper |
+| `FormField` | form.js | Form field with visibility |
+| `FormCheckbox` | form.js | Checkbox input |
+| `FormSelect` | form.js | Select input |
+| `FormRemoteSelect` | form.js | Select with remote data fetch |
+| `LoadingSpinner` | spinner.js | Loading animation |
+| `HTTPRequest` | request.js | HTTP request handler |
+| `Websocket` | websocket.js | WebSocket wrapper |
+| `Parser` | parser.js | Base DOM walker |
+| `TemplateParser` | parser.js | Template attribute parser |
+| `HTMLParser` | parser.js | HTML template parser |
+| `SVGParser` | parser.js | SVG template parser |
+| `Ajax` | ssr/ajax.js | SSR orchestrator singleton |
+
+### Functions
+
+| Function | Module | Description |
+|----------|--------|-------------|
+| `create_listener(event, base)` | listener.js | Create listener class/mixin |
+| `compile_template(inst, tmpl, container)` | parser.js | Compile HTML template |
+| `compile_svg(inst, tmpl, container)` | parser.js | Compile SVG template |
+| `extract_number(val)` | parser.js | Parse string to number |
+| `http_request(opts)` | request.js | Execute HTTP request |
+| `show_dialog(opts)` | overlay.js | Show confirmation dialog |
+| `show_message(opts)` | overlay.js | Show message overlay |
+| `show_info(message)` | overlay.js | Show info message |
+| `show_warning(message)` | overlay.js | Show warning message |
+| `show_error(message)` | overlay.js | Show error message |
+| `get_overlay(uid)` | overlay.js | Get overlay by UID |
+| `lookup_form_elem(opts, prefix)` | form.js | Find form element |
+| `uuid4()` | utils.js | Generate UUID v4 |
+| `set_default(ob, name, val)` | utils.js | Set default property |
+| `json_merge(base, other)` | utils.js | Shallow merge objects |
+| `parse_url(url)` | utils.js | Parse URL without query |
+| `parse_query(url, as_string)` | utils.js | Parse query parameters |
+| `parse_path(url, include_query)` | utils.js | Parse relative path |
+| `set_visible(elem, visible)` | utils.js | Toggle hidden class |
+| `query_elem(selector, context)` | utils.js | Query element (nullable) |
+| `get_elem(selector, context)` | utils.js | Get element (throws) |
+| `object_by_path(path)` | utils.js | Resolve dotted path on window |
+| `deprecate(dep, sub, as_of)` | utils.js | Log deprecation warning |
+| `create_cookie(name, value, days)` | utils.js | Create browser cookie |
+| `read_cookie(name)` | utils.js | Read browser cookie |
+| `create_svg_elem(name, opts, container)` | utils.js | Create SVG element |
+| `set_svg_attrs(el, opts)` | utils.js | Set SVG attributes |
+| `parse_svg(tmpl, container)` | utils.js | Parse SVG template string |
+| `load_svg(url, callback)` | utils.js | Load SVG from URL |
+| `ajax_destroy(elem)` | ssr/destroy.js | Destroy Ajax-bound element |
+| `register_ajax_destroy_handle(cb)` | ssr/destroy.js | Register destroy callback |
+| `unregister_ajax_destroy_handle(cb)` | ssr/destroy.js | Unregister destroy callback |
+
+### Singletons
+
+| Name | Type | Description |
+|------|------|-------------|
+| `spinner` | `LoadingSpinner` | Global loading spinner |
+| `clock` | `Clock` | Global clock event factory |
+| `ajax` | `Ajax` | Global SSR/Ajax orchestrator |
+
+### Listener Shortcuts
+
+| Name | Description |
+|------|-------------|
+| `ClickListener` | Base class for click listeners |
+| `clickListener` | Mixin factory for click listeners |
+| `ChangeListener` | Base class for change listeners |
+| `changeListener` | Mixin factory for change listeners |
+
+### Constants
+
+| Name | Value | Description |
+|------|-------|-------------|
+| `svg_ns` | `'http://www.w3.org/2000/svg'` | SVG namespace URI |
+| `WS_STATE_CONNECTING` | `0` | WebSocket connecting |
+| `WS_STATE_OPEN` | `1` | WebSocket open |
+| `WS_STATE_CLOSING` | `2` | WebSocket closing |
+| `WS_STATE_CLOSED` | `3` | WebSocket closed |
diff --git a/.ai/property-binding.md b/.ai/property-binding.md
new file mode 100644
index 0000000..7e439d1
--- /dev/null
+++ b/.ai/property-binding.md
@@ -0,0 +1,309 @@
+# Property Binding System
+
+This guide explains how to use treibstoff's reactive property binding system.
+
+## Context
+
+Treibstoff properties create getter/setter pairs on object instances via
+`Object.defineProperty`. When a property value changes, the system automatically
+triggers an `on_{name}` event, enabling reactive updates to the DOM, CSS, SVG
+attributes, or any custom logic.
+
+## Key API
+
+| Class | Constructor | Auto-Syncs To |
+|-------|------------|---------------|
+| `Property` | `new ts.Property(inst, 'name', default)` | Nothing (plain reactive value) |
+| `BoundProperty` | `new ts.BoundProperty(inst, 'name', opts)` | Base for bound properties |
+| `CSSProperty` | `new ts.CSSProperty(inst, 'name', {tgt: 'css-prop'})` | CSS style on element |
+| `AttrProperty` | `new ts.AttrProperty(inst, 'name', {ctx: elem, tgt: 'attr'})` | HTML attribute |
+| `TextProperty` | `new ts.TextProperty(inst, 'name', {ctx: elem})` | Element textContent |
+| `DataProperty` | `new ts.DataProperty(inst, 'name', {val: v})` | Plain data object (`inst.data`) |
+| `InputProperty` | `new ts.InputProperty(inst, 'name', {ctx: inputElem})` | Input element value (two-way) |
+| `SVGProperty` | `new ts.SVGProperty(inst, 'name', {ctx: svgElem, tgt: 'attr'})` | SVG attribute |
+| `ButtonProperty` | `new ts.ButtonProperty(inst, 'name', {ctx: btnElem})` | Button text + click/down/up events |
+
+## Pattern 1: Basic Property
+
+```javascript
+import ts from 'treibstoff';
+
+class Counter extends ts.Events {
+ constructor() {
+ super();
+ new ts.Property(this, 'count', 0);
+ }
+
+ on_count(val) {
+ console.log('Count changed to:', val);
+ }
+}
+
+let counter = new Counter();
+counter.count = 5; // logs: "Count changed to: 5"
+counter.count = 5; // no log — value didn't change
+counter.count = 10; // logs: "Count changed to: 10"
+```
+
+**How it works:**
+1. `new ts.Property(this, 'count', 0)` defines `get count()` and `set count()` on `this`
+2. The setter checks if the value actually changed
+3. If changed, it calls `this.trigger('on_count', val)` which:
+ - Calls `this.on_count(val)` if the method exists
+ - Notifies any external subscribers registered via `this.on('on_count', fn)`
+
+## Pattern 2: CSS Property (DOM Style Binding)
+
+```javascript
+class Box extends ts.HTMLWidget {
+ constructor(opts) {
+ super({parent: opts.parent, elem: opts.elem});
+ // Built-in: x, y, width, height are already CSSProperties
+
+ // Custom CSS property
+ new ts.CSSProperty(this, 'opacity', {val: 1});
+ new ts.CSSProperty(this, 'bg_color', {tgt: 'background-color', val: '#fff'});
+ }
+}
+
+let box = new Box({parent: null, elem: $('#my-box')});
+box.opacity = 0.5; // sets $(elem).css('opacity', 0.5)
+box.bg_color = 'red'; // sets $(elem).css('background-color', 'red')
+box.x = '100px'; // sets $(elem).css('left', '100px')
+box.y = '50px'; // sets $(elem).css('top', '50px')
+```
+
+**Context element:** Defaults to `inst.elem`. Override with `{ctx: otherElem}`.
+
+**Target name:** Defaults to the property name. Override with `{tgt: 'css-property-name'}`.
+
+## Pattern 3: Attribute Property (HTML Attribute Binding)
+
+```javascript
+class Link extends ts.Events {
+ constructor(elem) {
+ super();
+ this.elem = elem;
+ new ts.AttrProperty(this, 'href', {val: '#'});
+ new ts.AttrProperty(this, 'aria_label', {tgt: 'aria-label'});
+ }
+}
+
+let link = new Link($(''));
+link.href = '/items/42'; // calls elem.attr('href', '/items/42')
+link.aria_label = 'View item'; // calls elem.attr('aria-label', 'View item')
+```
+
+## Pattern 4: Text Property (textContent Binding)
+
+```javascript
+class Label extends ts.Events {
+ constructor(elem) {
+ super();
+ this.elem = elem;
+ new ts.TextProperty(this, 'text', {val: 'Hello'});
+ }
+}
+
+let label = new Label($(''));
+label.text = 'World'; // calls elem.text('World')
+```
+
+## Pattern 5: Data Property (Object Sync)
+
+Syncs the property value to a plain data object. Useful for serialization.
+
+```javascript
+class Node extends ts.Events {
+ constructor(data) {
+ super();
+ this.data = data;
+ new ts.DataProperty(this, 'x', {val: data.x});
+ new ts.DataProperty(this, 'y', {val: data.y});
+ new ts.DataProperty(this, 'label', {val: data.label});
+ }
+
+ on_x(val) {
+ this.update_position();
+ }
+
+ on_y(val) {
+ this.update_position();
+ }
+
+ toJSON() {
+ // this.data.x, this.data.y, this.data.label are always in sync
+ return this.data;
+ }
+}
+
+let node = new Node({x: 10, y: 20, label: 'Start'});
+node.x = 50;
+console.log(node.data.x); // 50 — automatically synced
+```
+
+## Pattern 6: Input Property (Two-Way Binding)
+
+Listens for `change` events on the input and updates the property. On set,
+updates the input's value.
+
+```javascript
+class NameField extends ts.Events {
+ constructor(inputElem) {
+ super();
+ new ts.InputProperty(this, 'name', {
+ ctx: inputElem,
+ val: 'Default'
+ });
+ }
+
+ on_name(val) {
+ console.log('Name is now:', val);
+ }
+}
+
+let field = new NameField($('#name-input'));
+field.name = 'Alice'; // updates input value to 'Alice'
+// User types 'Bob' → on_name('Bob') is called
+```
+
+**With extraction/validation:**
+```javascript
+new ts.InputProperty(this, 'age', {
+ ctx: inputElem,
+ val: 0,
+ extract: function(val) {
+ let num = parseInt(val, 10);
+ if (isNaN(num) || num < 0) {
+ throw 'Age must be a positive number';
+ }
+ return num;
+ },
+ state_evt: 'on_age_state' // optional custom state event name
+});
+
+// After extraction error:
+// this._age_property.error === true
+// this._age_property.msg === 'Age must be a positive number'
+// 'on_age_state' event is triggered
+```
+
+## Pattern 7: SVG Property
+
+```javascript
+class Circle extends ts.Events {
+ constructor(svgElem) {
+ super();
+ this.elem = svgElem;
+ new ts.SVGProperty(this, 'cx', {val: 50});
+ new ts.SVGProperty(this, 'cy', {val: 50});
+ new ts.SVGProperty(this, 'r', {val: 25});
+ }
+}
+
+let circle = new Circle(svgCircleElem);
+circle.r = 40; // calls setAttributeNS(null, 'r', 40)
+```
+
+## Pattern 8: Button Property
+
+```javascript
+class Toolbar extends ts.Events {
+ constructor(btnElem) {
+ super();
+ new ts.ButtonProperty(this, 'save', {
+ ctx: btnElem,
+ val: 'Save'
+ });
+ }
+
+ on_save_click() {
+ console.log('Save clicked');
+ }
+
+ on_save_down() {
+ console.log('Mouse down on save');
+ }
+
+ on_save_up() {
+ console.log('Mouse up on save');
+ }
+}
+```
+
+Setting the value updates the button text: `toolbar.save = 'Saving...'`.
+
+## Pattern 9: Cascading Property Updates
+
+Properties can trigger updates on other properties or child widgets.
+
+```javascript
+class ResizableNode extends ts.Events {
+ constructor(data, children) {
+ super();
+ this.data = data;
+ this.children = children;
+ this.min_width = 80;
+
+ new ts.DataProperty(this, 'w', {val: data.w});
+ }
+
+ on_w(val) {
+ // Enforce minimum
+ val = val < this.min_width ? this.min_width : val;
+
+ // Cascade to child layout
+ this.children.layout.width = val;
+
+ // Update SVG representation
+ this.ctx.svg_attrs(this.bg_elem, {width: val});
+
+ // Update sibling
+ this.children.resize_btn.update_position();
+ }
+}
+```
+
+## Pattern 10: BoundProperty Options
+
+All bound properties (subclasses of `BoundProperty`) accept these options:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `ctx` | `inst[ctxa]` | Context element to bind to |
+| `ctxa` | `'elem'` | Attribute name on instance to get context from |
+| `tgt` | `name` | Target attribute/property name |
+| `val` | `undefined` | Initial value |
+
+```javascript
+// Bind 'bg_color' property to CSS 'background-color' on a specific element
+new ts.CSSProperty(this, 'bg_color', {
+ ctx: this.header_elem, // bind to specific element, not this.elem
+ tgt: 'background-color', // CSS property name differs from JS name
+ val: '#f0f0f0' // initial value
+});
+```
+
+## Pitfalls
+
+1. **The `on_name` handler is called via `trigger()`.** The instance must have
+ a `trigger` method (i.e. extend `Events`) for auto-handlers to work. If not,
+ the property still works as a getter/setter but doesn't fire events.
+
+2. **Properties only trigger on actual value changes.** Setting the same value
+ twice does not re-trigger the handler.
+
+3. **BoundProperty context is lazily resolved.** If `ctx` is not passed in opts,
+ it reads `inst[ctxa]` (default: `inst.elem`). If `inst.elem` doesn't exist
+ yet at property creation time, the context is resolved on first access.
+
+4. **InputProperty listens for `change` events**, not `input` events. The
+ handler fires when the user leaves the field or presses Enter, not on
+ every keystroke.
+
+5. **DataProperty defaults to `inst.data`** as its context. Ensure `this.data`
+ exists before creating DataProperty instances.
+
+6. **Property names become getter/setter pairs** via `Object.defineProperty`.
+ They cannot be deleted or redefined. Don't create two properties with the
+ same name on the same instance.
diff --git a/.ai/ssr-integration.md b/.ai/ssr-integration.md
new file mode 100644
index 0000000..3713faf
--- /dev/null
+++ b/.ai/ssr-integration.md
@@ -0,0 +1,274 @@
+# SSR/Ajax Integration via HTML Template Attributes
+
+This guide explains how to wire server-side rendering (SSR) and Ajax interactions
+using declarative HTML attributes in treibstoff.
+
+## Context
+
+Treibstoff's SSR system lets you add Ajax behavior to server-rendered HTML without
+writing JavaScript. The `AjaxParser` walks the DOM looking for `ajax:*` attributes.
+The `AjaxDispatcher` intercepts DOM events and triggers the appropriate operation
+(action, event, overlay, path). The server responds with JSON containing a payload
+(HTML), and the `AjaxHandle` inserts it into the DOM.
+
+## Key Concepts
+
+- **`ajax:bind`** — Which DOM event(s) activate this element (e.g. `click`, `change`)
+- **`ajax:target`** — Server URL to request from
+- **`ajax:action`** — Fetch a tile/view and insert into DOM
+- **`ajax:event`** — Trigger a custom event on other elements
+- **`ajax:overlay`** — Load content into a modal overlay
+- **`ajax:path`** — Update browser URL / history
+- **`ajax:confirm`** — Show confirmation dialog before executing
+- **`ajax:form`** — Mark a form for Ajax submission
+
+## All ajax: Attributes
+
+| Attribute | Purpose | Format |
+|-----------|---------|--------|
+| `ajax:bind` | DOM event(s) to listen for | `"click"`, `"change"`, `"contextchanged"` |
+| `ajax:target` | Server URL for requests | URL string |
+| `ajax:action` | Fetch tile, insert into DOM | `"tilename:#selector:mode"` |
+| `ajax:event` | Trigger custom event | `"eventname:#selector"` |
+| `ajax:overlay` | Load content into overlay | `"actionname"` or `"CLOSE:uid"` |
+| `ajax:overlay-css` | CSS class for overlay | CSS class string |
+| `ajax:overlay-uid` | Unique ID for overlay | UID string |
+| `ajax:overlay-title` | Title for overlay header | Text string |
+| `ajax:path` | Update browser URL | `"href"`, `"target"`, or path string |
+| `ajax:path-target` | Override target for path op | URL string |
+| `ajax:path-action` | Action for history popstate | Same as `ajax:action` |
+| `ajax:path-event` | Event for history popstate | Same as `ajax:event` |
+| `ajax:path-overlay` | Overlay for history popstate | Same as `ajax:overlay` |
+| `ajax:path-overlay-css` | CSS for path overlay | CSS class string |
+| `ajax:path-overlay-uid` | UID for path overlay | UID string |
+| `ajax:path-overlay-title` | Title for path overlay | Text string |
+| `ajax:confirm` | Confirmation message | Text string |
+| `ajax:form` | Mark element for Ajax form | `"true"` |
+
+## Pattern 1: Navigation Link (Click → Fetch → Update DOM)
+
+The most common pattern: clicking a link fetches new content from the server
+and updates a section of the page.
+
+```html
+
+ Item 42
+
+```
+
+**What happens on click:**
+1. Browser default navigation is prevented
+2. `ajax:path="href"` — Browser URL is updated to `/items/42`
+3. `ajax:event="contextchanged:#layout"` — A `contextchanged` event is
+ triggered on `#layout`, carrying the target URL
+4. Elements bound to `contextchanged` (see Pattern 2) will fetch new content
+
+## Pattern 2: Container Auto-Update (Event → Fetch → Replace)
+
+A container that responds to custom events by fetching and replacing its content.
+
+```html
+
+
+```
+
+**What happens when `contextchanged` fires on this element:**
+1. `ajax:action="content:#content:inner"` is parsed as `tilename:selector:mode`
+2. An HTTP request is sent to `{target_url}/ajaxaction?ajax.action=content&ajax.mode=inner&ajax.selector=#content`
+3. The server returns JSON with a `payload` (HTML string)
+4. The payload replaces the inner HTML of `#content`
+5. `ts.ajax.bind()` is called on the new DOM to wire up any Ajax attributes inside
+
+**Modes:**
+- `inner` — Replace the element's inner HTML (element itself stays)
+- `replace` — Replace the entire element (including itself)
+
+## Pattern 3: Overlay Operations
+
+Load server content into a modal overlay.
+
+```html
+
+ Edit
+
+```
+
+**What happens:**
+1. An `Overlay` instance is created with the given CSS, UID, and title
+2. The action `overlayedit` is requested from the server
+3. The response payload is inserted into the overlay's `.modal-body`
+4. The overlay opens
+
+**Close an overlay:**
+```html
+
+ Close
+
+```
+
+## Pattern 4: Dynamic Event Binding (Tables, Pagination)
+
+For elements that need dynamic targets — e.g. a select that changes which
+page of results to show.
+
+```html
+
+ 10 per page
+ 25 per page
+
+```
+
+## Pattern 5: Confirmation Dialog
+
+Show a confirmation dialog before executing an action.
+
+```html
+
+ Delete
+
+```
+
+The user sees a dialog with "Are you sure...?" and OK/Cancel buttons.
+The action only executes if OK is clicked.
+
+**`NONE` selector/mode** means the server action has no DOM update — it
+performs a side effect only (delete, state change, etc.).
+
+## Pattern 6: No-DOM-Update Actions
+
+Actions that only trigger server-side effects without modifying the page.
+
+```html
+
+ English
+
+```
+
+## Pattern 7: Ajax Forms
+
+Forms submitted via Ajax using a hidden iframe.
+
+```html
+
+```
+
+Or explicitly with the attribute:
+```html
+
+```
+
+The form is submitted to a hidden iframe. The server responds by calling
+`ts.ajax.form()` from within the iframe, which triggers DOM updates and
+continuation operations.
+
+## Complete Example: Navigation Layout
+
+```html
+
+
+
+ Dashboard
+
+
+ Items
+
+
+
+
+
+```
+
+**Flow:** Click "Items" → URL changes to `/items` → `contextchanged` fires on
+`#layout` → Both `#content` and `#sidebar` fetch their respective tiles from
+`/items/ajaxaction` → DOM is updated → New content is Ajax-bound.
+
+## Server Response Format
+
+The server must respond to `/ajaxaction` requests with JSON:
+
+```json
+{
+ "mode": "inner",
+ "selector": "#content",
+ "payload": "...new HTML...
",
+ "continuation": [
+ {"type": "path", "path": "/items", "target": "/items", "action": "content:#content:inner"},
+ {"type": "event", "name": "itemsloaded", "selector": "#sidebar"},
+ {"type": "message", "payload": "Saved!", "flavor": "info"}
+ ]
+}
+```
+
+**Continuation types:** `path`, `action`, `event`, `overlay`, `message`.
+
+## Pitfalls
+
+1. **`ajax:bind` is required** for `ajax:action`, `ajax:event`, and
+ `ajax:overlay` to work. Without it, the parser skips the element.
+
+2. **`ajax:target` is the server URL**, not the DOM target. The DOM target
+ is specified in `ajax:action` (the `#selector` part).
+
+3. **Modes `inner` vs `replace`**: Use `inner` when the container element
+ should persist. Use `replace` when the entire element (including its
+ attributes) needs to be swapped.
+
+4. **After DOM replacement, `ts.ajax.bind()` is called automatically** on
+ the new content. Any `ajax:*` attributes in the new HTML will be wired up.
+
+5. **`ajax:path="href"`** reads the path from the element's `href` attribute.
+ `ajax:path="target"` reads from `ajax:target`.
+
+6. **Multiple actions** can be space-separated:
+ `ajax:action="content:#content:inner sidebar:#sidebar:inner"`.
+
+7. **Custom events** (like `contextchanged`) are application-defined. They
+ are standard jQuery events triggered via `ts.ajax.trigger()`.
diff --git a/.ai/ssr-programmatic.md b/.ai/ssr-programmatic.md
new file mode 100644
index 0000000..c5a9819
--- /dev/null
+++ b/.ai/ssr-programmatic.md
@@ -0,0 +1,265 @@
+# SSR/Ajax — Programmatic JavaScript API
+
+This guide explains how to use treibstoff's Ajax system from JavaScript code,
+as opposed to the declarative HTML attribute approach.
+
+## Context
+
+The `ts.ajax` singleton provides methods to programmatically trigger all Ajax
+operations: actions, events, overlays, paths, and forms. This is useful when
+you need dynamic behavior that cannot be expressed through HTML attributes alone.
+
+## Key API
+
+| Method | Purpose |
+|--------|---------|
+| `ts.ajax.action(opts)` | Fetch a tile and insert into DOM |
+| `ts.ajax.trigger(opts)` | Trigger a custom event on DOM elements |
+| `ts.ajax.overlay(opts)` | Load content into a modal overlay |
+| `ts.ajax.path(opts)` | Push/replace browser history entry |
+| `ts.ajax.form(opts)` | Render Ajax form response |
+| `ts.ajax.register(fn, instant)` | Register a binder callback |
+| `ts.ajax.bind(context)` | Parse and bind Ajax attributes in DOM |
+| `ts.ajax.attach(instance, elem)` | Attach JS instance for lifecycle management |
+| `ts.ajax.parse_target(url)` | Parse URL into {url, params, path, query} |
+
+## Pattern 1: Execute an Action
+
+Request a server-side tile and insert its HTML into the DOM.
+
+```javascript
+let target = ts.ajax.parse_target('http://example.com/items?page=2');
+ts.ajax.action({
+ name: 'content', // server-side tile/action name
+ selector: '#content', // DOM element to update
+ mode: 'inner', // 'inner' or 'replace'
+ url: target.url, // URL without query
+ params: target.params // query parameters as object
+});
+```
+
+## Pattern 2: Trigger a Custom Event
+
+Create and dispatch a custom event on matching DOM elements.
+
+```javascript
+ts.ajax.trigger({
+ name: 'contextchanged',
+ selector: '#layout',
+ target: 'http://example.com/items/42',
+ data: {key: 'value'} // optional extra data
+});
+```
+
+Elements bound to `contextchanged` via `ajax:bind` will receive the event
+with `evt.ajaxtarget` and `evt.ajaxdata` properties.
+
+## Pattern 3: Open an Overlay
+
+Load server content into a modal overlay.
+
+```javascript
+let overlay = ts.ajax.overlay({
+ action: 'editform',
+ target: 'http://example.com/items/42/edit',
+ css: 'overlay-form',
+ title: 'Edit Item',
+ on_close: function(inst) {
+ console.log('Overlay closed');
+ }
+});
+
+// The overlay UID for later reference
+let uid = overlay.uid;
+```
+
+**With explicit URL and params:**
+```javascript
+ts.ajax.overlay({
+ action: 'editform',
+ url: 'http://example.com/items/42/edit',
+ params: {mode: 'advanced'},
+ title: 'Edit Item'
+});
+```
+
+**Close an overlay by UID:**
+```javascript
+ts.ajax.overlay({
+ close: true,
+ uid: uid
+});
+```
+
+## Pattern 4: Manage Browser History
+
+Push or replace a browser history entry with associated Ajax operations.
+
+```javascript
+ts.ajax.path({
+ path: '/items/42',
+ target: 'http://example.com/items/42',
+ action: 'content:#content:inner',
+ event: 'contextchanged:#layout'
+});
+```
+
+When the user clicks the back button, the saved `action` and `event` are
+replayed automatically.
+
+**Replace instead of push:**
+```javascript
+ts.ajax.path({
+ path: '/items/42',
+ target: 'http://example.com/items/42',
+ action: 'content:#content:inner',
+ replace: true
+});
+```
+
+## Pattern 5: Register Binder Callbacks
+
+Register JavaScript that runs every time Ajax updates the DOM. This is the
+primary integration point for custom widgets.
+
+```javascript
+$(function() {
+ ts.ajax.register(function(context) {
+ // 'context' is the jQuery-wrapped DOM that was just updated
+ $('.my-widget', context).each(function() {
+ new MyWidget($(this));
+ });
+ }, true); // true = also execute immediately on registration
+});
+```
+
+**Best practice pattern for widget initialization:**
+```javascript
+class ItemList {
+ static initialize(context) {
+ $('.item-list', context).each(function() {
+ new ItemList($(this));
+ });
+ }
+
+ constructor(elem) {
+ this.elem = elem;
+ ts.ajax.attach(this, elem); // register for lifecycle
+ this.setup();
+ }
+
+ setup() {
+ this.elem.find('.item').on('click', this.on_item_click.bind(this));
+ }
+
+ destroy() {
+ // Called automatically when this DOM element is replaced by Ajax
+ this.elem.find('.item').off('click');
+ }
+}
+
+$(function() {
+ ts.ajax.register(ItemList.initialize, true);
+});
+```
+
+## Pattern 6: Attach Instances for Lifecycle Management
+
+When Ajax replaces DOM elements, attached instances get their `destroy()`
+method called automatically.
+
+```javascript
+class Tooltip {
+ constructor(elem) {
+ this.elem = elem;
+ ts.ajax.attach(this, elem);
+ this.tip = new ExternalTooltip(elem[0]);
+ }
+
+ destroy() {
+ this.tip.dispose();
+ }
+}
+```
+
+## Pattern 7: Spinner Management
+
+The loading spinner shows during Ajax requests automatically. For manual
+control:
+
+```javascript
+ts.ajax.spinner.show();
+// ... do work ...
+ts.ajax.spinner.hide();
+
+// Force hide (resets counter):
+ts.ajax.spinner.hide(true);
+```
+
+## Pattern 8: Error Display
+
+```javascript
+ts.show_error('' + error_message + ' ');
+ts.show_warning('Something might be wrong');
+ts.show_info('Operation completed');
+ts.show_message({
+ title: 'Custom Title',
+ message: 'Detailed message',
+ flavor: 'info', // 'info', 'warning', 'error'
+ css: 'modal-xl' // optional size class
+});
+```
+
+## Complete Example: Custom Action with Overlay Feedback
+
+```javascript
+function saveAndNotify(itemId, data) {
+ ts.http_request({
+ url: `/api/items/${itemId}`,
+ method: 'POST',
+ type: 'json',
+ params: data,
+ success: function(response) {
+ // Update the item display
+ ts.ajax.action({
+ name: 'itemdetail',
+ selector: '#item-detail',
+ mode: 'inner',
+ url: `/items/${itemId}`,
+ params: {}
+ });
+ // Trigger context change for sidebar etc.
+ ts.ajax.trigger({
+ name: 'contextchanged',
+ selector: '#layout',
+ target: `/items/${itemId}`
+ });
+ // Update browser URL
+ ts.ajax.path({
+ path: `/items/${itemId}`,
+ target: `/items/${itemId}`,
+ action: 'itemdetail:#item-detail:inner'
+ });
+ }
+ });
+}
+```
+
+## Pitfalls
+
+1. **`ts.ajax.register()` with `instant=true` only fires immediately if
+ `ts.ajax.bind()` has already been called** (i.e. after document ready).
+ If registered before document ready, the callback runs once on initial bind.
+
+2. **`ts.ajax.attach()` requires exactly one DOM element.** If a jQuery
+ collection has 0 or 2+ elements, it throws.
+
+3. **`ts.ajax.parse_target()` returns `{url, params, path, query}`.** Always
+ use this to split a URL before passing to `action()` or `overlay()`.
+
+4. **Continuation operations** from server responses are executed automatically.
+ Ensure server-side code returns proper continuation arrays.
+
+5. **The `ts.ajax.trigger()` method** is the treibstoff-level trigger (Ajax events),
+ not the `Events.trigger()` method. When called on the `ajax` singleton,
+ it dispatches Ajax events on DOM elements.
diff --git a/.ai/svg-graphics.md b/.ai/svg-graphics.md
new file mode 100644
index 0000000..ed0d98a
--- /dev/null
+++ b/.ai/svg-graphics.md
@@ -0,0 +1,332 @@
+# SVG Graphics
+
+This guide explains how to work with SVG using treibstoff's SVGContext, SVG
+utilities, and the property binding system.
+
+## Context
+
+Treibstoff provides `SVGContext` as a widget that wraps an `` element and
+offers helper methods for creating and modifying SVG sub-elements. Combined with
+`SVGProperty`, `Motion`, and the widget hierarchy, it enables building interactive
+SVG-based applications (diagrams, editors, visualizations).
+
+## Key API
+
+| Class/Function | Purpose |
+|----------------|---------|
+| `ts.SVGContext` | Widget wrapping an `` element |
+| `ts.SVGProperty` | Property that syncs to an SVG attribute |
+| `ts.create_svg_elem(name, opts, container)` | Create an SVG element |
+| `ts.set_svg_attrs(el, opts)` | Set attributes on an SVG element |
+| `ts.parse_svg(tmpl, container)` | Parse SVG template string |
+| `ts.compile_svg(inst, tmpl, container)` | Compile SVG template with t-elem |
+| `ts.load_svg(url, callback)` | Load external SVG file |
+| `ts.svg_ns` | SVG namespace URI |
+
+## Pattern 1: Creating an SVG Context
+
+`SVGContext` needs a parent widget with a jQuery-wrapped `elem`:
+
+```javascript
+import ts from 'treibstoff';
+
+class Application extends ts.HTMLWidget {
+ constructor() {
+ super({parent: null, elem: $('#app-container')});
+ this.canvas = new Canvas({parent: this});
+ }
+}
+
+class Canvas extends ts.SVGContext {
+ constructor(opts) {
+ // SVGContext creates an element inside parent.elem
+ super({parent: opts.parent, name: 'canvas'});
+ // this.elem is now the element
+ // this.svg_ns is the SVG namespace
+
+ // Create child SVG elements
+ this.layer = this.svg_elem('g', {class: 'layer'}, this.elem);
+ }
+}
+
+let app = new Application();
+// Result:
+```
+
+## Pattern 2: Creating SVG Elements
+
+```javascript
+// Via SVGContext instance
+let rect = this.ctx.svg_elem('rect', {
+ x: 10, y: 20, width: 100, height: 50,
+ fill: '#4a90d9', rx: 4
+}, parentGroup);
+
+let circle = this.ctx.svg_elem('circle', {
+ cx: 50, cy: 50, r: 25, fill: 'red'
+}, parentGroup);
+
+let path = this.ctx.svg_elem('path', {
+ d: 'M 10 10 L 90 90', stroke: '#333', fill: 'none'
+}, parentGroup);
+
+let text = this.ctx.svg_elem('text', {
+ x: 10, y: 30, 'font-size': '14px'
+}, parentGroup);
+text.textContent = 'Hello SVG';
+
+// Via standalone function (no SVGContext needed)
+let el = ts.create_svg_elem('rect', {width: 50, height: 50});
+container.appendChild(el);
+```
+
+## Pattern 3: Modifying SVG Attributes
+
+```javascript
+// Via SVGContext instance
+this.ctx.svg_attrs(rect, {
+ fill: '#f00',
+ transform: 'translate(10 20)',
+ opacity: 0.8
+});
+
+// Via standalone function
+ts.set_svg_attrs(rect, {
+ width: 200,
+ height: 100
+});
+```
+
+**Width/height validation:** `set_svg_attrs` validates that `width` and `height`
+are non-negative numbers. Invalid values are logged as errors and skipped.
+
+## Pattern 4: SVG Properties (Reactive Binding)
+
+```javascript
+class Circle extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ this.ctx = this.acquire(ts.SVGContext);
+
+ this.elem = this.ctx.svg_elem('circle', {}, this.ctx.elem);
+
+ // SVGProperty syncs to SVG attributes automatically
+ new ts.SVGProperty(this, 'cx', {ctx: this.elem, val: opts.cx || 50});
+ new ts.SVGProperty(this, 'cy', {ctx: this.elem, val: opts.cy || 50});
+ new ts.SVGProperty(this, 'r', {ctx: this.elem, val: opts.r || 25});
+ }
+
+ on_r(val) {
+ // Called when radius changes
+ console.log('Radius:', val);
+ }
+}
+
+let circle = new Circle({parent: canvas, cx: 100, cy: 100, r: 30});
+circle.r = 50; // SVG attribute updates automatically, on_r fires
+```
+
+## Pattern 5: SVG Template Compilation
+
+```javascript
+class Icon extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ this.ctx = this.acquire(ts.SVGContext);
+
+ // compile_svg processes t-elem attributes on SVG elements
+ ts.compile_svg(this, `
+
+
+
+
+ `, this.ctx.elem);
+
+ // After compile_svg:
+ // this.group → element
+ // this.background → element
+ // this.icon_path → element
+ }
+}
+```
+
+## Pattern 6: Two-Layer Pattern (Visible + Hit Area)
+
+For thin elements (lines, paths) that need a wider click target:
+
+```javascript
+class Edge extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ this.ctx = this.acquire(ts.SVGContext);
+
+ let d = `M ${opts.x1} ${opts.y1} L ${opts.x2} ${opts.y2}`;
+
+ // Visible line (thin)
+ this.visible = this.ctx.svg_elem('path', {
+ d: d, stroke: '#333', 'stroke-width': 2, fill: 'none'
+ }, opts.container);
+
+ // Invisible hit area (wide, for easier clicking)
+ this.hitarea = this.ctx.svg_elem('path', {
+ d: d, stroke: 'rgba(0,0,0,0)', 'stroke-width': 12, fill: 'none'
+ }, opts.container);
+
+ // Bind click to the hit area
+ this.set_scope(this.hitarea, null);
+ }
+
+ update(x1, y1, x2, y2) {
+ let d = `M ${x1} ${y1} L ${x2} ${y2}`;
+ this.ctx.svg_attrs(this.visible, {d: d});
+ this.ctx.svg_attrs(this.hitarea, {d: d});
+ }
+}
+```
+
+## Pattern 7: Loading External SVG
+
+```javascript
+ts.load_svg('/assets/icons.svg', function(svg) {
+ // svg is a jQuery-wrapped element
+ $('#icon-container').append(svg);
+});
+```
+
+## Pattern 8: Parsing SVG from String
+
+```javascript
+// Parse SVG markup into elements
+let elements = ts.parse_svg(`
+
+
+`, containerElement);
+
+// elements is an array of SVG DOM elements
+```
+
+## Pattern 9: Pan/Zoom with SVGContext
+
+```javascript
+class ZoomableCanvas extends ts.SVGContext {
+ constructor(opts) {
+ super({parent: opts.parent, name: 'zoomable'});
+ // xyz holds pan (x, y) and zoom (z)
+ // Already initialized: this.xyz = {x: 0, y: 0, z: 1}
+
+ this.content = this.svg_elem('g', {}, this.elem);
+ }
+
+ pan(dx, dy) {
+ this.xyz.x += dx;
+ this.xyz.y += dy;
+ this._apply_transform();
+ }
+
+ zoom(factor, cx, cy) {
+ let old_z = this.xyz.z;
+ this.xyz.z *= factor;
+ // Adjust pan to zoom toward cursor position
+ this.xyz.x = cx - (cx - this.xyz.x) * (this.xyz.z / old_z);
+ this.xyz.y = cy - (cy - this.xyz.y) * (this.xyz.z / old_z);
+ this._apply_transform();
+ }
+
+ _apply_transform() {
+ this.svg_attrs(this.content, {
+ transform: `translate(${this.xyz.x} ${this.xyz.y}) scale(${this.xyz.z})`
+ });
+ }
+}
+```
+
+## Complete Example: Interactive Diagram Node
+
+```javascript
+import ts from 'treibstoff';
+
+class DiagramNode extends ts.Widget {
+ constructor(opts) {
+ super({parent: opts.parent});
+ this.ctx = this.acquire(ts.SVGContext);
+ this.data = opts.data;
+
+ new ts.DataProperty(this, 'x', {val: opts.data.x});
+ new ts.DataProperty(this, 'y', {val: opts.data.y});
+ new ts.DataProperty(this, 'label', {val: opts.data.label});
+ new ts.Property(this, 'selected', false);
+
+ this.compile();
+ this.bind();
+ }
+
+ compile() {
+ this.elem = this.ctx.svg_elem('g', {
+ transform: `translate(${this.x} ${this.y})`
+ }, this.ctx.elem);
+
+ this.bg = this.ctx.svg_elem('rect', {
+ width: 120, height: 40, rx: 4, fill: '#e8e8e8'
+ }, this.elem);
+
+ this.text = this.ctx.svg_elem('text', {
+ x: 60, y: 25, 'text-anchor': 'middle', 'font-size': '13px'
+ }, this.elem);
+ this.text.textContent = this.label;
+ }
+
+ bind() {
+ this.set_scope(this.elem, this.ctx.elem);
+ }
+
+ down(evt) {
+ this._start = {x: evt.pageX, y: evt.pageY};
+ this._pos = {x: this.x, y: this.y};
+ }
+
+ move(evt) {
+ this.x = this._pos.x + (evt.pageX - this._start.x);
+ this.y = this._pos.y + (evt.pageY - this._start.y);
+ }
+
+ on_x() { this._update_transform(); }
+ on_y() { this._update_transform(); }
+
+ on_selected(val) {
+ this.ctx.svg_attrs(this.bg, {
+ fill: val ? '#cce5ff' : '#e8e8e8',
+ stroke: val ? '#007bff' : 'none'
+ });
+ }
+
+ on_label(val) {
+ this.text.textContent = val;
+ }
+
+ _update_transform() {
+ this.ctx.svg_attrs(this.elem, {
+ transform: `translate(${this.x} ${this.y})`
+ });
+ }
+}
+```
+
+## Pitfalls
+
+1. **SVGContext creates its own `` element.** Don't create one manually.
+ The parent widget must have a jQuery-wrapped `elem` property.
+
+2. **SVG elements use `setAttributeNS`**, not jQuery's `.attr()`. Always use
+ `svg_attrs()` or `set_svg_attrs()` to modify SVG attributes.
+
+3. **Width/height must be non-negative.** `set_svg_attrs` validates these and
+ logs an error if invalid.
+
+4. **`svg_elem()` returns a raw DOM element**, not a jQuery-wrapped one.
+ Use it directly with `svg_attrs()` or standard DOM methods.
+
+5. **`acquire(ts.SVGContext)`** finds the nearest SVGContext ancestor in the
+ widget hierarchy. Fails (returns null) if no SVGContext is above.
+
+6. **`ts.svg_ns`** is `'http://www.w3.org/2000/svg'`. Elements must be created
+ with `createElementNS(svg_ns, tag)` — which `create_svg_elem` handles.
diff --git a/.ai/template-parsing.md b/.ai/template-parsing.md
new file mode 100644
index 0000000..359553e
--- /dev/null
+++ b/.ai/template-parsing.md
@@ -0,0 +1,333 @@
+# Template Parsing
+
+This guide explains how to use treibstoff's template and parser system to
+build DOM structures with automatic property and element binding.
+
+## Context
+
+Treibstoff's template system compiles HTML or SVG strings into DOM elements
+while processing special `t-*` attributes. Elements are assigned to widget
+properties, and input/button elements get reactive property bindings
+automatically.
+
+## Key API
+
+| Class/Function | Purpose |
+|----------------|---------|
+| `ts.compile_template(inst, tmpl, container)` | Compile HTML template |
+| `ts.compile_svg(inst, tmpl, container)` | Compile SVG template |
+| `ts.extract_number(val)` | Parse string to number (throws if NaN) |
+| `ts.Parser` | Base DOM walker |
+| `ts.TemplateParser` | Processes `t-elem` attributes |
+| `ts.HTMLParser` | Processes all `t-*` attributes for HTML |
+| `ts.SVGParser` | Processes `t-elem` for SVG elements |
+
+## Template Attributes
+
+| Attribute | Applies To | Purpose |
+|-----------|-----------|---------|
+| `t-elem` | Any element | Assign element to `widget[name]` |
+| `t-prop` | ` `, ``, `` | Create a bound property |
+| `t-val` | With `t-prop` | Initial property value |
+| `t-type` | With `t-prop` on ` ` | Value extractor type (`"number"`) |
+| `t-extract` | With `t-prop` on ` ` | Custom extractor method name |
+| `t-state-evt` | With `t-prop` on ` ` | Custom state event name |
+| `t-options` | `` | JSON array of `[value, label]` pairs |
+| `t-bind-click` | `` | Widget method to call on click |
+| `t-bind-down` | `` | Widget method to call on mousedown |
+| `t-bind-up` | `` | Widget method to call on mouseup |
+
+## Pattern 1: Element References
+
+```javascript
+import ts from 'treibstoff';
+
+class Panel extends ts.Events {
+ constructor(container) {
+ super();
+ ts.compile_template(this, `
+
+ `, container);
+
+ // After compilation:
+ // this.panel → jQuery wrapped
+ // this.header → jQuery wrapped