Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ desktop.ini
perf/*
*.orig
*.bak
*.original.md
.DS_Store
npm-debug.log
node_modules
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ changes — update **both** the Starlight docs (for humans) and the agent guide
(for agents). The agent guide should be token-efficient: dense, code-first,
minimal prose.

**Before staging any doc, verify every package, spec path, and URL it names exists on the target branch.** Pay extra attention to untracked or generated files in the working tree. Full checklist: [`tko.io/public/agents/process.md`](tko.io/public/agents/process.md#never-ship-docs-that-reference-things-that-dont-exist-on-the-target-branch).

## Docs Verification

When validating `tko.io` documentation changes with the local docs site:
Expand All @@ -202,7 +204,9 @@ Avoid scope creep. If an improvement would balloon the PR, file a follow-up issu

## Review Your Own Change Adversarially

Before declaring a change done, steelman the case against it. Ask what could go wrong, what assumption could be false, what future goal it quietly forecloses, what coverage or signal it weakens, who it surprises. Get an independent second pass (a colleague, a subagent where available) on changes that touch framework internals, test coverage, public APIs, or docs the whole team relies on.
Before declaring a change done, steelman the case against it. Ask what could go wrong, what assumption could be false, what future goal it quietly forecloses, what coverage or signal it weakens, who it surprises.

**Adversarial review is mandatory for in-scope changes** (code, tests, public API, agent-facing docs, CI, `tools/build.ts`, `vitest.config.ts`, `biome.json`, landing commit messages). A single pair of eyes (yours) is not enough in a dark factory — the missing human reviewer has to be replaced by a second agent that was not told what "good" looks like and is asked only "where is this wrong?". Spawn a fresh subagent, brief it with the artefact + claim only (no author reasoning), bias toward flagging, verify any findings defensively, and record the outcome at the end of the commit message that introduces the in-scope change — one audit line per in-scope commit, never the PR description (that's for *why* the change exists, not reviewer ceremony). Out of scope: typos, whitespace, comment corrections. Full how-to and audit-trail format: [`tko.io/public/agents/process.md`](tko.io/public/agents/process.md#adversarial-review-is-mandatory).

This is the ceiling on "Always Improve": that section pushes toward *more* in a PR; this one pushes toward *scrutiny* of what's in it. Use both — improve in scope, audit the scope itself here.

Expand All @@ -217,6 +221,7 @@ Failure modes specific to a published low-level framework, worth probing every t
- Patching the symptom, not the root cause
- Unrelated refactors or opportunistic redesigns that balloon the PR (the "Always Improve" bar is *small, low-risk, in-scope* fixes — anything larger belongs in its own PR)
- Silent assumptions about environment, timing, or ordering
- Docs that reference packages, APIs, or spec paths that do not exist on the target branch (see "Agent-First Documentation" → "Never ship docs that reference things that don't exist on the target branch")

If the change doesn't survive a ten-minute attempt to poke holes in it, it's not ready.

Expand Down
74 changes: 74 additions & 0 deletions tko.io/public/agents/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,80 @@ In those cases:
- let the custom binding read observables and update the DOM
- avoid making the custom binding the authoritative owner of app state

## DOM Mutation Containment

DOM mutation and direct DOM-API calls belong inside a `BindingHandler` (a class that extends `LifeCycle` and is registered as a binding). They do not belong in component view models, utility modules, or arbitrary class methods.

Violations (do not do outside a `BindingHandler`):
- `document.createElement`, `document.body.appendChild` — bypasses the binding pipeline
- `element.querySelector` / `querySelectorAll` — except to find a mount root for `applyBindings`
- `element.style.*`, `element.classList.*` — use the `style` / `css` bindings
- `element.addEventListener` — use `click`, `event:{…}`, or a binding handler
- `appendChild` / `insertBefore` / `replaceWith` — use `foreach`, `if`, `template`
- `element.innerHTML = …` — use `text` or (when trusted markup is the point) `html`; also flag XSS if content is external
- `element.focus()` / `element.select()` — use `hasFocus` or a focus-orchestration binding
- `requestAnimationFrame` for DOM scheduling — move inside a binding that owns the frame loop
- Storing `HTMLElement` references as component view-model fields — the element belongs to the binding

Where these calls are safe:
- inside a class that extends `BindingHandler` (its `constructor`/`init`/`update` receive the owning element)
- reading observables whose values drive DOM updates via bindings — that is the whole point
- direct DOM reads in tests/verification code after bindings are active

Binding handlers are the prescribed escape hatch for imperative DOM work: they receive the exact element, participate in the lifecycle, and dispose cleanly. Mutating the DOM from elsewhere creates a second source of truth for DOM state and the reactive graph loses sight of it.

## Component Design: Binding Handlers and Component View Models

### Binding handlers — narrow scope, one DOM task

Each `BindingHandler` subclass should do one thing to its element. A handler that sanitizes HTML *and* walks the DOM for citations *and* renders diagrams *and* rewrites tables should be split into separate handlers, each composable on the same element.

Violations:
- a binding handler performing multiple unrelated DOM operations — split by concern
- using a binding handler where a component with JSX would suffice — prefer declarative JSX when the rendering can be expressed that way

Not a violation:
- a handler that is complex because its single DOM task is inherently complex (rich-text editor, canvas renderer, third-party widget bridge)

### Component view models — rendering only

A component view model (a class registered with `components.register(...)`, typically extending `ComponentABC`) should contain only code that produces its template output. Data transformation, parsing, business rules, and utilities belong in standalone `.ts` files the component imports.

Violations:
- template method exceeding ~80 lines of JSX without decomposition — extract nested child components
- manipulating the DOM directly from a component method — delegate to a binding handler
- non-rendering logic (parsing, domain rules, formatting of structured data) embedded in the component — move to utilities
- instantiating a child component with `params={{ ... }}` wrapping every prop (see "Component Params in JSX" in `/agents/guide.md`)

Not a violation:
- a component that is large because it composes many small child components
- simple inline computeds that only map data for the template (`const label = ko.pureComputed(() => format(value()))`)

## Component Communication via `subscribable`

When a component needs to trigger an imperative action inside a binding handler (for example "print this iframe", "scroll to top", "focus on demand"), pass a `ko.subscribable` owned by the component into the handler. The handler subscribes in its constructor and disposes the subscription in `dispose`.

Do not model the command as an `observable` holding a function (`observable<(() => void) | null>(null)`). Function-valued observables capture closures that may retain DOM references, muddle cleanup, and do not broadcast.

```js
// Component owns the channel and fires events
class PrintableCard extends ComponentABC {
printChannel = new ko.subscribable()
onPrintClick = () => this.printChannel.notifySubscribers(null, 'print')
}

// Binding handler subscribes and disposes with its lifecycle
class PrintIframe extends BindingHandler {
constructor (params) {
super(params)
const channel = this.value
this.addDisposable(channel.subscribe(() => this.$element.contentWindow.print(), null, 'print'))
}
}
```

The element reference stays inside the binding handler, `dispose` cleans up naturally, and multiple elements can listen on the same channel without coordination.

## Security Preference

- Prefer `text` over `html`.
Expand Down
73 changes: 73 additions & 0 deletions tko.io/public/agents/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ ko.when(viewModel.isReady, () => console.log('Ready'))
ko.when(() => viewModel.isReady() && viewModel.hasData()).then(() => console.log('Ready'))
```

### Observables vs computed dependency management

- Bare `ko.observable()` and `ko.observableArray()` are safe anywhere — they are containers and do not re-evaluate.
- Inside a class using the `LifeCycle` mixin (for example `BindingHandler`, `ComponentABC`, or anything produced by `LifeCycle.mixInto`), prefer `this.computed(...)` over standalone `ko.computed(...)` and `this.subscribe(observable, cb)` over `observable.subscribe(cb)` — both are auto-disposed when the instance is torn down.
- In plain view models without `LifeCycle`, `ko.computed(...)` and `observable.subscribe(...)` are fine; call `dispose()` on them when you are done.
- **Never** create `ko.observable()`, `ko.computed()`, or call `.subscribe()` inside a computed's evaluator. The evaluator runs on every dependency change, so new observables/subscriptions pile up and never dispose — memory and CPU grow unbounded. Create them once outside the computed, or in a `LifeCycle`-enabled constructor.
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section states that creating ko.observable() inside a computed evaluator causes instances to pile up and never dispose. In @tko/computed, dependency tracking disposes subscriptions to prior dependencies on each evaluation, so creating an observable inside an evaluator doesn't inherently leak unless you retain references or create unmanaged subscriptions/computeds. Please narrow this guidance to the cases that actually leak (e.g., creating subscriptions or computeds inside the evaluator) or reword it as a performance/clarity anti-pattern rather than an unbounded leak for plain observables.

Suggested change
- **Never** create `ko.observable()`, `ko.computed()`, or call `.subscribe()` inside a computed's evaluator. The evaluator runs on every dependency change, so new observables/subscriptions pile up and never dispose — memory and CPU grow unbounded. Create them once outside the computed, or in a `LifeCycle`-enabled constructor.
- Avoid creating reactive objects inside a computed's evaluator. Creating `ko.computed()` instances or calling `.subscribe()` there can accumulate unmanaged work unless you dispose them manually, because the evaluator runs on every dependency change. Creating a plain `ko.observable()` there does not inherently leak, but it is usually a performance/clarity anti-pattern unless it is purely temporary and you do not retain references to it. Prefer creating observables, computeds, and subscriptions once outside the evaluator, or in a `LifeCycle`-enabled constructor.

Copilot uses AI. Check for mistakes.

## Bindings

Activate with `ko.applyBindings(viewModel, element)`.
Expand Down Expand Up @@ -65,6 +72,69 @@ When the goal is to demonstrate TKO itself, keep the state flow inside observabl
- If an example contrasts reactive models, the counters and highlighted state should also be observable-driven so the example demonstrates the pattern instead of bypassing it.
- The line to avoid is using the DOM itself as the mutable source of truth after bindings are active.

### Reactive styling

Reactive state that drives visual variations can be expressed several ways in TKO. Two common patterns are shown below; they are examples, not an exhaustive list (CSS custom properties written from a computed, classless inline style objects, component-scoped style blocks, and CSS-in-JS tokens are all reasonable too). **What matters more than the specific pattern is that a codebase applies one consistently** for the same kind of state — mixing approaches for identical problems is what hurts readability.

**Example A — computed class name / inline style.** The computed returns the class name or style string directly. The rule sits next to the reactive expression.

```tsx
const className = this.computed(() => this.isActive() ? classes.active : classes.inactive)
return <div class={className}>...</div>
```

**Example B — computed attribute, CSS selector does the switching.** The computed returns a flag value; CSS reads the attribute and applies styles.

```tsx
const activeAttr = this.computed(() => this.isActive() || undefined)
return <div data-active={activeAttr}>...</div>
```

```css
.my-component { opacity: 0.5; }
.my-component[data-active] { opacity: 1; }
```

**Trade-offs between these two.**
- A keeps the rule ("active → this class") visible in the component file; refactors that rename classes stay local. Split visibility if the class body lives in a separate stylesheet.
- B keeps styling in CSS and pairs naturally with the `|| undefined` pattern for binary attributes (see Gotchas); adding a new visual variant means editing CSS, not the component.
- B sidesteps class-name string concatenation inside computeds for complex multi-state styling. A can avoid the same footgun with template literals or a class-map helper; it just doesn't get it for free.
- A reads more directly when one class encapsulates many unrelated properties that B would need multiple attribute selectors to reach.

Classic `data-bind` writers for the same effects: `css:` / `style:` for A, `attr:` for B (`data-bind="attr: { 'data-active': activeAttr }"`).

### JSX scope rule

Use JSX inside component view models (classes registered with `components.register(...)`, typically extending `ComponentABC`) or in functions whose output is immediately passed to `tko.jsx.render(...)` and mounted. Do not leave JSX in standalone utility functions that outlive a component lifecycle — without `LifeCycle`, subscriptions and computeds created for that JSX have no owner to dispose them.

```tsx
// Good: component owns the JSX and its lifecycle
class MyCard extends ComponentABC {
template = () => <div ko-text={this.message} />
}

// Also fine: top-level render for a fixed mount
const { node } = tko.jsx.render(<div ko-text={message} />)
document.getElementById('root').appendChild(node)

// Avoid: JSX returned from a utility module without a clear owner
export const myView = () => <div ko-text={message} />
```

### Component params in JSX

When instantiating a component from JSX, pass each constructor parameter as its own attribute. Do **not** wrap them in a single `params={{ ... }}` attribute — JSX collects attributes into one object and hands that object to the component constructor, so wrapping in `params` delivers `{ params: { … } }` instead of the flat shape the constructor expects.

```tsx
// Bad — wrapped in params
<my-report-card params={{ output, onView, onEdit }} />

// Good — each prop is its own attribute
<my-report-card output={output} onView={onView} onEdit={onEdit} />
```

The constructor receives `{ output, onView, onEdit }` directly.

## Classic data-bind parsing and CSP

Classic `data-bind` parsing is provider-driven. Use `DataBindProvider` when you need binding strings, and combine it with other providers through `MultiProvider` as needed.
Expand Down Expand Up @@ -222,6 +292,9 @@ tko.applyBindings({ removeTodo: t => todos.remove(t) }, root)
- **Observable children in JSX** are reactive — when an observable's value changes, the DOM updates. If it becomes `undefined`, a placeholder comment is rendered.
- **Cannot apply bindings twice** to the same DOM node — throws error.
- **`dispose()` on computed/subscriptions** stops updates. After disposal, reading returns last cached value.
- **HTML-producing computeds** should return `null` for empty output, not `false` or an empty fragment — bindings and JSX observers handle `null` cleanly.
- **Binary HTML attributes** (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) omit the attribute when the value is `null`, `undefined`, or `false`. Use `|| undefined` in computeds to make "no attribute" explicit: `disabled={this.computed(() => shouldBeDisabled() || undefined)}`. Never return the string `"false"` — it keeps the attribute set.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Correct binary-attribute false/null semantics

This gotcha states that binary attributes are omitted when the value is null or false, but packages/utils.jsx/src/JsxObserver.ts removes attributes only for undefined and otherwise stringifies the value via setAttributeNS, so false/null become present attributes ("false"/"null"). For boolean HTML attrs like disabled, presence keeps the attribute active, so code generated from this guidance can leave controls disabled when the computed returns false; please align this rule (and the copied wording in verified-behaviors/utils-jsx.md) with actual runtime behavior.

Useful? React with 👍 / 👎.

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Gotcha says binary HTML attributes are omitted when the value is null, undefined, or false, but JSX attribute setting currently only removes attributes when the value is undefined (other values, including null/false, are stringified and set). Please revise the wording to avoid implying that null/false automatically omit the attribute, and keep the guidance focused on returning undefined (e.g., via || undefined) to ensure removal.

Suggested change
- **Binary HTML attributes** (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) omit the attribute when the value is `null`, `undefined`, or `false`. Use `|| undefined` in computeds to make "no attribute" explicit: `disabled={this.computed(() => shouldBeDisabled() || undefined)}`. Never return the string `"false"` — it keeps the attribute set.
- **Binary HTML attributes** (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) should return `undefined` when you want JSX to remove the attribute. Use `|| undefined` in computeds to make "no attribute" explicit: `disabled={this.computed(() => shouldBeDisabled() || undefined)}`. Do not rely on `null` or `false` to omit the attribute, and never return the string `"false"` — it keeps the attribute set.

Copilot uses AI. Check for mistakes.
- **Prefer named computed variables over inline computeds in JSX.** A computed created inline in JSX is easier to accidentally recreate on each render than one bound to a class field or `const`.

## Testing

Expand Down
Loading
Loading