From 9aa3cfd69a959c5aebdde3e9ae4b4af13311754b Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 06:47:00 -0400 Subject: [PATCH 01/15] Add honeycomb showcase example --- honeycomb.html | 537 ++++++++++++++++++++++++++++++++++ tko.io/public/agents/guide.md | 13 +- 2 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 honeycomb.html diff --git a/honeycomb.html b/honeycomb.html new file mode 100644 index 00000000..96d4a72c --- /dev/null +++ b/honeycomb.html @@ -0,0 +1,537 @@ + + + + + + Honeycomb Locality + + + +
+
+
+
+

Hover one hex. Compare all work vs local work.

+
+ Same visible result. Left compares every visible hex. Right notifies 6 neighbors. +
+
+ +
+
Board Size
+
+
visible hexes on screen
+
+ +
+
Conceptual Virtual DOM Pass · O(n)
+
+
+ board size on each hover
+
+ +
+
Observable Local Update · O(1)
+
+
+ 6 neighbors on each hover
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + + + + diff --git a/tko.io/public/agents/guide.md b/tko.io/public/agents/guide.md index 693f5b05..c3fc3eea 100644 --- a/tko.io/public/agents/guide.md +++ b/tko.io/public/agents/guide.md @@ -6,7 +6,7 @@ Test-backed behavior summaries live under `/agents/verified-behaviors/`. Treat t ```html - + ``` ## Observables @@ -49,6 +49,15 @@ Binding notes: ``` +## Example Discipline + +When the goal is to demonstrate TKO itself, keep the state flow inside observables, computeds, and bindings. + +- Prefer `text`, `css`, `attr`, `event`, `foreach`, and `pureComputed` over manual DOM writes. +- Avoid driving visible state with `textContent`, `innerHTML`, `classList`, or ad-hoc `addEventListener` when bindings can express the same behavior. +- Use custom `bindingHandlers` only for DOM-specific effects that do not belong in the state layer, such as animation, focus, canvas, SVG, or third-party widget integration. +- 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. + ## 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. @@ -211,6 +220,8 @@ tko.applyBindings({ removeTodo: t => todos.remove(t) }, root) Observable writes update DOM synchronously — assert immediately after setting: +Direct DOM reads are appropriate here because this is verification code, not the UI update path itself. + ```js const vm = { msg: ko.observable('Hello') } ko.applyBindings(vm, document.getElementById('app')) From 6f68ea6b291f6655af89859a5805cc30049dcae9 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 07:04:51 -0400 Subject: [PATCH 02/15] Polish honeycomb interaction --- honeycomb.html | 55 +++++++++++++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/honeycomb.html b/honeycomb.html index 96d4a72c..db9fb42d 100644 --- a/honeycomb.html +++ b/honeycomb.html @@ -62,6 +62,7 @@ border-radius: 0 0 18px 18px; background: rgba(10, 16, 25, 0.78); backdrop-filter: blur(10px); + user-select: none; } .title { @@ -79,9 +80,6 @@ margin-top: 2px; color: var(--muted); font-size: 0.8rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .chip { @@ -162,7 +160,6 @@ stroke-width: 1.35; transform-box: fill-box; transform-origin: center; - will-change: transform, filter; --touch-brightness: 1; --touch-saturate: 1; filter: brightness(var(--touch-brightness)) saturate(var(--touch-saturate)); @@ -217,28 +214,26 @@
-

Hover one hex. Compare all work vs local work.

-
- Same visible result. Left compares every visible hex. Right notifies 6 neighbors. -
+

Honeycomb

+
Counting Virtual DOM Comparisons vs Observable Updates
-
Board Size
+
Visible Hexes
-
visible hexes on screen
+
cells currently on screen
-
Conceptual Virtual DOM Pass · O(n)
+
DOM Comparisons · O(n)
-
+ board size on each hover
+
hover: + all visible, click: + all visible × touched
-
Observable Local Update · O(1)
+
Observable Notifications · O(1)
-
+ 6 neighbors on each hover
+
hover: + 6 neighbors, click: + touched cells
@@ -324,8 +319,8 @@

Hover one hex. Compare all work vs local work.

} const source = !!pulse.source - const peakScale = source ? 1.08 : 1.03 - const peakBrightness = source ? 1.42 : 1.22 + const peakScale = source ? 1.05 : 1.02 + const peakBrightness = source ? 1.32 : 1.16 const baseBrightness = parseFloat(getComputedStyle(element).getPropertyValue('--touch-brightness')) || 1 const baseSaturate = parseFloat(getComputedStyle(element).getPropertyValue('--touch-saturate')) || 1 @@ -334,17 +329,17 @@

Hover one hex. Compare all work vs local work.

{ transform: 'scale(1)', filter: `brightness(${baseBrightness}) saturate(${baseSaturate})` }, { transform: `scale(${peakScale})`, - filter: `brightness(${baseBrightness * peakBrightness}) saturate(${baseSaturate * 1.18})`, - offset: 0.32 + filter: `brightness(${baseBrightness * peakBrightness}) saturate(${baseSaturate * 1.28}) contrast(1.06) sepia(0.12)`, + offset: 0.26 }, { - transform: 'scale(1.01)', - filter: `brightness(${baseBrightness * 1.04}) saturate(${baseSaturate * 1.04})`, - offset: 0.68 + transform: 'scale(1)', + filter: `brightness(${baseBrightness * 1.02}) saturate(${baseSaturate * 1.02})`, + offset: 0.58 }, { transform: 'scale(1)', filter: `brightness(${baseBrightness}) saturate(${baseSaturate})` } ], - { duration: source ? 180 : 140, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'none' } + { duration: source ? 150 : 120, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'none' } ) } } @@ -352,9 +347,19 @@

Hover one hex. Compare all work vs local work.

ko.bindingHandlers.hexTone = { update(element, valueAccessor) { const level = ko.unwrap(valueAccessor()) || 0 - const capped = Math.min(level, 12) - const brightness = Math.max(0.46, 1 - capped * 0.05) - const saturate = Math.max(0.7, 1 - capped * 0.02) + const threshold = 8 + const capped = Math.min(level, threshold) + let brightness = Math.max(0.46, 1 - capped * 0.05) + let saturate = Math.max(0.7, 1 - capped * 0.02) + + if (level > threshold) { + const phase = (level - threshold) * 0.75 + brightness += Math.sin(phase) * 0.18 + saturate += Math.sin(phase) * 0.08 + } + + brightness = Math.max(0.46, Math.min(1.08, brightness)) + saturate = Math.max(0.7, Math.min(1.08, saturate)) element.style.setProperty('--touch-brightness', String(brightness)) element.style.setProperty('--touch-saturate', String(saturate)) } From 2d07975b08016000db4a14e3b00d9dd85eab15e3 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 07:19:57 -0400 Subject: [PATCH 03/15] Add docs examples section --- honeycomb.html | 2 +- tko.io/astro.config.mjs | 1 + tko.io/public/examples/honeycomb.html | 542 ++++++++++++++++++ tko.io/public/llms.txt | 4 + tko.io/src/content/docs/examples/honeycomb.md | 26 + tko.io/src/content/docs/examples/index.md | 25 + 6 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 tko.io/public/examples/honeycomb.html create mode 100644 tko.io/src/content/docs/examples/honeycomb.md create mode 100644 tko.io/src/content/docs/examples/index.md diff --git a/honeycomb.html b/honeycomb.html index db9fb42d..73940308 100644 --- a/honeycomb.html +++ b/honeycomb.html @@ -215,7 +215,7 @@

Honeycomb

-
Counting Virtual DOM Comparisons vs Observable Updates
+
Counting Naive Virtual DOM Comparisons vs Observable Updates
diff --git a/tko.io/astro.config.mjs b/tko.io/astro.config.mjs index 4d1b8d30..94713041 100644 --- a/tko.io/astro.config.mjs +++ b/tko.io/astro.config.mjs @@ -29,6 +29,7 @@ export default defineConfig({ sidebar: [ { label: 'Introduction', slug: 'index' }, { label: 'Knockout 3 to 4 Guide', slug: '3to4' }, + { label: 'Examples', autogenerate: { directory: 'examples' } }, { label: 'Bindings', autogenerate: { directory: 'bindings' } }, { label: 'Observables', autogenerate: { directory: 'observables' } }, { label: 'Computed', autogenerate: { directory: 'computed' } }, diff --git a/tko.io/public/examples/honeycomb.html b/tko.io/public/examples/honeycomb.html new file mode 100644 index 00000000..db9fb42d --- /dev/null +++ b/tko.io/public/examples/honeycomb.html @@ -0,0 +1,542 @@ + + + + + + Honeycomb Locality + + + +
+
+
+
+

Honeycomb

+
Counting Virtual DOM Comparisons vs Observable Updates
+
+ +
+
Visible Hexes
+
+
cells currently on screen
+
+ +
+
DOM Comparisons · O(n)
+
+
hover: + all visible, click: + all visible × touched
+
+ +
+
Observable Notifications · O(1)
+
+
hover: + 6 neighbors, click: + touched cells
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + + + + diff --git a/tko.io/public/llms.txt b/tko.io/public/llms.txt index 689020ed..ead773dd 100644 --- a/tko.io/public/llms.txt +++ b/tko.io/public/llms.txt @@ -22,6 +22,8 @@ How to run and verify TKO code. - Agent TSX Scaffold: /agents/sample-tsx.html Minimal in-browser TSX + esbuild scaffold for rapid prototype work. +- Examples: /examples/ + Interactive examples that show update locality and TKO's observable model. ## Use This First @@ -29,6 +31,7 @@ - API usage or authoring pattern: /agents/guide.md - Verification or test flow: /agents/testing.md - Rapid prototype in-browser: /agents/sample-tsx.html +- Conceptual interactive examples: /examples/ ## Build Choice @@ -54,6 +57,7 @@ - Agent Guide: /agents/guide.md (API reference, gotchas, examples) - Agent Testing: /agents/testing.md (how to run and verify TKO code) - Agent TSX Scaffold: /agents/sample-tsx.html (minimal browser TSX + esbuild scaffold for rapid prototype work) +- Examples: /examples/ (interactive examples showing update locality and reactive behavior) - Playground: /playground - GitHub: https://github.com/knockout/tko diff --git a/tko.io/src/content/docs/examples/honeycomb.md b/tko.io/src/content/docs/examples/honeycomb.md new file mode 100644 index 00000000..e66f64c4 --- /dev/null +++ b/tko.io/src/content/docs/examples/honeycomb.md @@ -0,0 +1,26 @@ +--- +title: Honeycomb +description: Count virtual DOM comparisons against observable updates on a dense interactive board. +sidebar: + order: 1 +--- + +# Honeycomb + +This example contrasts two mental models: + +- `Virtual DOM comparisons`: reconsider the visible board to discover what changed. +- `Observable updates`: notify only the local cells affected by the interaction. + +Hover shows a single local update. Click shows a burst of local updates. + +
+ +
+ +[Open the example directly](/examples/honeycomb.html) diff --git a/tko.io/src/content/docs/examples/index.md b/tko.io/src/content/docs/examples/index.md new file mode 100644 index 00000000..49abaace --- /dev/null +++ b/tko.io/src/content/docs/examples/index.md @@ -0,0 +1,25 @@ +--- +title: Examples +description: Interactive examples that show where TKO differs from virtual DOM frameworks. +sidebar: + label: Overview + order: 0 +--- + +# Examples + +These examples are meant to make TKO's model visible. + +- They focus on observables, bindings, and update locality. +- They are intentionally interactive. +- They are designed to show what work is happening, not just what UI appears. + +## Available examples + +- [Honeycomb](/examples/honeycomb/) + +## Why this section exists + +TKO often feels different from virtual DOM frameworks because it connects observable state directly to DOM updates. + +That difference is easier to understand when you can interact with it and watch the cost accumulate. From ab00fb498e576db14a7a493609765296d73bfde5 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 07:36:11 -0400 Subject: [PATCH 04/15] Add odoom example --- tko.io/public/examples/odoom.html | 891 ++++++++++++++++++++++ tko.io/src/content/docs/examples/index.md | 1 + tko.io/src/content/docs/examples/odoom.md | 23 + 3 files changed, 915 insertions(+) create mode 100644 tko.io/public/examples/odoom.html create mode 100644 tko.io/src/content/docs/examples/odoom.md diff --git a/tko.io/public/examples/odoom.html b/tko.io/public/examples/odoom.html new file mode 100644 index 00000000..47dfe32a --- /dev/null +++ b/tko.io/public/examples/odoom.html @@ -0,0 +1,891 @@ + + + + + + odoom + + + +
+
+ +
+
+ +
+
+
+

odoom

+
One-shot by AI: a tiny WebGL corridor crawler with an observable HUD.
+
+ +
+
+
Health
+
+
+
+
Ammo
+
+
+
+
Cells
+
+
+
+
Heading
+
+
+
+
FPS
+
+
+
+ +
+
+ +
+
+

Controls

+
+
+ + +
+
+ + +
+
+ + +
+
W/S move. A/D turn. Shift sprint. Space fires.
+
+
+ +
+

Minimap

+ +
Green cells are pickups. White shows the current facing direction.
+
+
+
+
+ + + + + diff --git a/tko.io/src/content/docs/examples/index.md b/tko.io/src/content/docs/examples/index.md index 49abaace..db21183d 100644 --- a/tko.io/src/content/docs/examples/index.md +++ b/tko.io/src/content/docs/examples/index.md @@ -17,6 +17,7 @@ These examples are meant to make TKO's model visible. ## Available examples - [Honeycomb](/examples/honeycomb/) +- [odoom](/examples/odoom/) ## Why this section exists diff --git a/tko.io/src/content/docs/examples/odoom.md b/tko.io/src/content/docs/examples/odoom.md new file mode 100644 index 00000000..08597ebd --- /dev/null +++ b/tko.io/src/content/docs/examples/odoom.md @@ -0,0 +1,23 @@ +--- +title: odoom +description: A tiny WebGL corridor crawler with an observable HUD and controls. +sidebar: + order: 2 +--- + +# odoom + +`odoom` is a deliberately small experiment: WebGL handles the corridor view, while TKO observables drive the HUD, controls, minimap, and gameplay counters. + +It is not a full Doom clone. It is a compact example of using observables as the state layer around a fast rendering surface. + +
+ +
+ +[Open the example directly](/examples/odoom.html) From c6d062bbae5288a9c0c5d3afc96215acb79691c0 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 09:36:30 -0400 Subject: [PATCH 05/15] Expand and simplify examples site --- tko.io/astro.config.mjs | 13 +- tko.io/public/examples/audio-mixer.html | 930 ++++++++++++ tko.io/public/examples/form-engine.html | 1184 +++++++++++++++ tko.io/public/examples/honeycomb.html | 83 +- tko.io/public/examples/odoom.html | 81 +- tko.io/public/examples/signal-graph.html | 1041 +++++++++++++ tko.io/public/examples/spreadsheet.html | 1290 +++++++++++++++++ tko.io/src/content/docs/examples/honeycomb.md | 26 - tko.io/src/content/docs/examples/index.md | 10 +- tko.io/src/content/docs/examples/odoom.md | 23 - 10 files changed, 4622 insertions(+), 59 deletions(-) create mode 100644 tko.io/public/examples/audio-mixer.html create mode 100644 tko.io/public/examples/form-engine.html create mode 100644 tko.io/public/examples/signal-graph.html create mode 100644 tko.io/public/examples/spreadsheet.html delete mode 100644 tko.io/src/content/docs/examples/honeycomb.md delete mode 100644 tko.io/src/content/docs/examples/odoom.md diff --git a/tko.io/astro.config.mjs b/tko.io/astro.config.mjs index 94713041..a3cdd081 100644 --- a/tko.io/astro.config.mjs +++ b/tko.io/astro.config.mjs @@ -29,7 +29,18 @@ export default defineConfig({ sidebar: [ { label: 'Introduction', slug: 'index' }, { label: 'Knockout 3 to 4 Guide', slug: '3to4' }, - { label: 'Examples', autogenerate: { directory: 'examples' } }, + { + label: 'Examples', + items: [ + { label: 'Overview', slug: 'examples' }, + { label: 'Honeycomb ↗', link: '/examples/honeycomb.html?view=example' }, + { label: 'odoom ↗', link: '/examples/odoom.html?view=example' }, + { label: 'Spreadsheet ↗', link: '/examples/spreadsheet.html?view=example' }, + { label: 'Signal Graph ↗', link: '/examples/signal-graph.html?view=example' }, + { label: 'Reactive Audio Mixer ↗', link: '/examples/audio-mixer.html?view=example' }, + { label: 'Form Engine ↗', link: '/examples/form-engine.html?view=example' } + ] + }, { label: 'Bindings', autogenerate: { directory: 'bindings' } }, { label: 'Observables', autogenerate: { directory: 'observables' } }, { label: 'Computed', autogenerate: { directory: 'computed' } }, diff --git a/tko.io/public/examples/audio-mixer.html b/tko.io/public/examples/audio-mixer.html new file mode 100644 index 00000000..9968f4ff --- /dev/null +++ b/tko.io/public/examples/audio-mixer.html @@ -0,0 +1,930 @@ + + + + + + Reactive Audio Mixer + + + + +
+
+
+

Reactive Audio Mixer

+

Observable controls, derived mix state, and meters that stay in sync.

+

+ Five channels drive a live control surface: gain sliders, mute and solo toggles, master bus + readouts, and clipping/headroom feedback all stay reactive without ad-hoc DOM syncing. +

+
+
+ +
+ Mix state + +
+
+
+ +
+
+
+
+

Channel rack

+

Each strip is driven by its own observables.

+
+
+ Channels live + +
+
+ +
    +
  • +
    +
    +
    +
    +
    +
    + +
    + + +
    + +
    + + +
    + +
    +
    +
    +
    +
    +
    + +
    + + +
    +
  • +
+
+ + +
+
+ + + + + diff --git a/tko.io/public/examples/form-engine.html b/tko.io/public/examples/form-engine.html new file mode 100644 index 00000000..00cea303 --- /dev/null +++ b/tko.io/public/examples/form-engine.html @@ -0,0 +1,1184 @@ + + + + + + Form Engine + + + + +
+
+
+

Form Engine

+
+ A product-style form that keeps validation, dirty state, derived summaries, and async save flow in observables. + The visible UI is reactive, but the state remains simple. +
+
+ +
+
+
Dirty fields
+
+
changes since the last save
+
+
+
Validation errors
+
+
blocking save right now
+
+
+
Estimated monthly
+
+
derived from plan, seats, and add-ons
+
+
+
Completion
+
+
required fields in good shape
+
+
+
Save state
+
+
+
+
+ +
+
+
+
+
+

Account

+
Primary identity and contact details.
+
+
+
+

Organization

+
+
+ + +
Shown on invoices and the internal summary cards.
+
+
+
+ + +
The person responsible for the draft.
+
+
+
+
+ +
+

Contact

+
+
+ + +
Used for save confirmations and review notes.
+
+
+
+ + +
Separate from the primary contact if needed.
+
+
+
+
+
+
+ +
+
+

Project

+
Timeline, scope, and delivery pressure.
+
+
+
+

Timeline

+
+
+ + +
Used as the main label in the summary.
+
+
+
+
+ + +
+
+
+ + +
Raises the derived monthly estimate when urgent.
+
+
+
+
+
+ +
+

Scope

+
+
+ + +
Feeds the derived pricing summary.
+
+
+ +
+ + +
Affects the per-seat estimate.
+
+
+ +
+ + + +
+
+
+
+
+ +
+
+

Notes

+
Freeform context for reviewers and implementers.
+
+
+
+ + +
Optional, but useful when the form becomes a real intake flow.
+
+
+
+
+
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+ + +
+
+
+ + + + + diff --git a/tko.io/public/examples/honeycomb.html b/tko.io/public/examples/honeycomb.html index db9fb42d..debd4ec8 100644 --- a/tko.io/public/examples/honeycomb.html +++ b/tko.io/public/examples/honeycomb.html @@ -5,6 +5,8 @@ Honeycomb Locality +

Honeycomb

-
Counting Virtual DOM Comparisons vs Observable Updates
+
Counting Naive Virtual DOM Comparisons vs Observable Updates
diff --git a/tko.io/public/examples/odoom.html b/tko.io/public/examples/odoom.html index 47dfe32a..b28e1c0b 100644 --- a/tko.io/public/examples/odoom.html +++ b/tko.io/public/examples/odoom.html @@ -5,6 +5,8 @@ odoom +
diff --git a/tko.io/public/examples/signal-graph.html b/tko.io/public/examples/signal-graph.html new file mode 100644 index 00000000..e99282de --- /dev/null +++ b/tko.io/public/examples/signal-graph.html @@ -0,0 +1,1041 @@ + + + + + + Signal Graph + + + + + +
+
+
+

Signal Graph

+
+ A computed dependency map: mutate one input and only the downstream branch lights up. + The graph shows actual observable changes and computed recomputes, not generic UI noise. +
+
+ + + +
+
+ +
+
+
Focused input
+
+
the input observable you just changed
+
+ +
+
Downstream nodes
+
+
only nodes reachable from the focused input
+
+ +
+
Propagation hits
+
+
writes plus computed recomputes observed so far
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ Use the mutation buttons above the graph. The amber input lane is the only place state originates; + everything else is downstream computation. +
+
+ One source → + downstream only +
+
+
+ + + + diff --git a/tko.io/public/examples/spreadsheet.html b/tko.io/public/examples/spreadsheet.html new file mode 100644 index 00000000..c9a31c63 --- /dev/null +++ b/tko.io/public/examples/spreadsheet.html @@ -0,0 +1,1290 @@ + + + + + + Spreadsheet + + + + +
+
+
+
+
+

Spreadsheet

+
+ One-shot by AI: cells are observables, formula cells are computeds, and one edit ripples through + the sheet. +
+
+
TKO dependency engine
+
+ +
+
+
Selected
+
+
+
+
Type
+
+
+
+
Value
+
+
+
+
Direct inputs
+
+
+
+ +
+
+
+
Formula Bar
+
+
+
+
+ +
+
Literal cells are observables. Formula cells are computeds that read other cells through the same reactive graph.
+
+ +
+
+
+
+ + +
+
+ +
+
+
+ +
+ +
+ +
+
+
+ + + +
+
+
+
+
+ + +
+
+ + + + + diff --git a/tko.io/src/content/docs/examples/honeycomb.md b/tko.io/src/content/docs/examples/honeycomb.md deleted file mode 100644 index e66f64c4..00000000 --- a/tko.io/src/content/docs/examples/honeycomb.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Honeycomb -description: Count virtual DOM comparisons against observable updates on a dense interactive board. -sidebar: - order: 1 ---- - -# Honeycomb - -This example contrasts two mental models: - -- `Virtual DOM comparisons`: reconsider the visible board to discover what changed. -- `Observable updates`: notify only the local cells affected by the interaction. - -Hover shows a single local update. Click shows a burst of local updates. - -
- -
- -[Open the example directly](/examples/honeycomb.html) diff --git a/tko.io/src/content/docs/examples/index.md b/tko.io/src/content/docs/examples/index.md index db21183d..0f8d7233 100644 --- a/tko.io/src/content/docs/examples/index.md +++ b/tko.io/src/content/docs/examples/index.md @@ -6,8 +6,6 @@ sidebar: order: 0 --- -# Examples - These examples are meant to make TKO's model visible. - They focus on observables, bindings, and update locality. @@ -16,8 +14,12 @@ These examples are meant to make TKO's model visible. ## Available examples -- [Honeycomb](/examples/honeycomb/) -- [odoom](/examples/odoom/) +- [Honeycomb](/examples/honeycomb.html?view=example) +- [odoom](/examples/odoom.html?view=example) +- [Spreadsheet](/examples/spreadsheet.html?view=example) +- [Signal Graph](/examples/signal-graph.html?view=example) +- [Reactive Audio Mixer](/examples/audio-mixer.html?view=example) +- [Form Engine](/examples/form-engine.html?view=example) ## Why this section exists diff --git a/tko.io/src/content/docs/examples/odoom.md b/tko.io/src/content/docs/examples/odoom.md deleted file mode 100644 index 08597ebd..00000000 --- a/tko.io/src/content/docs/examples/odoom.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: odoom -description: A tiny WebGL corridor crawler with an observable HUD and controls. -sidebar: - order: 2 ---- - -# odoom - -`odoom` is a deliberately small experiment: WebGL handles the corridor view, while TKO observables drive the HUD, controls, minimap, and gameplay counters. - -It is not a full Doom clone. It is a compact example of using observables as the state layer around a fast rendering surface. - -
- -
- -[Open the example directly](/examples/odoom.html) From a86cf4805db9355ad672358fe9db9b7a33833755 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 09:41:48 -0400 Subject: [PATCH 06/15] Clarify applyBindings mount guidance --- tko.io/public/agent-guide.md | 2 ++ tko.io/public/agents/guide.md | 2 ++ tko.io/public/llms.txt | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tko.io/public/agent-guide.md b/tko.io/public/agent-guide.md index 3eee6cf9..e587cc73 100644 --- a/tko.io/public/agent-guide.md +++ b/tko.io/public/agent-guide.md @@ -31,6 +31,8 @@ ko.when(() => viewModel.isReady() && viewModel.hasData()).then(() => console.log Activate with `ko.applyBindings(viewModel, element)`. +Looking up that mount element with `document.getElementById(...)`, `querySelector(...)`, or another host reference is normal. The important rule is that once bindings are active, observable state should remain the source of truth instead of ad-hoc DOM mutation. + Syntax: `data-bind="bindingName: expression"` on HTML elements. text, html, visible, hidden, css:{class:bool}, style:{prop:val}, attr:{name:val}, diff --git a/tko.io/public/agents/guide.md b/tko.io/public/agents/guide.md index c3fc3eea..a7d7cd06 100644 --- a/tko.io/public/agents/guide.md +++ b/tko.io/public/agents/guide.md @@ -53,10 +53,12 @@ Binding notes: When the goal is to demonstrate TKO itself, keep the state flow inside observables, computeds, and bindings. +- It is normal to look up a mount element with `document.getElementById(...)`, `querySelector(...)`, or another host-framework reference so you can call `ko.applyBindings(viewModel, element)`. - Prefer `text`, `css`, `attr`, `event`, `foreach`, and `pureComputed` over manual DOM writes. - Avoid driving visible state with `textContent`, `innerHTML`, `classList`, or ad-hoc `addEventListener` when bindings can express the same behavior. - Use custom `bindingHandlers` only for DOM-specific effects that do not belong in the state layer, such as animation, focus, canvas, SVG, or third-party widget integration. - 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. ## Classic data-bind parsing and CSP diff --git a/tko.io/public/llms.txt b/tko.io/public/llms.txt index ead773dd..5391be02 100644 --- a/tko.io/public/llms.txt +++ b/tko.io/public/llms.txt @@ -43,6 +43,7 @@ - Derived `ko-*` values must stay observable or computed. `ko-text={price() > 50 ? 'expensive' : 'cheap'}` freezes; use a computed. - `ko.applyBindings(...)` returns a `Promise`. - TKO connects observable state to the DOM; `bindingHandlers` are the DOM/state bridge. +- Looking up a mount element with `document.getElementById(...)` (or similar) in order to call `ko.applyBindings(viewModel, element)` is normal and expected. What to avoid is driving reactive UI state through ad-hoc DOM mutation once bindings are active. ## Package Routing @@ -68,6 +69,7 @@ - Bindings are the DOM integration layer: they read state, update DOM, and write user-driven changes back to state. - `bindingHandlers` are the bridge between the DOM and the observable state layer. - `ko.applyBindings(viewModel, element)` activates that bridge on an existing DOM subtree. +- It is acceptable to locate that mount subtree with `document.getElementById(...)`, `querySelector(...)`, or a framework-provided element reference before calling `ko.applyBindings(...)`. - In TSX, `tko.jsx.render()` creates DOM nodes and `ko.applyBindings({}, root)` then activates the `ko-*` bindings on that rendered DOM. ## Two Binding Syntaxes From a1b73866d8412a15a184b4329c6850d779967426 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 10:39:52 -0400 Subject: [PATCH 07/15] Clarify agent contract and simplify odoom --- .gitignore | 1 + tko.io/public/agent-guide.md | 2 + tko.io/public/agents/contract.md | 93 +++++++ tko.io/public/agents/guide.md | 3 + tko.io/public/examples/odoom.html | 449 ++++++++++++++++-------------- tko.io/public/llms.txt | 5 + 6 files changed, 344 insertions(+), 209 deletions(-) create mode 100644 tko.io/public/agents/contract.md diff --git a/.gitignore b/.gitignore index 76507a2f..bba36d91 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ builds/**/meta .vscode/settings.json .playwright-mcp .playwright-cli +test-results/ diff --git a/tko.io/public/agent-guide.md b/tko.io/public/agent-guide.md index e587cc73..9a512036 100644 --- a/tko.io/public/agent-guide.md +++ b/tko.io/public/agent-guide.md @@ -1,5 +1,7 @@ # TKO Agent Guide +For preferred state/binding/DOM architecture in examples and prototypes, read `/agents/contract.md`. + ## Setup ```html diff --git a/tko.io/public/agents/contract.md b/tko.io/public/agents/contract.md new file mode 100644 index 00000000..9e88e8a9 --- /dev/null +++ b/tko.io/public/agents/contract.md @@ -0,0 +1,93 @@ +# TKO Agent Contract + +Use this file when deciding how state, bindings, and DOM work should be divided in TKO examples and prototypes. + +## Core Rule + +- Keep app state in observables, observableArrays, and computeds. +- Let standard bindings and custom bindings carry that state to the DOM. +- Use DOM lookup only to find the mount root for `ko.applyBindings(viewModel, element)`. + +## Replace X With Y + +- This section is about replacing ad-hoc DOM/event/state handling with bindings, not about binding-syntax style. +- If you are about to do `element.textContent = value`, use the `text` binding. +- If you are about to do `element.innerHTML = markup`, first ask whether the content should be plain text instead; prefer the `text` binding by default. Use `html` only when rendering trusted HTML is truly the point. +- If you are about to manually create, replace, or reconcile a repeated set of child nodes, use `foreach`. +- If you are about to toggle classes with `classList`, use `css`. +- If you are about to set attributes manually, use `attr`. +- If you are about to set inline styles from state, use `style`. +- If you are about to wire ordinary UI events with imperative listeners, use `click`, `event:{...}`, `value`, `textInput`, `checked`, or related built-in bindings. +- If you are about to call `focus()` or manage focus from state, use `hasFocus` when it fits. +- If you are about to mirror user input into plain mutable objects, store that input in observables instead. +- If you are about to keep counters, highlights, or explanatory UI state outside observables, move them into observables/computeds so the example demonstrates TKO rather than bypassing it. + +## When Custom Bindings Are The Right Tool + +Use a custom `bindingHandler` when the work is inherently DOM-specific and does not belong in the state layer. + +Typical good fits: +- canvas drawing +- WebGL rendering +- SVG-specific effects +- animation +- resize / measurement +- focus orchestration when `hasFocus` is not enough +- third-party widget integration + +In those cases: +- let observables remain the source of truth +- let the custom binding read observables and update the DOM +- avoid making the custom binding the authoritative owner of app state + +## Security Preference + +- Prefer `text` over `html`. +- Treat `html` as an exception for trusted markup, not the default way to render content. +- If the content originates from users, external services, or mixed trust levels, do not pass it through `html` unless it has been explicitly sanitized for that purpose. + +## Mounting Is Allowed + +These are normal: + +```js +const root = document.getElementById('app') +ko.applyBindings(viewModel, root) +``` + +```js +const root = container.querySelector('[data-app-root]') +ko.applyBindings(viewModel, root) +``` + +The contract is not “never touch `document`”. +The contract is “do not let ad-hoc DOM mutation become your reactive state system”. + +## Binding Syntax Preference + +- `ko-*` and `data-bind` are both valid binding surfaces. +- The choice between them is primarily stylistic / authoring-oriented unless you specifically need classic provider-driven strings or are teaching the classic syntax directly. + +## Render Loops + +Render loops are acceptable when they belong to rendering: +- `requestAnimationFrame` +- canvas redraws +- WebGL frame submission +- resize observers + +Prefer this split: +- state object: observables + domain actions +- renderer / custom binding: DOM, canvas, WebGL, RAF + +## Tests + +Direct DOM reads are fine in tests and verification code. +Example: + +```js +ko.applyBindings(vm, document.getElementById('app')) +console.assert(document.querySelector('#app span').textContent === 'Hello') +``` + +That is verification code, not the app’s state/update architecture. diff --git a/tko.io/public/agents/guide.md b/tko.io/public/agents/guide.md index a7d7cd06..d5f363d9 100644 --- a/tko.io/public/agents/guide.md +++ b/tko.io/public/agents/guide.md @@ -2,6 +2,8 @@ Test-backed behavior summaries live under `/agents/verified-behaviors/`. Treat those files as the contract layer when prose docs and implementation need reconciliation. +For preferred state/binding/DOM architecture in examples and prototypes, read `/agents/contract.md`. + ## Setup ```html @@ -53,6 +55,7 @@ Binding notes: When the goal is to demonstrate TKO itself, keep the state flow inside observables, computeds, and bindings. +- If you want a replacement-oriented checklist for DOM/state decisions, use `/agents/contract.md`. - It is normal to look up a mount element with `document.getElementById(...)`, `querySelector(...)`, or another host-framework reference so you can call `ko.applyBindings(viewModel, element)`. - Prefer `text`, `css`, `attr`, `event`, `foreach`, and `pureComputed` over manual DOM writes. - Avoid driving visible state with `textContent`, `innerHTML`, `classList`, or ad-hoc `addEventListener` when bindings can express the same behavior. diff --git a/tko.io/public/examples/odoom.html b/tko.io/public/examples/odoom.html index b28e1c0b..f17c609c 100644 --- a/tko.io/public/examples/odoom.html +++ b/tko.io/public/examples/odoom.html @@ -356,7 +356,12 @@ -
+
@@ -366,7 +371,7 @@

odoom

-
One-shot by AI: a tiny WebGL corridor crawler with an observable HUD.
+
A tiny WebGL corridor crawler with an observable HUD.
@@ -631,212 +636,230 @@

Minimap

return program } - function createViewModel() { - const runtime = { - map: baseMap.slice(), - input: Object.create(null), - renderTime: 0, - raf: 0, - gl: null, - wallTexture: null, - resizeObserver: null, - minimapDraw: null, - playerX: 1.5, - playerY: 1.5, - angle: 0.15 - } - - const vm = { - runtime, - health: ko.observable(88), - ammo: ko.observable(24), - cells: ko.observable(0), - fps: ko.observable(0), - message: ko.observable('Click the viewport, then move with WASD. Use Space to fire.'), - minimapVisible: ko.observable(true), - fov: ko.observable(68), - pixelScale: ko.observable(2), - muzzleFlash: ko.observable(0), - playerX: ko.observable(runtime.playerX), - playerY: ko.observable(runtime.playerY), - angle: ko.observable(runtime.angle), - healthLabel: ko.pureComputed(() => numberFormatter.format(vm.health())), - ammoLabel: ko.pureComputed(() => numberFormatter.format(vm.ammo())), - cellsLabel: ko.pureComputed(() => numberFormatter.format(vm.cells())), - fpsLabel: ko.pureComputed(() => numberFormatter.format(Math.round(vm.fps()))), - headingLabel: ko.pureComputed(() => `${Math.round(((vm.angle() * 180) / Math.PI + 360) % 360)}°`), - fovLabel: ko.pureComputed(() => `${vm.fov()}°`), - pixelScaleLabel: ko.pureComputed(() => `${vm.pixelScale()}x`), - muzzleOpacity: ko.pureComputed(() => vm.muzzleFlash().toFixed(3)), - minimapLabel: ko.pureComputed(() => (vm.minimapVisible() ? 'Hide minimap' : 'Show minimap')), - fovInput: ko.pureComputed({ - read: () => String(vm.fov()), - write: value => vm.fov(Number(value)) - }), - pixelScaleInput: ko.pureComputed({ - read: () => String(vm.pixelScale()), - write: value => vm.pixelScale(Number(value)) - }), - toggleMinimap() { - vm.minimapVisible(!vm.minimapVisible()) - }, - resetRun() { - runtime.map = baseMap.slice() - runtime.playerX = 1.5 - runtime.playerY = 1.5 - runtime.angle = 0.15 - vm.playerX(runtime.playerX) - vm.playerY(runtime.playerY) - vm.angle(runtime.angle) - vm.health(88) - vm.ammo(24) - vm.cells(0) - vm.muzzleFlash(0) - vm.message('Run reset. Click the viewport and move.') - if (runtime.gl && runtime.wallTexture) { - updateWallTexture(runtime.gl, runtime.wallTexture, runtime.map) - } - if (runtime.minimapDraw) runtime.minimapDraw() - } - } - - return vm + const inputBindings = { + KeyW: 'forward', + KeyS: 'back', + KeyA: 'turnLeft', + ArrowLeft: 'turnLeft', + KeyD: 'turnRight', + ArrowRight: 'turnRight', + ShiftLeft: 'sprint', + ShiftRight: 'sprint' } - function handlePickup(vm) { - const { runtime } = vm - const cx = Math.floor(runtime.playerX) - const cy = Math.floor(runtime.playerY) - const index = cy * MAP_W + cx - const cell = runtime.map[index] - - if (cell === AMMO) { - runtime.map[index] = 0 - vm.ammo(vm.ammo() + 12) - vm.message('Picked up shells. Observable ammo updated immediately.') - } else if (cell === MEDKIT) { - runtime.map[index] = 0 - vm.health(Math.min(100, vm.health() + 18)) - vm.message('Medkit grabbed. HUD updated through observables.') - } else if (cell === CELL) { - runtime.map[index] = 0 - vm.cells(vm.cells() + 1) - vm.message('Energy cell secured. WebGL view unchanged, HUD reacts instantly.') - } else { - return + class ODoomState { + #renderer = null + + constructor() { + this.mapData = ko.observable(baseMap.slice()) + this.health = ko.observable(88) + this.ammo = ko.observable(24) + this.cells = ko.observable(0) + this.fps = ko.observable(0) + this.message = ko.observable('Click the viewport, then move with WASD. Use Space to fire.') + this.minimapVisible = ko.observable(true) + this.fov = ko.observable(68) + this.pixelScale = ko.observable(2) + this.muzzleFlash = ko.observable(0) + this.playerX = ko.observable(1.5) + this.playerY = ko.observable(1.5) + this.angle = ko.observable(0.15) + this.forward = ko.observable(false) + this.back = ko.observable(false) + this.turnLeft = ko.observable(false) + this.turnRight = ko.observable(false) + this.sprint = ko.observable(false) + this.viewportFocused = ko.observable(false) + + this.healthLabel = ko.pureComputed(() => numberFormatter.format(this.health())) + this.ammoLabel = ko.pureComputed(() => numberFormatter.format(this.ammo())) + this.cellsLabel = ko.pureComputed(() => numberFormatter.format(this.cells())) + this.fpsLabel = ko.pureComputed(() => numberFormatter.format(Math.round(this.fps()))) + this.headingLabel = ko.pureComputed(() => `${Math.round(((this.angle() * 180) / Math.PI + 360) % 360)}°`) + this.fovLabel = ko.pureComputed(() => `${this.fov()}°`) + this.pixelScaleLabel = ko.pureComputed(() => `${this.pixelScale()}x`) + this.muzzleOpacity = ko.pureComputed(() => this.muzzleFlash().toFixed(3)) + this.minimapLabel = ko.pureComputed(() => (this.minimapVisible() ? 'Hide minimap' : 'Show minimap')) + this.fovInput = ko.pureComputed({ + read: () => String(this.fov()), + write: value => this.fov(Number(value)) + }) + this.pixelScaleInput = ko.pureComputed({ + read: () => String(this.pixelScale()), + write: value => this.pixelScale(Number(value)) + }) } - if (runtime.gl && runtime.wallTexture) { - updateWallTexture(runtime.gl, runtime.wallTexture, runtime.map) + setRenderer(renderer) { + this.#renderer = renderer } - if (runtime.minimapDraw) runtime.minimapDraw() - } - function updatePlayer(vm, dt) { - const { runtime } = vm - const input = runtime.input - const sprint = input.ShiftLeft || input.ShiftRight - const moveSpeed = (sprint ? 3.4 : 2.2) * dt - const turnSpeed = 1.9 * dt - - if (input.KeyA || input.ArrowLeft) runtime.angle -= turnSpeed - if (input.KeyD || input.ArrowRight) runtime.angle += turnSpeed - - let moveX = 0 - let moveY = 0 - const dirX = Math.cos(runtime.angle) - const dirY = Math.sin(runtime.angle) - const strafeX = Math.cos(runtime.angle + Math.PI / 2) - const strafeY = Math.sin(runtime.angle + Math.PI / 2) - - if (input.KeyW) { - moveX += dirX * moveSpeed - moveY += dirY * moveSpeed + get renderer() { + return this.#renderer } - if (input.KeyS) { - moveX -= dirX * moveSpeed - moveY -= dirY * moveSpeed + + setInputFlag = (code, value) => { + const intent = inputBindings[code] + if (intent) { + this[intent](value) + } } - const nextX = runtime.playerX + moveX - const nextY = runtime.playerY + moveY - if (!wallAt(runtime.map, Math.floor(nextX), Math.floor(runtime.playerY))) { - runtime.playerX = nextX + toggleMinimap = () => { + this.minimapVisible(!this.minimapVisible()) } - if (!wallAt(runtime.map, Math.floor(runtime.playerX), Math.floor(nextY))) { - runtime.playerY = nextY + + resetRun = () => { + this.mapData(baseMap.slice()) + this.playerX(1.5) + this.playerY(1.5) + this.angle(0.15) + this.health(88) + this.ammo(24) + this.cells(0) + this.muzzleFlash(0) + this.message('Run reset. Click the viewport and move.') + this.forward(false) + this.back(false) + this.turnLeft(false) + this.turnRight(false) + this.sprint(false) + this.#renderer?.resetTiming() } - vm.playerX(runtime.playerX) - vm.playerY(runtime.playerY) - vm.angle(runtime.angle) - vm.muzzleFlash(Math.max(0, vm.muzzleFlash() - dt * 2.7)) - handlePickup(vm) - } + handlePickup = () => { + const map = this.mapData() + const cx = Math.floor(this.playerX()) + const cy = Math.floor(this.playerY()) + const index = cy * MAP_W + cx + const cell = map[index] + let nextMap = null + + if (cell === AMMO) { + nextMap = map.slice() + nextMap[index] = 0 + this.ammo(this.ammo() + 12) + this.message('Picked up shells. Observable ammo updated immediately.') + } else if (cell === MEDKIT) { + nextMap = map.slice() + nextMap[index] = 0 + this.health(Math.min(100, this.health() + 18)) + this.message('Medkit grabbed. HUD updated through observables.') + } else if (cell === CELL) { + nextMap = map.slice() + nextMap[index] = 0 + this.cells(this.cells() + 1) + this.message('Energy cell secured. WebGL view unchanged, HUD reacts instantly.') + } else { + return + } - function fireWeapon(vm) { - if (vm.ammo() <= 0) { - vm.message('Click. Empty.') - return + this.mapData(nextMap) } - vm.ammo(vm.ammo() - 1) - vm.muzzleFlash(1) - vm.message('Fired a round. Ammo observable changed, WebGL flash reacted.') - } + updatePlayer = dt => { + const map = this.mapData() + const moveSpeed = (this.sprint() ? 3.4 : 2.2) * dt + const turnSpeed = 1.9 * dt + let angle = this.angle() - ko.bindingHandlers.odoomController = { - init(element, valueAccessor) { - const vm = valueAccessor() + if (this.turnLeft()) angle -= turnSpeed + if (this.turnRight()) angle += turnSpeed - function onKeyDown(event) { - vm.runtime.input[event.code] = true - if (event.code === 'Space') { - event.preventDefault() - fireWeapon(vm) - } + let moveX = 0 + let moveY = 0 + const dirX = Math.cos(angle) + const dirY = Math.sin(angle) + + if (this.forward()) { + moveX += dirX * moveSpeed + moveY += dirY * moveSpeed + } + if (this.back()) { + moveX -= dirX * moveSpeed + moveY -= dirY * moveSpeed } - function onKeyUp(event) { - vm.runtime.input[event.code] = false + let playerX = this.playerX() + let playerY = this.playerY() + const nextX = playerX + moveX + const nextY = playerY + moveY + + if (!wallAt(map, Math.floor(nextX), Math.floor(playerY))) { + playerX = nextX + } + if (!wallAt(map, Math.floor(playerX), Math.floor(nextY))) { + playerY = nextY } - function onPointerDown() { - element.focus() + this.playerX(playerX) + this.playerY(playerY) + this.angle(angle) + this.muzzleFlash(Math.max(0, this.muzzleFlash() - dt * 2.7)) + this.handlePickup() + } + + fireWeapon = () => { + if (this.ammo() <= 0) { + this.message('Click. Empty.') + return } - element.addEventListener('keydown', onKeyDown) - element.addEventListener('keyup', onKeyUp) - element.addEventListener('pointerdown', onPointerDown) + this.ammo(this.ammo() - 1) + this.muzzleFlash(1) + this.message('Fired a round. Ammo observable changed, WebGL flash reacted.') + } - ko.utils.domNodeDisposal.addDisposeCallback(element, () => { - element.removeEventListener('keydown', onKeyDown) - element.removeEventListener('keyup', onKeyUp) - element.removeEventListener('pointerdown', onPointerDown) - }) + handleKeyDown = (_, event) => { + this.setInputFlag(event.code, true) + if (event.code === 'Space') { + event.preventDefault() + this.fireWeapon() + } + } + + handleKeyUp = (_, event) => { + this.setInputFlag(event.code, false) + } + + focusViewport = () => { + this.viewportFocused(true) } } - ko.bindingHandlers.odoomMinimap = { - init(canvas, valueAccessor) { - const vm = valueAccessor() + class ODoomRenderer { + #state + #renderTime = 0 + #raf = 0 + #resizeObserver = null + + constructor(state) { + this.#state = state + } + + resetTiming() { + this.#renderTime = 0 + } + + bindMinimap(canvas) { const ctx = canvas.getContext('2d') const size = canvas.width / MAP_W - function draw() { + const draw = () => { + const map = this.#state.mapData() ctx.clearRect(0, 0, canvas.width, canvas.height) for (let y = 0; y < MAP_H; y += 1) { for (let x = 0; x < MAP_W; x += 1) { - const cell = vm.runtime.map[y * MAP_W + x] + const cell = map[y * MAP_W + x] ctx.fillStyle = cell === WALL ? '#172131' : cell === AMMO || cell === MEDKIT || cell === CELL ? '#63efbe' : '#0b1017' ctx.fillRect(x * size, y * size, size - 1, size - 1) } } - const px = vm.playerX() * size - const py = vm.playerY() * size + const px = this.#state.playerX() * size + const py = this.#state.playerY() * size ctx.fillStyle = '#ffffff' ctx.beginPath() ctx.arc(px, py, 5, 0, Math.PI * 2) @@ -846,29 +869,26 @@

Minimap

ctx.lineWidth = 2 ctx.beginPath() ctx.moveTo(px, py) - ctx.lineTo(px + Math.cos(vm.angle()) * 16, py + Math.sin(vm.angle()) * 16) + ctx.lineTo(px + Math.cos(this.#state.angle()) * 16, py + Math.sin(this.#state.angle()) * 16) ctx.stroke() } - vm.runtime.minimapDraw = draw const redraw = ko.computed(() => { - vm.playerX() - vm.playerY() - vm.angle() - vm.minimapVisible() - vm.cells() - vm.ammo() - vm.health() + this.#state.mapData() + this.#state.playerX() + this.#state.playerY() + this.#state.angle() + this.#state.minimapVisible() + this.#state.cells() + this.#state.ammo() + this.#state.health() draw() }) ko.utils.domNodeDisposal.addDisposeCallback(canvas, () => redraw.dispose()) } - } - ko.bindingHandlers.odoomScene = { - init(canvas, valueAccessor) { - const vm = valueAccessor() + bindScene(canvas) { const gl = canvas.getContext('webgl', { antialias: false, alpha: false }) if (!gl) throw new Error('WebGL unavailable') @@ -877,9 +897,7 @@

Minimap

gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW) - const wallTexture = createWallTexture(gl, vm.runtime.map) - vm.runtime.gl = gl - vm.runtime.wallTexture = wallTexture + const wallTexture = createWallTexture(gl, this.#state.mapData()) const positionLocation = gl.getAttribLocation(program, 'aPosition') const resolutionLocation = gl.getUniformLocation(program, 'uResolution') @@ -890,8 +908,8 @@

Minimap

const timeLocation = gl.getUniformLocation(program, 'uTime') const flashLocation = gl.getUniformLocation(program, 'uFlash') - function resize() { - const scale = vm.pixelScale() + const resize = () => { + const scale = this.#state.pixelScale() const width = Math.max(320, Math.floor(canvas.clientWidth / scale)) const height = Math.max(180, Math.floor(canvas.clientHeight / scale)) if (canvas.width !== width || canvas.height !== height) { @@ -902,28 +920,31 @@

Minimap

} const resizeComputed = ko.computed(() => { - vm.pixelScale() + this.#state.pixelScale() resize() }) - const observer = new ResizeObserver(() => resize()) - observer.observe(canvas) - vm.runtime.resizeObserver = observer + this.#resizeObserver = new ResizeObserver(() => resize()) + this.#resizeObserver.observe(canvas) + + const textureComputed = ko.computed(() => { + updateWallTexture(gl, wallTexture, this.#state.mapData()) + }) - function renderFrame(now) { - if (!vm.runtime.renderTime) vm.runtime.renderTime = now - const dt = Math.min(0.05, (now - vm.runtime.renderTime) / 1000) - vm.runtime.renderTime = now + const renderFrame = now => { + if (!this.#renderTime) this.#renderTime = now + const dt = Math.min(0.05, (now - this.#renderTime) / 1000) + this.#renderTime = now - updatePlayer(vm, dt) - vm.fps(1 / Math.max(dt, 0.001)) + this.#state.updatePlayer(dt) + this.#state.fps(1 / Math.max(dt, 0.001)) resize() - const angle = vm.angle() + const angle = this.#state.angle() const dirX = Math.cos(angle) const dirY = Math.sin(angle) - const planeScale = Math.tan((vm.fov() * Math.PI) / 360) + const planeScale = Math.tan((this.#state.fov() * Math.PI) / 360) const planeX = -dirY * planeScale const planeY = dirX * planeScale @@ -936,31 +957,41 @@

Minimap

gl.bindTexture(gl.TEXTURE_2D, wallTexture) gl.uniform1i(wallMapLocation, 0) gl.uniform2f(resolutionLocation, canvas.width, canvas.height) - gl.uniform2f(playerLocation, vm.playerX(), vm.playerY()) + gl.uniform2f(playerLocation, this.#state.playerX(), this.#state.playerY()) gl.uniform2f(directionLocation, dirX, dirY) gl.uniform2f(planeLocation, planeX, planeY) gl.uniform1f(timeLocation, now * 0.001) - gl.uniform1f(flashLocation, vm.muzzleFlash()) + gl.uniform1f(flashLocation, this.#state.muzzleFlash()) gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) - if (vm.runtime.minimapDraw && vm.minimapVisible()) { - vm.runtime.minimapDraw() - } - - vm.runtime.raf = requestAnimationFrame(renderFrame) + this.#raf = requestAnimationFrame(renderFrame) } - vm.runtime.raf = requestAnimationFrame(renderFrame) + this.#raf = requestAnimationFrame(renderFrame) ko.utils.domNodeDisposal.addDisposeCallback(canvas, () => { - cancelAnimationFrame(vm.runtime.raf) + cancelAnimationFrame(this.#raf) resizeComputed.dispose() - observer.disconnect() + textureComputed.dispose() + this.#resizeObserver.disconnect() }) } } - const viewModel = createViewModel() + ko.bindingHandlers.odoomMinimap = { + init(canvas, valueAccessor) { + valueAccessor().renderer.bindMinimap(canvas) + } + } + + ko.bindingHandlers.odoomScene = { + init(canvas, valueAccessor) { + valueAccessor().renderer.bindScene(canvas) + } + } + + const viewModel = new ODoomState() + viewModel.setRenderer(new ODoomRenderer(viewModel)) ko.applyBindings(viewModel, appRoot) })() diff --git a/tko.io/public/llms.txt b/tko.io/public/llms.txt index 5391be02..0ad3c9cd 100644 --- a/tko.io/public/llms.txt +++ b/tko.io/public/llms.txt @@ -16,6 +16,8 @@ - Verified Behaviors Index: /agents/verified-behaviors/index.md Unit-test-backed behavior contract. Prefer this when behavior questions matter. +- Agent Contract: /agents/contract.md + Preferred state/binding/DOM split for TKO examples and prototypes. - Agent Guide: /agents/guide.md API reference, gotchas, and examples. - Agent Testing: /agents/testing.md @@ -28,6 +30,7 @@ ## Use This First - Behavior question or edge case: /agents/verified-behaviors/index.md +- State vs DOM architecture choice: /agents/contract.md - API usage or authoring pattern: /agents/guide.md - Verification or test flow: /agents/testing.md - Rapid prototype in-browser: /agents/sample-tsx.html @@ -56,6 +59,7 @@ - Verified Behaviors: /agents/verified-behaviors/index.md (package-scoped index) - Agent Guide: /agents/guide.md (API reference, gotchas, examples) +- Agent Contract: /agents/contract.md (preferred state/binding/DOM split for examples and prototypes) - Agent Testing: /agents/testing.md (how to run and verify TKO code) - Agent TSX Scaffold: /agents/sample-tsx.html (minimal browser TSX + esbuild scaffold for rapid prototype work) - Examples: /examples/ (interactive examples showing update locality and reactive behavior) @@ -80,6 +84,7 @@ TSX: `ko-text={msg}` — compile-time JSX expressions, needs esbuild + `tko.jsx. Inside `ko-foreach` children, binding-context vars use strings: `ko-text="$data"` (not `{$data}`) See /agents/guide.md for usage patterns. +See /agents/contract.md for preferred state vs DOM architecture. Use /agents/verified-behaviors/index.md for test-backed behavior contracts. ## Docs From 4894f78931299e89971b1c73a04721ab130ef15c Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 10:48:08 -0400 Subject: [PATCH 08/15] Ignore Claude worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bba36d91..6845c866 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ builds/**/meta .playwright-mcp .playwright-cli test-results/ +.claude/worktrees/ From 29e7aedb6513a08b749dc07181bb434151428b99 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 10:50:13 -0400 Subject: [PATCH 09/15] Remove audio mixer example --- tko.io/astro.config.mjs | 1 - tko.io/public/examples/audio-mixer.html | 930 ---------------------- tko.io/src/content/docs/examples/index.md | 1 - 3 files changed, 932 deletions(-) delete mode 100644 tko.io/public/examples/audio-mixer.html diff --git a/tko.io/astro.config.mjs b/tko.io/astro.config.mjs index a3cdd081..ada7eb48 100644 --- a/tko.io/astro.config.mjs +++ b/tko.io/astro.config.mjs @@ -37,7 +37,6 @@ export default defineConfig({ { label: 'odoom ↗', link: '/examples/odoom.html?view=example' }, { label: 'Spreadsheet ↗', link: '/examples/spreadsheet.html?view=example' }, { label: 'Signal Graph ↗', link: '/examples/signal-graph.html?view=example' }, - { label: 'Reactive Audio Mixer ↗', link: '/examples/audio-mixer.html?view=example' }, { label: 'Form Engine ↗', link: '/examples/form-engine.html?view=example' } ] }, diff --git a/tko.io/public/examples/audio-mixer.html b/tko.io/public/examples/audio-mixer.html deleted file mode 100644 index 9968f4ff..00000000 --- a/tko.io/public/examples/audio-mixer.html +++ /dev/null @@ -1,930 +0,0 @@ - - - - - - Reactive Audio Mixer - - - - -
-
-
-

Reactive Audio Mixer

-

Observable controls, derived mix state, and meters that stay in sync.

-

- Five channels drive a live control surface: gain sliders, mute and solo toggles, master bus - readouts, and clipping/headroom feedback all stay reactive without ad-hoc DOM syncing. -

-
-
- -
- Mix state - -
-
-
- -
-
-
-
-

Channel rack

-

Each strip is driven by its own observables.

-
-
- Channels live - -
-
- -
    -
  • -
    -
    -
    -
    -
    -
    - -
    - - -
    - -
    - - -
    - -
    -
    -
    -
    -
    -
    - -
    - - -
    -
  • -
-
- - -
-
- - - - - diff --git a/tko.io/src/content/docs/examples/index.md b/tko.io/src/content/docs/examples/index.md index 0f8d7233..a08ed601 100644 --- a/tko.io/src/content/docs/examples/index.md +++ b/tko.io/src/content/docs/examples/index.md @@ -18,7 +18,6 @@ These examples are meant to make TKO's model visible. - [odoom](/examples/odoom.html?view=example) - [Spreadsheet](/examples/spreadsheet.html?view=example) - [Signal Graph](/examples/signal-graph.html?view=example) -- [Reactive Audio Mixer](/examples/audio-mixer.html?view=example) - [Form Engine](/examples/form-engine.html?view=example) ## Why this section exists From e893cb4e753bd8a22385c60a8e13d771b90f8ad7 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 10:53:54 -0400 Subject: [PATCH 10/15] Describe examples overview --- tko.io/src/content/docs/examples/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tko.io/src/content/docs/examples/index.md b/tko.io/src/content/docs/examples/index.md index a08ed601..d508cb64 100644 --- a/tko.io/src/content/docs/examples/index.md +++ b/tko.io/src/content/docs/examples/index.md @@ -14,11 +14,11 @@ These examples are meant to make TKO's model visible. ## Available examples -- [Honeycomb](/examples/honeycomb.html?view=example) -- [odoom](/examples/odoom.html?view=example) -- [Spreadsheet](/examples/spreadsheet.html?view=example) -- [Signal Graph](/examples/signal-graph.html?view=example) -- [Form Engine](/examples/form-engine.html?view=example) +- [Honeycomb](/examples/honeycomb.html?view=example): Count naive virtual DOM comparisons against observable updates in a dense interactive field. +- [odoom](/examples/odoom.html?view=example): A tiny WebGL corridor crawler where the HUD, controls, and simulation state stay observable-driven. +- [Spreadsheet](/examples/spreadsheet.html?view=example): Cells as observables, formulas as computeds, and dependency propagation you can edit live. +- [Signal Graph](/examples/signal-graph.html?view=example): Mutate one input and watch only the downstream computed branch recompute. +- [Form Engine](/examples/form-engine.html?view=example): A realistic product form showing dirty state, validation, summaries, and save flow as reactive state. ## Why this section exists From f2fe3b32b074e1ee6d2635561eb5b5a004974b77 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 11:21:46 -0400 Subject: [PATCH 11/15] Center docs page headings --- tko.io/src/styles/tko.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tko.io/src/styles/tko.css b/tko.io/src/styles/tko.css index 0973624c..10faa4c0 100644 --- a/tko.io/src/styles/tko.css +++ b/tko.io/src/styles/tko.css @@ -106,6 +106,8 @@ h1, .content-panel h1 { font-size: clamp(1.85rem, 4vw, 2.75rem); font-weight: 700; + margin: 0; + line-height: 1; } .content-panel h2 { @@ -190,6 +192,9 @@ header { .content-panel .sl-markdown-content > .sl-heading-wrapper:first-child { margin-top: 0; + display: flex; + align-items: center; + min-height: 100%; } .content-panel a { From 8639f85c1041b138021f766bbb1b8f5713b56380 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 7 Apr 2026 11:35:00 -0400 Subject: [PATCH 12/15] Note self-contained HTML examples --- tko.io/public/llms.txt | 5 +++-- tko.io/src/content/docs/examples/index.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tko.io/public/llms.txt b/tko.io/public/llms.txt index 0ad3c9cd..cb86b143 100644 --- a/tko.io/public/llms.txt +++ b/tko.io/public/llms.txt @@ -25,7 +25,7 @@ - Agent TSX Scaffold: /agents/sample-tsx.html Minimal in-browser TSX + esbuild scaffold for rapid prototype work. - Examples: /examples/ - Interactive examples that show update locality and TKO's observable model. + Interactive self-contained HTML examples that show update locality and TKO's observable model. ## Use This First @@ -35,6 +35,7 @@ - Verification or test flow: /agents/testing.md - Rapid prototype in-browser: /agents/sample-tsx.html - Conceptual interactive examples: /examples/ + The examples are self-contained HTML files. ## Build Choice @@ -62,7 +63,7 @@ - Agent Contract: /agents/contract.md (preferred state/binding/DOM split for examples and prototypes) - Agent Testing: /agents/testing.md (how to run and verify TKO code) - Agent TSX Scaffold: /agents/sample-tsx.html (minimal browser TSX + esbuild scaffold for rapid prototype work) -- Examples: /examples/ (interactive examples showing update locality and reactive behavior) +- Examples: /examples/ (interactive self-contained HTML examples showing update locality and reactive behavior) - Playground: /playground - GitHub: https://github.com/knockout/tko diff --git a/tko.io/src/content/docs/examples/index.md b/tko.io/src/content/docs/examples/index.md index d508cb64..09d982a0 100644 --- a/tko.io/src/content/docs/examples/index.md +++ b/tko.io/src/content/docs/examples/index.md @@ -10,6 +10,7 @@ These examples are meant to make TKO's model visible. - They focus on observables, bindings, and update locality. - They are intentionally interactive. +- They are self-contained HTML files you can inspect, copy, or adapt directly. - They are designed to show what work is happening, not just what UI appears. ## Available examples From 1b409774f40d2bcfd555c1cd57d6c402938f306d Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Fri, 10 Apr 2026 14:13:23 -0400 Subject: [PATCH 13/15] Fix demo UX bugs and collapse examples sidebar Cancel in-flight save on form-engine restore, scope odoom focus handler to viewport, normalize lowercase cell refs in spreadsheet, and replace expanded sidebar group with a single Examples link. Co-Authored-By: Claude Opus 4.6 (1M context) --- tko.io/astro.config.mjs | 12 +----------- tko.io/public/examples/form-engine.html | 9 +++++++++ tko.io/public/examples/odoom.html | 4 ++-- tko.io/public/examples/spreadsheet.html | 12 ++++++------ 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/tko.io/astro.config.mjs b/tko.io/astro.config.mjs index ada7eb48..26d303f6 100644 --- a/tko.io/astro.config.mjs +++ b/tko.io/astro.config.mjs @@ -29,17 +29,7 @@ export default defineConfig({ sidebar: [ { label: 'Introduction', slug: 'index' }, { label: 'Knockout 3 to 4 Guide', slug: '3to4' }, - { - label: 'Examples', - items: [ - { label: 'Overview', slug: 'examples' }, - { label: 'Honeycomb ↗', link: '/examples/honeycomb.html?view=example' }, - { label: 'odoom ↗', link: '/examples/odoom.html?view=example' }, - { label: 'Spreadsheet ↗', link: '/examples/spreadsheet.html?view=example' }, - { label: 'Signal Graph ↗', link: '/examples/signal-graph.html?view=example' }, - { label: 'Form Engine ↗', link: '/examples/form-engine.html?view=example' } - ] - }, + { label: 'Examples', slug: 'examples' }, { label: 'Bindings', autogenerate: { directory: 'bindings' } }, { label: 'Observables', autogenerate: { directory: 'observables' } }, { label: 'Computed', autogenerate: { directory: 'computed' } }, diff --git a/tko.io/public/examples/form-engine.html b/tko.io/public/examples/form-engine.html index 00cea303..9f54e6ba 100644 --- a/tko.io/public/examples/form-engine.html +++ b/tko.io/public/examples/form-engine.html @@ -1109,6 +1109,15 @@

Save flow

} vm.restoreSample = function () { + if (saveFrame) { + cancelAnimationFrame(saveFrame) + saveFrame = 0 + } + if (savedResetTimer) { + clearTimeout(savedResetTimer) + savedResetTimer = 0 + } + vm.company.reset('Northstar Labs') vm.owner.reset('Avery Stone') vm.contactEmail.reset('avery@northstar.example') diff --git a/tko.io/public/examples/odoom.html b/tko.io/public/examples/odoom.html index f17c609c..2288c45a 100644 --- a/tko.io/public/examples/odoom.html +++ b/tko.io/public/examples/odoom.html @@ -360,9 +360,9 @@ class="odoom" id="appRoot" tabindex="0" - data-bind="hasFocus: viewportFocused, event: { keydown: handleKeyDown, keyup: handleKeyUp, pointerdown: focusViewport }" + data-bind="hasFocus: viewportFocused, event: { keydown: handleKeyDown, keyup: handleKeyUp }" > -
+
diff --git a/tko.io/public/examples/spreadsheet.html b/tko.io/public/examples/spreadsheet.html index c9a31c63..d7e61798 100644 --- a/tko.io/public/examples/spreadsheet.html +++ b/tko.io/public/examples/spreadsheet.html @@ -894,12 +894,12 @@

Mechanics

const refs = new Set() const body = text.slice(1) - for (const match of body.matchAll(/\b([A-F][1-6])\s*:\s*([A-F][1-6])\b/g)) { - expandRange(match[1], match[2]).forEach(ref => refs.add(ref)) + for (const match of body.matchAll(/\b([A-F][1-6])\s*:\s*([A-F][1-6])\b/gi)) { + expandRange(match[1].toUpperCase(), match[2].toUpperCase()).forEach(ref => refs.add(ref)) } - for (const match of body.matchAll(/\b([A-F][1-6])\b/g)) { - refs.add(match[1]) + for (const match of body.matchAll(/\b([A-F][1-6])\b/gi)) { + refs.add(match[1].toUpperCase()) } return [...refs] @@ -938,7 +938,7 @@

Mechanics

new RegExp(`\\b${source}\\s*\\(\\s*([A-F][1-6]\\s*:\\s*[A-F][1-6])\\s*\\)`, 'gi'), (_, range) => { const token = `__RANGE_${rangeTokens.length}__` - rangeTokens.push({ token, range: range.replace(/\s+/g, '') }) + rangeTokens.push({ token, range: range.replace(/\s+/g, '').toUpperCase() }) return `${alias}(${token})` } ) @@ -950,7 +950,7 @@

Mechanics

registerRangeFunction('MIN', 'MIN') registerRangeFunction('MAX', 'MAX') - normalized = normalized.replace(/\b([A-F][1-6])\b/g, (_, ref) => `C("${ref}")`) + normalized = normalized.replace(/\b([A-F][1-6])\b/gi, (_, ref) => `C("${ref.toUpperCase()}")`) for (const { token, range } of rangeTokens) { normalized = normalized.replace(token, `R("${range}")`) From 769505d4d9d130505b0b08b6417eb60e58f8777f Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 14 Apr 2026 13:09:16 -0400 Subject: [PATCH 14/15] Remove stale honeycomb.html from repo root Duplicate of tko.io/public/examples/honeycomb.html. The root copy was an earlier version (542 lines vs 619 lines) and would drift. Co-Authored-By: Claude Opus 4.6 (1M context) --- honeycomb.html | 542 ------------------------------------------------- 1 file changed, 542 deletions(-) delete mode 100644 honeycomb.html diff --git a/honeycomb.html b/honeycomb.html deleted file mode 100644 index 73940308..00000000 --- a/honeycomb.html +++ /dev/null @@ -1,542 +0,0 @@ - - - - - - Honeycomb Locality - - - -
-
-
-
-

Honeycomb

-
Counting Naive Virtual DOM Comparisons vs Observable Updates
-
- -
-
Visible Hexes
-
-
cells currently on screen
-
- -
-
DOM Comparisons · O(n)
-
-
hover: + all visible, click: + all visible × touched
-
- -
-
Observable Notifications · O(1)
-
-
hover: + 6 neighbors, click: + touched cells
-
-
-
- - - - - - - - - - - - - - -
-
-
- - - - - From 38b05bdd5caf25129a00d24d96058ff436c711a0 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 14 Apr 2026 13:17:27 -0400 Subject: [PATCH 15/15] Fix implicit global appRoot, guard NaN in form-engine - Use document.getElementById('appRoot') instead of implicit id global in honeycomb and odoom examples - Guard against NaN when seats input is non-numeric in form-engine Co-Authored-By: Claude Opus 4.6 (1M context) --- tko.io/public/examples/form-engine.html | 4 +++- tko.io/public/examples/honeycomb.html | 2 +- tko.io/public/examples/odoom.html | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tko.io/public/examples/form-engine.html b/tko.io/public/examples/form-engine.html index 9f54e6ba..72db5264 100644 --- a/tko.io/public/examples/form-engine.html +++ b/tko.io/public/examples/form-engine.html @@ -1069,7 +1069,9 @@

Save flow

} const priorityFee = vm.priority.value() === 'urgent' ? 18 : vm.priority.value() === 'low' ? -6 : 0 const addonFee = (vm.supportPack.value() ? 24 : 0) + (vm.auditLogs.value() ? 16 : 0) + (vm.csvExport.value() ? 10 : 0) - const monthly = planRates[vm.plan.value()] + Number(vm.seats.value()) * seatRates[vm.plan.value()] + priorityFee + addonFee + const parsedSeats = Number(vm.seats.value()) + const seatCount = Number.isFinite(parsedSeats) ? parsedSeats : 0 + const monthly = planRates[vm.plan.value()] + seatCount * seatRates[vm.plan.value()] + priorityFee + addonFee return Math.max(0, monthly) }) diff --git a/tko.io/public/examples/honeycomb.html b/tko.io/public/examples/honeycomb.html index debd4ec8..3b22a442 100644 --- a/tko.io/public/examples/honeycomb.html +++ b/tko.io/public/examples/honeycomb.html @@ -612,7 +612,7 @@

Honeycomb

} } - ko.applyBindings(viewModel, appRoot) + ko.applyBindings(viewModel, document.getElementById('appRoot')) })() diff --git a/tko.io/public/examples/odoom.html b/tko.io/public/examples/odoom.html index 2288c45a..1f844539 100644 --- a/tko.io/public/examples/odoom.html +++ b/tko.io/public/examples/odoom.html @@ -992,7 +992,7 @@

Minimap

const viewModel = new ODoomState() viewModel.setRenderer(new ODoomRenderer(viewModel)) - ko.applyBindings(viewModel, appRoot) + ko.applyBindings(viewModel, document.getElementById('appRoot')) })()